Skip to content

feat: LinkedIn POSSE syndication for blog, newsletter, podcast#398

Open
byte-the-bot wants to merge 2 commits into
mainfrom
implement/BLOG-19f5318e76184459
Open

feat: LinkedIn POSSE syndication for blog, newsletter, podcast#398
byte-the-bot wants to merge 2 commits into
mainfrom
implement/BLOG-19f5318e76184459

Conversation

@byte-the-bot

@byte-the-bot byte-the-bot commented May 24, 2026

Copy link
Copy Markdown
Collaborator

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/posts API, writes linkedin_url: back into markdown frontmatter, and commits to main using the App token. OAuth tokens are stored encrypted in a new LinkedInUsers table (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

  • DB-backed CSRF state instead of SignedCookieJar: The plan specified signed cookies, but the codebase has no existing SignedCookieJar usage. The implementation uses a LinkedInOauthStates table (same pattern as LinearOauthStates), 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) returns Err at boot. Both-absent returns Ok(None), allowing the Fly container to boot before secrets are added. This prevents the PR from creating a deploy-blocking crash loop.
  • Cron errors swallowed at Job::run: RefreshLinkedInToken::run wraps do_refresh in if let Err(e) = ... { tracing::error! } and always returns Ok(()). Correct — propagating an error would crash the server via cja::cron::Worker. Token expiry is expected operational state, not exceptional.
  • DB state race guard: publish_and_write re-parses frontmatter (not a raw contains() check) after posting to detect a write race. On race, logs at tracing::error! with the orphaned LinkedIn URN/URL so it's recoverable from workflow logs.
  • Clap subcommand naming: Variant is PublishLinkedin (single capital L) with #[command(name = "publish-linkedin")] explicit annotation, avoiding heck's publish-linked-in derivation.

Risk assessment

  • Blast radius: New tables (LinkedInUsers, LinkedInOauthStates), new AppState field (linkedin: Option<LinkedInConfig>), new CLI command, new cron job, new admin routes, new workflow, new optional fields on BlogFrontMatter and PodcastFrontMatter. Server continues to boot and function if LinkedIn secrets are absent. The from_dir sibling-file filter must ship before any linkedin.md files enter the repo (they're written only by the CLI after it lands, so the ordering is safe).
  • Confidence level: High — the implementation closely follows two proven patterns (Bluesky POSSE + Google OAuth), all cargo test --workspace passes (187 server tests), clippy/fmt clean.
  • Rollback safety: Yes — migration down drops both tables cleanly. The 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 at tracing::error! and returns Ok(false). Confirm the LinkedIn post we just created is indeed loggable-and-recoverable: the urn and web_url are 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 state DELETE (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 — both encrypted_access_token and encrypted_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-day tracing::warn! fires when refresh_token_expires_at < Utc::now() + 30 days. Confirm this warning is also surfaced in the admin dashboard panel (the plan specifies a visible warning; check server/src/http_server/admin/mod.rs for the < NOW() + INTERVAL '30 days' check).
  • db/migrations/20260523234949_AddLinkedInUsers.down.sql — drops LinkedInOauthStates before LinkedInUsers. 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 --workspace passes (187 server tests + posts tests, including new linkedin::tests and commands::linkedin::tests)
  • cargo clippy --all-targets --all-features --workspace --tests -- -D warnings passes
  • cargo fmt --check passes
  • ./scripts/auto-fix-all.sh produces no diff
  • .sqlx/ offline cache regenerated and committed alongside the migration

Summary

Adds LinkedIn POSSE (Publish on your Own Site, Syndicate Elsewhere) syndication for blog posts, weekly newsletters, and podcast episodes. Mirrors the existing Bluesky pipeline.

  • Post-Fly-Deploy GitHub Action scans each content directory, posts new items to Corey's personal LinkedIn profile, writes linkedin_url: back into the markdown frontmatter, commits to main via App token.
  • OAuth tokens stored encrypted in a new LinkedInUsers table (mirrors the Google integration). Lazy refresh at publish time + proactive 6h cron job.
  • New admin OAuth flow at /admin/auth/linkedin and dashboard panel showing expiry warnings.
  • Custom post body via sibling linkedin.md file, then frontmatter linkedin_content, then first paragraph of the markdown body. Footer always "New post on coreyja.com\n".

Files

New:

  • db/migrations/20260523234949_AddLinkedInUsers.{up,down}.sqlLinkedInUsers + LinkedInOauthStates tables
  • server/src/linkedin.rs — HTTP client + token refresh + extract_first_paragraph / compose_linkedin_body / linkedin_urn_to_web_url helpers
  • server/src/commands/linkedin.rspublish-linkedin CLI command + per-kind scanners/classifiers/publishers
  • server/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 with continue-on-error

Modified:

  • posts/src/{blog,podcast}.rslinkedin_url + linkedin_content frontmatter fields, sibling-file filter in from_dir
  • server/src/state.rslinkedin: Option<LinkedInConfig> in AppState
  • server/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 up

Deviations from the approved plan

  1. DB-backed CSRF state instead of signed cookies. The plan called for SignedCookieJar but the codebase has no existing usage of it (would require enabling new axum-extra features). The existing Linear OAuth flow uses a DB-backed state pattern (LinearOauthStates); I mirrored it as LinkedInOauthStates. Functionally equivalent CSRF protection, no new dependencies.
  2. #[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.
  3. A few unrelated whitespace changes in server/src/{bluesky,commands/{bluesky,buttondown},http_server/pages/podcast}.rs, posts/src/notes.rs were applied by the project's scripts/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 by bluesky.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/linkedin flow once after deploy to seed the LinkedInUsers row.

Pre-merge sanity checks

Test plan

  • cargo test --workspace passes (187 server tests + posts tests all green, including new linkedin::tests and commands::linkedin::tests)
  • cargo clippy --all-targets --all-features --workspace --tests -- -D warnings passes
  • cargo fmt --check passes
  • ./scripts/auto-fix-all.sh produces no further diff

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant