Skip to content

feat(plugin-cli): app-password publish auth for CI#1129

Closed
ascorbic wants to merge 1 commit into
mainfrom
feat/cli-app-password
Closed

feat(plugin-cli): app-password publish auth for CI#1129
ascorbic wants to merge 1 commit into
mainfrom
feat/cli-app-password

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds non-interactive emdash-plugin publish authentication for CI via atproto app passwords. Set EMDASH_PUBLISHER_APP_PASSWORD in the environment and pass --publisher <handle-or-did> (or EMDASH_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 createSession is the niche they exist for. The publishing layer is already auth-agnostic, so this is contained.

Design

A small two-phase PublishAuth seam (identify() + handler()) shared by OAuth and app-password. For OAuth identify() is offline and handler() resumes the stored session — preserved byte-for-byte. For app-password identify() runs createSession against the resolved PDS (still before the tarball fetch, so bad credentials fail before the download).

Guardrails refuse the full-account com.atproto.access scope (only appPass / appPassPrivileged 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. Stable error codes surface through --json for CI consumers.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (no new diagnostics in touched files)
  • pnpm test passes (284 tests in @emdash-cms/plugin-cli, was 278)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation — n/a, CLI only
  • I have added a changeset (@emdash-cms/plugin-cli minor)
  • New features link to an approved Discussion — maintainer to fill in URL

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7

Test plan

  • pnpm --filter @emdash-cms/plugin-cli test — 284 tests pass (17 files)
  • pnpm --filter @emdash-cms/plugin-cli typecheck — clean
  • pnpm lint:json — no new diagnostics in packages/plugin-cli/src/app-password.ts, commands/publish.ts, oauth.ts, or tests/mock-pds.ts
  • Manual: OAuth path produces identical CLI output to main (no app-password env set → selectPublishAuth returns the OAuth provider, same Publishing as ... log, same pin-check ordering)
  • Adversarial review pass (separate agent) — six findings, all addressed: INVALID_PUBLISHER exit code 1 (was 2), concurrent identify() race now shares the in-flight promise, ?? chain no longer misclassifies empty strings, identifier whitespace trimmed before reaching createSession, changeset documents all stable codes

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).
Copilot AI review requested due to automatic review settings May 20, 2026 22:21
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

🦋 Changeset detected

Latest commit: a2089fe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@emdash-cms/plugin-cli Minor
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch

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

@github-actions
Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

See CONTRIBUTING.md for the full contribution policy.

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This 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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-perf-coordinator a2089fe May 20 2026, 10:22 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-i18n a2089fe May 20 2026, 10:23 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground a2089fe May 20 2026, 10:23 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs a2089fe May 20 2026, 10:23 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1129

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1129

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1129

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1129

emdash

npm i https://pkg.pr.new/emdash@1129

create-emdash

npm i https://pkg.pr.new/create-emdash@1129

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1129

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1129

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1129

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1129

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1129

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1129

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1129

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1129

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1129

commit: a2089fe

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache a2089fe May 20 2026, 10:25 PM

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PublishAuth provider plus env/flag-driven auth selection (EMDASH_PUBLISHER_APP_PASSWORD + --publisher / env DID/handle).
  • Refactors runPublish to consume a two-phase PublishAuth seam (identify → handler) shared by OAuth and app-password flows.
  • Adds a mock PDS createSession endpoint + 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 rejects com.atproto.access but accepts any other/missing scope value. The PR description + changeset say only com.atproto.appPass and com.atproto.appPassPrivileged are accepted. To match that contract, consider enforcing an allow-list and throwing an AppPasswordError when scope is 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.

Comment on lines +152 to +161
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.",
);
Comment on lines +56 to +58
* 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).
@ascorbic ascorbic closed this May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants