ReviewOS

stacks/postline

Publishing audit: close the gaps on Twitter, Bluesky and LinkedIn, then add The OT as a target

#40
Open glennmichael123 opened this 9 days ago ·
9 days ago

Goal: publish reliably to X/Twitter, Bluesky, LinkedIn and The OT. The drivers exist — the open question is what is unfinished in each.

What already exists

app/Services/Social/Drivers/: BlueskyDriver, TwitterDriver, LinkedInDriver, MastodonDriver, InstagramDriver, ThreadsDriver, dispatched through the publishers map in CrosspostService (per-provider failures are isolated; one platform erroring doesn't abort the rest). DriverRegistry.ts is dead code and lists a different, shorter set of providers - see #44.

No driver exists for The OT.

Audit, per provider

For X/Twitter, Bluesky and LinkedIn, confirm against a live account:

  • Single post, with and without a link preview
  • Thread — rule confirmed and pinned in a5debcc; live check still pending. It is not a provider list: a segment chains only when its result carries a cid, an AT-protocol content hash, so Bluesky threads natively and the others publish segments independently. A network that gains native threading starts chaining as soon as its driver returns a cid, with nothing to update. The chaining rules moved to Support/Social/threads.ts as pure functions and are covered by tests/unit/threads.test.ts — root pinned while parent advances, a failed segment never becoming the next one's parent, and chains never crossing between two accounts on one network. What remains is confirming it renders as a real thread on a live account
  • Media: images (see #18), alt text, video if supported
  • Character limits and truncation behaviour
  • Token refresh and the reconnect path when auth expires (#19 covered OAuth refresh for LinkedIn/Instagram/Threads — verify it holds)
  • Error surfaces: rate limits, duplicate-content rejections, suspended accounts

The OT — built, and verified working

This section was out of date; corrected 2026-10-21. Publishing to The OT is not outstanding work. It is implemented, wired into the composer, and verified end to end.

The decision this section asked for has been made: The OT is modelled as two providers rather than as a separate publish action, and SocialProvider extends to carry them.

  • opentimes — short form. OpenTimesService.publishToPost writes a discover_entries row keyed post:<id>, so republishing updates the entry instead of posting twice. Rejects bodies over its character limit with a message pointing at the blog.
  • blog — long form. Writes the publication post and its discover_entries row keyed blog:<slug>.

Both sit at the top of the publishers map in CrosspostService, ahead of the social networks, because they are the two targets that are always connected.

  • Decide whether The OT appears in the composer's target list — it does, as providers, so scheduling, variants, queue and metrics need no special case
  • Write through the existing models rather than adding a Drivers/ entry
  • blog is a valid PurgeRun provider, and the purge path handles it

Verified in the running app, signed in through a browser, one composer post to both targets:

posts         id 13  published
post_targets  opentimes  published  opentimes:1
              blog       published  /blog/verifying-the-local-publish-path
discover_entries  1  short  post:13
                  2  long   blog:verifying-the-local-publish-path

Both appear on their public surfaces — the post is live in the Discover feed and listed on /blog. So "one composer post goes out to The OT in a single publish, with per-target status visible" already holds; what is left in Done when is the three social networks.

Credential storage: how a connect actually starts

Rewritten 2026-10-22. An earlier version of this section proposed that each instance register its own OAuth app and paste the client ID and secret into settings. That assumed Postline was self-hosted server software. It is not — per Chris: "postline is a normal app, installed through app stores, or via downloaded file from the relating marketing site and gh releases."

That changes the answer, and mostly for the better.

A desktop app is a public client

A native app cannot keep a secret — anything shipped in the binary is readable by whoever has the binary — so the protocol does not ask it to. Native apps are public clients (RFC 8252): they use PKCE instead of a client secret, and the client ID is not confidential.

So Postline can register each app once, ship the client ID in the binary, and let the user simply sign in. No broker, no per-user registration, no pasted secrets — and no contradiction with features/own-your-keys.stx, because nothing passes through a server the project runs. The tokens still land only in that machine's own SQLite file.

The X flow already works this way: TwitterService builds the consent URL with PKCE, and .env already documents the choice — "Type of App = Web App gives you a client secret; Native App is public (PKCE only) and leaves TWITTER_CLIENT_SECRET blank. Both work."

Where the networks actually land

Checked against each provider's own documentation 2026-10-22, after an earlier version of this section assumed PKCE was a parameter you could add to any OAuth flow. It is not: two of the three need more than parameters, and one cannot be a public client at all.

NetworkHow a connect startsPublic client?
BlueskyHandle + app passwordn/a — no OAuth app
MastodonInstance URL + access tokenn/a — no OAuth app
XSign-in, PKCE, no secretYes, already working
LinkedInSign-in, PKCE — but see belowYes, once LinkedIn enables it
ThreadsSign-inNo — secret required
InstagramSign-inNo — same Graph pattern, unverified

Threads cannot be a public client. Meta's own "Get Access Tokens and Permissions" page lists the authorization parameters as exactly client_id, redirect_uri, scope, response_type, state — no code_challenge — and marks client_secret required on the token exchange. A secret shipped in a distributed binary is extractable by anyone who has the binary, so this is not "add PKCE", it is "Meta does not offer the flow". Instagram runs the same Graph pattern and should be assumed identical until someone checks its page directly.

LinkedIn does offer it, at a different endpoint, with a manual gate. Per learn.microsoft.com/linkedin/shared/authentication/authorization-code-flow-native:

  • The endpoint is https://www.linkedin.com/oauth/native-pkce/authorization, not the /oauth/v2/authorization this app calls today.
  • code_challenge and code_challenge_method=S256 are both required.
  • The token exchange takes code_verifier and no client_secret — it is absent from the parameter table, which is the whole point.
  • The redirect must be a loopback IP: "we will only communicate with loopback IPs", http://127.0.0.1:{port} or http://[::1]:{port}. Not an https callback on a hostname, which is what the standard flow requires and what LINKEDIN_REDIRECT_URL holds now.
  • The app must use the system browser, not an embedded webview — which a Craft window is.
  • And it is not self-serve: "please reach out to your point of contact at LinkedIn, and we will enable PKCE OAuth 2 flow for your app."

Note the native-PKCE page carries a 2018 date and a 2023 revision, while the standard flow page was revised 2026-05-15. Worth confirming the flow is still offered before building against it.

What this costs the shipped-client-ID plan

It narrows it. X works as a public client today but is the one network billing per call, so it stays bring-your-own-keys for cost reasons. LinkedIn is reachable but gated on a LinkedIn-side approval and an architectural change. Meta cannot do it at all.

So "ship the client ID and let people sign in" currently applies to no network that is not already working. That does not make it wrong as a direction — it makes it a per-network negotiation rather than a refactor.

  • Decide whether LinkedIn is worth the native-PKCE path: a loopback HTTP listener on a random port, the system browser rather than the Craft webview, a second endpoint, and a support request to LinkedIn
  • Treat Instagram and Threads as bring-your-own-keys until Meta offers a public-client flow. Shipping a Meta secret in the binary is not an option dressed as a trade-off; it is the secret being public
  • Verify Instagram's authorization page directly rather than inheriting the Threads finding
  • Keep X on bring-your-own-keys, for billing rather than secrecy

Blocked on #46

#46 — social tokens stored in plaintext — was already worth fixing. This distribution model raises it: these are not a developer's own credentials in a file they chose the location of. They are consumer tokens, on consumer machines, arriving through the Mac App Store, in a database the app wrote without asking. Encryption at rest should land before the connect flow gets easier to reach.

Explicitly not in scope: per-user credentials

Stated so it is not assumed. social_identities has an account_id but accounts has no user_id, and Account is the workspace rather than a person. Multi-tenancy is not what a desktop app needs — every install is already one person's — and the ORM blocks it anyway:

security.api.rowScoping is 'deny': NOT registering store/update/destroy for 7 model(s)
with no row-level scoping - Account, MediaAsset, Post, PostTarget, SocialDriver,
SocialIdentity, TimelineItem

Done when

One composer post can go out to X, Bluesky, LinkedIn and The OT in a single publish, with per-target status visible and real failures legible — and a fresh install can reach that point without anyone opening .env.

Timing

Chris asked for Postline/The Open Times to be finished and usable this week, and to start publishing from it next week — to Twitter, Bluesky, LinkedIn and The OT. That puts this issue in the this-week set, alongside #38, #39 and #40.

8 days ago

Worked the parts of this that need no live account. Two findings worth pulling out.

The OT section was stale. It asked whether The OT should be a composer target; it already is, and has been. opentimes (short form → Discover feed) and blog (long form → publication) are both in the publishers map ahead of the social networks, and SocialProvider extends to carry them, so scheduling, variants, queue and metrics need no special case. I verified it in the running app rather than from the code: one composer post, signed in through a browser, produced post_targets rows opentimes → published and blog → published, two discover_entries (short keyed post:13, long keyed blog:<slug>), and both are live on /discover and /blog. Section rewritten and its boxes ticked.

Thread chaining had no test, and could not have had one. The rules lived inside publishThread between a posts insert and a status update, so exercising them needed a database and a network — tests/unit/accounts.test.ts even claimed to cover thread chains while never calling the method. Lifted to Support/Social/threads.ts as pure functions (a5debcc), behaviour unchanged, now covered.

That answers this issue's "confirm which does what" for threads: it is not a provider list. A segment chains only when its result carries a cid — an AT-protocol content hash — so Bluesky threads natively and everything else publishes segments independently, and a network that gains native threading starts chaining with nothing here to update. Pinned: root stays put while parent advances, a failed segment never becomes the next one's parent, and two accounts on one network thread separately. Confirming it renders as a thread still needs a live account, so the box stays open.

Also checked and found already covered, so not duplicated: per-account de-dup, bare-network fallback, per-network variants and failure isolation (one account erroring not stopping the others) are all tested in tests/unit/accounts.test.ts, and each driver has its own suite.

What is genuinely blocked on a live account

Everything else in the per-provider audit — single post and link preview, media and alt text, character limits in practice, token refresh and reconnect, and the rate-limit and duplicate-content error surfaces. None of it can be confirmed from here; it needs X, Bluesky and LinkedIn accounts connected.

Suite 779 pass, 0 fail.

8 days ago

Rewrote the credential-storage section. The previous version was built on a wrong assumption and would have sent someone down the wrong path.

It proposed that each instance register its own OAuth app and paste a client ID and secret into settings — the self-hosted answer. Postline is not self-hosted server software. Per Chris: "postline is a normal app, installed through app stores, or via downloaded file from the relating marketing site and gh releases."

A desktop app is a public client, so this gets easier rather than harder. A native app cannot keep a secret, so RFC 8252 does not ask it to — PKCE replaces the client secret and the client ID is not confidential. Postline can register each app once, ship the client ID in the binary, and let users just sign in. No broker, no per-user registration, nothing pasted, and no conflict with own-your-keys.stx, because nothing passes through a server the project runs.

TwitterService already works this way, and .env already documents the choice: "Type of App = Web App gives you a client secret; Native App is public (PKCE only) and leaves TWITTER_CLIENT_SECRET blank. Both work."

X stays bring-your-own-keys, for billing rather than secrecy. It bills per call — roughly $0.015 a post and $0.20 for a post containing a link, which is what crossposting an article is every time. A shipped client ID would put every user's posting on the project's credits.

Three things that fall out and are now checkboxes:

  • PKCE is missing on three of the four OAuth providers. grep -c codeVerifier returns 7 in TwitterService and 0 in LinkedIn, Instagram and Threads. A public client without PKCE is an authorization-code flow anyone can intercept, so that has to land before any client ID ships.
  • The redirect URI needs settling. A distributed app cannot use a developer's hostname. http://localhost:3040/api/... is loopback-shaped already, but 3040 is this repo's dev port rather than one claimed at runtime.
  • #46 is now a prerequisite, not a neighbour. Plaintext tokens stop being a developer's own file and become consumer credentials on consumer machines, arriving through the Mac App Store, in a database the app wrote without asking.

Also corrected the not-in-scope note: multi-tenancy isn't what a desktop app needs — every install is already one person's.

8 days ago

Checked PKCE against each provider's own documentation before writing any code. The answer is that "add PKCE to LinkedIn, Instagram and Threads" is not implementable as stated, and I have amended the section rather than leave a checkbox that cannot be ticked.

Threads cannot be a public client. Meta's Get Access Tokens and Permissions page lists the authorization parameters as exactly client_id, redirect_uri, scope, response_type, state — no code_challenge — and marks client_secret required on the token exchange. So this is not a missing parameter, it is a flow Meta does not offer. A secret shipped in a distributed binary is extractable by whoever has the binary. Instagram runs the same Graph pattern; I have flagged it as assumed-identical rather than claim a verification I did not do.

LinkedIn does offer PKCE, but not where the code is looking. Per LinkedIn's native-client docs, it is a separate endpoint — /oauth/native-pkce/authorization, not the /oauth/v2/authorization this app calls — and it comes with conditions:

  • the redirect must be a loopback IP ("we will only communicate with loopback IPs"), not the https callback LINKEDIN_REDIRECT_URL holds now
  • the app must use the system browser, not an embedded webview — which a Craft window is
  • the token exchange takes code_verifier and no client_secret", which is the actual prize
  • and it is not self-serve: "please reach out to your point of contact at LinkedIn, and we will enable PKCE OAuth 2 flow for your app"

That page is dated 2018 with a 2023 revision while the standard-flow page was revised 2026-05-15, so the flow's current availability is worth confirming before anyone builds against it.

Why I stopped rather than implementing. These are local drivers — LinkedInDriver alone is 263 lines — there is no registered app for any of the three, and no credentials, so nothing I wrote could be tested against a real provider. For LinkedIn specifically, adding code_verifier to the existing confidential flow is the change most likely to break a flow that currently works.

The consequence for the plan is in the issue body now: shipping a client ID applies to no network that is not already working. X works as a public client but stays BYO for billing; LinkedIn is reachable but gated on a LinkedIn-side approval plus an architectural change; Meta cannot do it. That makes this a per-network negotiation rather than a refactor.

Sign in to comment on this issue.