feat(plugin-cli): app-password publish auth for CI#1129
Conversation
Adds non-interactive `emdash-plugin publish` authentication for CI via atproto app passwords. Set `EMDASH_PUBLISHER_APP_PASSWORD` and pass `--publisher <handle-or-did>` (or `EMDASH_PUBLISHER_DID` / `EMDASH_PUBLISHER_HANDLE`); interactive OAuth is unchanged otherwise. Introduces a small two-phase `PublishAuth` seam (identify + handler) so the OAuth fast-fail path is preserved byte-for-byte and the app-password path can run its `createSession` network call during identify() — still before the tarball fetch, so bad credentials fail before the download. Guardrails refuse the full-account `com.atproto.access` scope, reject malformed `xxxx-xxxx-xxxx-xxxx` strings before any network call, and cross-check the logged-in DID against the resolved publisher identifier. Failures surface stable codes through `--json` mode: APP_PASSWORD_FORMAT, MISSING_APP_PASSWORD, MISSING_PUBLISHER (exit 2); INVALID_PUBLISHER, APP_PASSWORD_LOGIN_FAILED, FULL_ACCOUNT_CREDENTIAL, PUBLISHER_DID_MISMATCH (exit 1).
🦋 Changeset detectedLatest commit: a2089fe The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
Scope checkThis PR changes 1,160 lines across 6 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-perf-coordinator | a2089fe | May 20 2026, 10:22 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-i18n | a2089fe | May 20 2026, 10:23 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | a2089fe | May 20 2026, 10:23 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | a2089fe | May 20 2026, 10:23 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | a2089fe | May 20 2026, 10:25 PM |
There was a problem hiding this comment.
Pull request overview
Adds a CI-friendly, non-interactive authentication path for emdash-plugin publish using atproto app passwords, while preserving the existing interactive OAuth flow as the default when no app password is configured.
Changes:
- Introduces an app-password-based
PublishAuthprovider plus env/flag-driven auth selection (EMDASH_PUBLISHER_APP_PASSWORD+--publisher/ env DID/handle). - Refactors
runPublishto consume a two-phasePublishAuthseam (identify → handler) shared by OAuth and app-password flows. - Adds a mock PDS
createSessionendpoint + comprehensive tests for guardrails, selection precedence, and publish integration behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/plugin-cli/src/app-password.ts | New app-password auth provider, selection logic, stable error codes, and JWT-scope guardrail. |
| packages/plugin-cli/src/commands/publish.ts | Routes publish through PublishAuth abstraction; adds --publisher; adjusts error/exit-code handling. |
| packages/plugin-cli/src/oauth.ts | Exports parseActorIdentifier for reuse by app-password path. |
| packages/plugin-cli/tests/mock-pds.ts | Extends mock PDS to implement com.atproto.server.createSession and emit unsigned JWTs for scope tests. |
| packages/plugin-cli/tests/app-password.test.ts | Adds end-to-end tests for app-password auth, selection precedence, and publish pin-check integration. |
| .changeset/cli-app-password.md | Documents the new CI authentication path and its stable error codes/scopes. |
Comments suppressed due to low confidence (1)
packages/plugin-cli/src/app-password.ts:378
assertNonFullAccountScope()only rejectscom.atproto.accessbut accepts any other/missingscopevalue. The PR description + changeset say onlycom.atproto.appPassandcom.atproto.appPassPrivilegedare accepted. To match that contract, consider enforcing an allow-list and throwing anAppPasswordErrorwhenscopeis missing/unrecognised (or update the user-facing docs if the looser behavior is intended).
function assertNonFullAccountScope(accessJwt: string): void {
const payload = decodeJwtPayload(accessJwt);
const scope =
payload && typeof payload === "object" ? (payload as { scope?: unknown }).scope : undefined;
if (scope === "com.atproto.access") {
throw new AppPasswordError(
"FULL_ACCOUNT_CREDENTIAL",
"EMDASH_PUBLISHER_APP_PASSWORD authenticated with the full-account scope " +
"(`com.atproto.access`). Refusing to publish with account credentials — " +
"create a dedicated app password at https://bsky.app/settings/app-passwords " +
"or your PDS equivalent and use that instead.",
);
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function createAppPasswordAuth(options: CreateAppPasswordAuthOptions): PublishAuth { | ||
| const actor = parseActorIdentifier(options.identifier); | ||
| if (!isWellFormedAppPassword(options.password)) { | ||
| throw new AppPasswordError( | ||
| "APP_PASSWORD_FORMAT", | ||
| "EMDASH_PUBLISHER_APP_PASSWORD does not look like an atproto app password " + | ||
| "(expected `xxxx-xxxx-xxxx-xxxx`, lowercase alphanumeric). " + | ||
| "Create one at https://bsky.app/settings/app-passwords or your PDS equivalent, " + | ||
| "not the account password.", | ||
| ); |
| * Return a fetch handler authenticated as the publisher. Must be called | ||
| * AFTER `identify()` — the order matches the OAuth path, where the | ||
| * handler is only built once the session DID is known. |
|
|
||
| Set `EMDASH_PUBLISHER_APP_PASSWORD` in the environment and pass `--publisher <handle-or-did>` (or set `EMDASH_PUBLISHER_DID` / `EMDASH_PUBLISHER_HANDLE`) to publish without the browser-based OAuth dance. The interactive OAuth path is unchanged when no app password is set. | ||
|
|
||
| Guardrails refuse a full-account credential (only `com.atproto.appPass` and `com.atproto.appPassPrivileged` scopes are accepted), reject malformed `xxxx-xxxx-xxxx-xxxx` strings before any network call, and cross-check the logged-in DID against the resolved publisher identifier. Failures surface stable error codes through `--json` mode for CI consumers: `APP_PASSWORD_FORMAT`, `MISSING_APP_PASSWORD`, `MISSING_PUBLISHER` (exit 2, config errors); `INVALID_PUBLISHER`, `APP_PASSWORD_LOGIN_FAILED`, `FULL_ACCOUNT_CREDENTIAL`, `PUBLISHER_DID_MISMATCH` (exit 1, runtime errors). |
What does this PR do?
Adds non-interactive
emdash-plugin publishauthentication for CI via atproto app passwords. SetEMDASH_PUBLISHER_APP_PASSWORDin the environment and pass--publisher <handle-or-did>(orEMDASH_PUBLISHER_DID/EMDASH_PUBLISHER_HANDLE) to publish without the browser OAuth dance. Interactive OAuth is unchanged when no app password is set.Why app passwords instead of OAuth-in-CI
Atproto OAuth refresh tokens rotate single-use. A stateless runner publishes once, then the persisted session is dead unless the rotated token is written back somewhere durable — which needs a PAT to mutate the secret, or cache plumbing that breaks silently. App passwords are long-lived, re-create a session per run, and
createSessionis the niche they exist for. The publishing layer is already auth-agnostic, so this is contained.Design
A small two-phase
PublishAuthseam (identify()+handler()) shared by OAuth and app-password. For OAuthidentify()is offline andhandler()resumes the stored session — preserved byte-for-byte. For app-passwordidentify()runscreateSessionagainst the resolved PDS (still before the tarball fetch, so bad credentials fail before the download).Guardrails refuse the full-account
com.atproto.accessscope (onlyappPass/appPassPrivilegedare accepted), reject malformedxxxx-xxxx-xxxx-xxxxstrings before any network call, and cross-check the logged-in DID against the resolved publisher identifier. Stable error codes surface through--jsonfor CI consumers.Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpasses (no new diagnostics in touched files)pnpm testpasses (284 tests in@emdash-cms/plugin-cli, was 278)pnpm formathas been run@emdash-cms/plugin-climinor)AI-generated code disclosure
Test plan
pnpm --filter @emdash-cms/plugin-cli test— 284 tests pass (17 files)pnpm --filter @emdash-cms/plugin-cli typecheck— cleanpnpm lint:json— no new diagnostics inpackages/plugin-cli/src/app-password.ts,commands/publish.ts,oauth.ts, ortests/mock-pds.tsmain(no app-password env set →selectPublishAuthreturns the OAuth provider, samePublishing as ...log, same pin-check ordering)INVALID_PUBLISHERexit code 1 (was 2), concurrentidentify()race now shares the in-flight promise,??chain no longer misclassifies empty strings, identifier whitespace trimmed before reachingcreateSession, changeset documents all stable codes