feat: LinkedIn POSSE syndication for blog, newsletter, podcast#398
Open
byte-the-bot wants to merge 2 commits into
Open
feat: LinkedIn POSSE syndication for blog, newsletter, podcast#398byte-the-bot wants to merge 2 commits into
byte-the-bot wants to merge 2 commits into
Conversation
…19f5318e76184459) Mirrors the Bluesky POSSE pipeline for LinkedIn: - New `publish-linkedin --kind <blog|newsletter|podcast> --dir <path>` command scans for items past the 2026-05-23 cutoff missing `linkedin_url:` in frontmatter, posts to LinkedIn's /rest/posts, writes the URN back as the new `linkedin_url:` field. - Custom post body: sibling `linkedin.md` file wins, then frontmatter `linkedin_content`, otherwise first paragraph of the body. Footer is always "New post on coreyja.com\n<canonical-url>". - OAuth tokens stored encrypted in new `LinkedInUsers` table (mirrors the Google integration). Lazy refresh at publish time; proactive 6h cron job (`RefreshLinkedInToken`) refreshes 7d before expiry and warns 30d before refresh-token expiry. - New `/admin/auth/linkedin` and `/admin/auth/linkedin/callback` admin routes complete the LinkedIn OAuth flow. CSRF protection uses a DB-backed `LinkedInOauthStates` table (matches existing `LinearOauthStates` pattern; avoids new dependencies that the plan's signed-cookie approach would require). - Admin dashboard shows LinkedIn auth status with expiry warnings. - New `.github/workflows/linkedin.yml` runs after successful Fly Deploy, publishes each kind with `continue-on-error: true`, commits the frontmatter updates back to main via App token, surfaces failures as a red workflow run for the next deploy to retry. - `LinkedInConfig::from_env_optional()` returns None when both env vars are unset (boot proceeds), Some when both set, Err when exactly one is set (prevents silent misconfiguration). - Posts crate filters sibling `linkedin.md` / `*.linkedin.md` files from `BlogPosts::from_dir` and `PodcastEpisodes::from_dir` so they're not parsed as posts at compile time. Notable deviations from the plan: - CSRF state uses a DB table (`LinkedInOauthStates`) instead of signed cookies. The existing codebase has no `SignedCookieJar` usage and Linear OAuth already uses this DB-state pattern. - Posts crate's `linkedin_content` doc comment uses `#[allow(clippy::doc_markdown)]` at module scope rather than backticking every "LinkedIn" mention. Required repo secrets before this workflow first runs: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, DATABASE_URL, ENCRYPTION_SECRET_KEY.
- OAuth state: enforce 10-minute TTL on callback validation, sweep stale
rows opportunistically, add idx_linkedin_oauth_states_created_at index
to support future cleanup jobs (mirrors LinearOauthStates pattern).
- Race-guard: log orphan URN/URL at error! level instead of warn! so the
LinkedIn post we just created can be recovered from workflow logs. Use
authoritative frontmatter parse (not substring scan) to detect a race
in publish_and_write so body text mentioning "linkedin_url:" can't
trigger a false-positive that orphans the post.
- classify_*: drop content.contains("linkedin_url:") early-out; the
parsed-frontmatter check is the authoritative idempotency signal and
the substring would false-positive against body text.
- linkedin_auth: use let-else for the Option<LinkedInConfig> branch to
match the symmetric handling in linkedin_auth_callback.
- from_env_optional tests: consolidate three env-mutating tests into one
sequential function so parallel cargo test doesn't race env vars.
Also covers the previously-untested "only secret set" partial case.
- LinkedInUserRow: drop broad #[allow(dead_code)] — all fields are read.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review Brief
What changed and why
Adds LinkedIn POSSE syndication for blog posts, weekly newsletters, and podcast episodes, mirroring the existing Bluesky pipeline. A post-Fly-Deploy GitHub Action (
linkedin.yml) scans each content directory, posts new items to Corey's personal LinkedIn profile via the REST/rest/postsAPI, writeslinkedin_url:back into markdown frontmatter, and commits tomainusing the App token. OAuth tokens are stored encrypted in a newLinkedInUserstable (mirroring the Google integration), lazily refreshed at publish time and proactively by a server-side cron job every 6 hours. The existing POSSE pattern — scan → skip-if-already-syndicated → publish → write frontmatter → commit — is preserved exactly.Architecture decisions and trade-offs
SignedCookieJar: The plan specified signed cookies, but the codebase has no existingSignedCookieJarusage. The implementation uses aLinkedInOauthStatestable (same pattern asLinearOauthStates), with a 10-minute TTL enforced at query time. Functionally equivalent CSRF protection, no new dependencies.AppState.linkedin: Option<LinkedInConfig>: Partial config (exactly one env var set) returnsErrat boot. Both-absent returnsOk(None), allowing the Fly container to boot before secrets are added. This prevents the PR from creating a deploy-blocking crash loop.Job::run:RefreshLinkedInToken::runwrapsdo_refreshinif let Err(e) = ... { tracing::error! }and always returnsOk(()). Correct — propagating an error would crash the server viacja::cron::Worker. Token expiry is expected operational state, not exceptional.publish_and_writere-parses frontmatter (not a rawcontains()check) after posting to detect a write race. On race, logs attracing::error!with the orphaned LinkedIn URN/URL so it's recoverable from workflow logs.PublishLinkedin(single capital L) with#[command(name = "publish-linkedin")]explicit annotation, avoiding heck'spublish-linked-inderivation.Risk assessment
LinkedInUsers,LinkedInOauthStates), newAppStatefield (linkedin: Option<LinkedInConfig>), new CLI command, new cron job, new admin routes, new workflow, new optional fields onBlogFrontMatterandPodcastFrontMatter. Server continues to boot and function if LinkedIn secrets are absent. Thefrom_dirsibling-file filter must ship before anylinkedin.mdfiles enter the repo (they're written only by the CLI after it lands, so the ordering is safe).cargo test --workspacepasses (187 server tests), clippy/fmt clean.Option<LinkedInConfig>field means removing the env vars instantly disables all LinkedIn-specific paths without a revert. The workflow can be disabled independently.Spot-check suggestions
server/src/commands/linkedin.rs:publish_and_write— after a detected race the function logs the orphaned URN attracing::error!and returnsOk(false). Confirm the LinkedIn post we just created is indeed loggable-and-recoverable: theurnandweb_urlare included in the error message, so manual cleanup is possible from workflow logs. Good.server/src/http_server/admin/linkedin_auth.rs:linkedin_auth_callback— the DB stateDELETE(which also sweeps stale rows) runs before the token exchange POST. If the exchange fails, the state row is already gone, which is correct (user restarts the flow). Verify this is the intended ordering.server/src/linkedin.rs:refresh_linkedin_token— bothencrypted_access_tokenandencrypted_refresh_token(plus their expiry timestamps) are written back in the UPDATE. Confirmed: the SQL updates all four columns. Correct.server/src/jobs/refresh_linkedin_token.rs:do_refresh— the 30-daytracing::warn!fires whenrefresh_token_expires_at < Utc::now() + 30 days. Confirm this warning is also surfaced in the admin dashboard panel (the plan specifies a visible warning; checkserver/src/http_server/admin/mod.rsfor the< NOW() + INTERVAL '30 days'check).db/migrations/20260523234949_AddLinkedInUsers.down.sql— dropsLinkedInOauthStatesbeforeLinkedInUsers. Neither table references the other via FK, so order is irrelevant, but worth a quick skim to confirm no FK was accidentally added.What the agent verified
cargo test --workspacepasses (187 server tests + posts tests, including newlinkedin::testsandcommands::linkedin::tests)cargo clippy --all-targets --all-features --workspace --tests -- -D warningspassescargo fmt --checkpasses./scripts/auto-fix-all.shproduces no diff.sqlx/offline cache regenerated and committed alongside the migrationSummary
Adds LinkedIn POSSE (Publish on your Own Site, Syndicate Elsewhere) syndication for blog posts, weekly newsletters, and podcast episodes. Mirrors the existing Bluesky pipeline.
linkedin_url:back into the markdown frontmatter, commits tomainvia App token.LinkedInUserstable (mirrors the Google integration). Lazy refresh at publish time + proactive 6h cron job./admin/auth/linkedinand dashboard panel showing expiry warnings.linkedin.mdfile, then frontmatterlinkedin_content, then first paragraph of the markdown body. Footer always "New post on coreyja.com\n".Files
New:
db/migrations/20260523234949_AddLinkedInUsers.{up,down}.sql—LinkedInUsers+LinkedInOauthStatestablesserver/src/linkedin.rs— HTTP client + token refresh +extract_first_paragraph/compose_linkedin_body/linkedin_urn_to_web_urlhelpersserver/src/commands/linkedin.rs—publish-linkedinCLI command + per-kind scanners/classifiers/publishersserver/src/jobs/refresh_linkedin_token.rs— proactive token-refresh cron job (6h interval)server/src/http_server/admin/linkedin_auth.rs—/admin/auth/linkedin+ callback.github/workflows/linkedin.yml— runs after successful Fly Deploy, three publish steps withcontinue-on-errorModified:
posts/src/{blog,podcast}.rs—linkedin_url+linkedin_contentfrontmatter fields, sibling-file filter infrom_dirserver/src/state.rs—linkedin: Option<LinkedInConfig>inAppStateserver/src/{main,cron}.rs,server/src/jobs/mod.rs,server/src/commands/mod.rs,server/src/http_server/{routes,admin/mod,test_helpers}.rs— wire everything upDeviations from the approved plan
SignedCookieJarbut the codebase has no existing usage of it (would require enabling newaxum-extrafeatures). The existing Linear OAuth flow uses a DB-backed state pattern (LinearOauthStates); I mirrored it asLinkedInOauthStates. Functionally equivalent CSRF protection, no new dependencies.#[allow(clippy::doc_markdown)]at file/module scope on the new LinkedIn files instead of backticking every "LinkedIn" mention in doc comments. "LinkedIn" is a proper noun appearing dozens of times in docs; the allow is more pragmatic.server/src/{bluesky,commands/{bluesky,buttondown},http_server/pages/podcast}.rs,posts/src/notes.rswere applied by the project'sscripts/auto-fix-all.sh(cargo fmt). They are pure formatting, not behavior changes.Required repo secrets (set BEFORE workflow first runs)
LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET(LinkedIn Developer Portal)DATABASE_URL(Neon connection string — same value Fly uses)ENCRYPTION_SECRET_KEY(same value Fly uses — required to decrypt tokens)APP_ID,APP_PRIVATE_KEY(already used bybluesky.yml)Corey also needs to create the LinkedIn Developer app, add "Sign In with LinkedIn using OpenID Connect" + "Share on LinkedIn" products, set redirect URI to
https://coreyja.com/admin/auth/linkedin/callback, and complete the/admin/auth/linkedinflow once after deploy to seed theLinkedInUsersrow.Pre-merge sanity checks
LINKEDIN_CUTOFF_DATE = "2026-05-23"inserver/src/commands/linkedin.rsis the actual merge day; bump if it slipped.LINKEDIN_VERSION_HEADER = "202602"inserver/src/linkedin.rsis still within the last 6 months at https://learn.microsoft.com/en-us/linkedin/marketing/versioning .Test plan
cargo test --workspacepasses (187 server tests + posts tests all green, including newlinkedin::testsandcommands::linkedin::tests)cargo clippy --all-targets --all-features --workspace --tests -- -D warningspassescargo fmt --checkpasses./scripts/auto-fix-all.shproduces no further diff