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 acid, 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 toSupport/Social/threads.tsas pure functions and are covered bytests/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.publishToPostwrites adiscover_entriesrow keyedpost:<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 itsdiscover_entriesrow keyedblog:<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 blogis a validPurgeRunprovider, 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-pathBoth 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.
| Network | How a connect starts | Public client? |
|---|---|---|
| Bluesky | Handle + app password | n/a — no OAuth app |
| Mastodon | Instance URL + access token | n/a — no OAuth app |
| X | Sign-in, PKCE, no secret | Yes, already working |
| Sign-in, PKCE — but see below | Yes, once LinkedIn enables it | |
| Threads | Sign-in | No — secret required |
| Sign-in | No — 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/authorizationthis app calls today. code_challengeandcode_challenge_method=S256are both required.- The token exchange takes
code_verifierand noclient_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}orhttp://[::1]:{port}. Not an https callback on a hostname, which is what the standard flow requires and whatLINKEDIN_REDIRECT_URLholds 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, TimelineItemDone 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.