Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/atproto-upstream-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'ePDS': minor
---

Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer.

**Affects:** End users, Operators

**End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, which adds changing your handle and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in and email verification you already use through ePDS are unchanged.

**Operators:**

- ePDS now requires **Node.js 22.19.0 or newer** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22.19.0` (the floor is set by a transitive `undici@8` runtime dependency, which fails on Node 22.0–22.18).
- The bundled AT Protocol packages moved to their latest releases: `@atproto/pds` 0.5.23, `@atproto/oauth-provider` 0.21.1, `@atproto/oauth-provider-ui` 0.8.9.
- No configuration changes are required, but the native `better-sqlite3` module must be built for the Node 22 ABI — a clean `pnpm install` on Node 22 handles this; an in-place Node version switch needs `pnpm rebuild better-sqlite3`.
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
Expand All @@ -31,7 +31,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
Expand All @@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
Expand All @@ -55,7 +55,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
node-version: 22
cache: 'pnpm'

- run: pnpm install --frozen-lockfile
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.19.0
4 changes: 2 additions & 2 deletions Dockerfile.auth
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# so each service has its own Dockerfile.

# -- Base --
FROM node:20-alpine AS base
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser
WORKDIR /app
Expand All @@ -24,7 +24,7 @@ COPY tsconfig.json ./
RUN pnpm --filter @certified-app/auth-service... build

# -- Auth Service --
FROM node:20-alpine
FROM node:22-alpine
RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser
RUN apk add --no-cache wget su-exec
WORKDIR /app
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.demo
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# with output: "standalone" and then runs the self-contained server.

# -- Base --
FROM node:20-alpine AS base
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app

Expand Down Expand Up @@ -35,7 +35,7 @@ RUN /tmp/resolve-version.sh && \
pnpm --filter @certified-app/demo build

# -- Production --
FROM node:20-alpine
FROM node:22-alpine
RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser
WORKDIR /app

Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.pds
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# so each service has its own Dockerfile.

# -- Base --
FROM node:20-alpine AS base
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser
WORKDIR /app
Expand All @@ -24,7 +24,7 @@ COPY tsconfig.json ./
RUN pnpm --filter @certified-app/pds-core... build

# -- PDS Core --
FROM node:20-alpine
FROM node:22-alpine
RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser
RUN apk add --no-cache wget su-exec
WORKDIR /app
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
Comment thread
aspiers marked this conversation as resolved.
"packageManager": "pnpm@9.15.9",
"//workspaces": "This field is only read by @manypkg/get-packages (used by Changesets) to put us into single-package mode. pnpm ignores it because pnpm-workspace.yaml takes precedence for actual workspace resolution. Do not remove without reading docs/PUBLISHING.md.",
Expand Down
13 changes: 9 additions & 4 deletions packages/pds-core/package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
{
"name": "@certified-app/pds-core",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"engines": {
"node": ">=22.19.0"
},
Comment thread
aspiers marked this conversation as resolved.
"scripts": {
"build": "tsc --build",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@atproto/crypto": "^0.4.5",
"@atproto/oauth-provider": "^0.15.9",
"@atproto/oauth-provider-ui": "^0.4.3",
"@atproto/pds": "^0.4.209",
"@atproto/oauth-provider": "^0.21.1",
"@atproto/oauth-provider-ui": "^0.8.9",
"@atproto/pds": "^0.5.23",
"@atproto/syntax": "^0.7.2",
"@certified-app/shared": "workspace:*",
"@did-plc/lib": "^0.0.4",
"dotenv": "^16.3.1",
Expand All @@ -21,7 +26,7 @@
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.11.0",
"@types/node": "^22.0.0",
"@types/serialize-javascript": "^5.0.4",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
Expand Down
10 changes: 5 additions & 5 deletions packages/pds-core/src/__tests__/auth-ui-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,11 +778,11 @@ describe('createAuthUiGuard', () => {
// ---------------------------------------------------------------------------

// Helper: build a binding fixture good enough for the guard's matchesHint
// logic. Only `account.sub` and `account.preferred_username` are read.
function binding(sub: string, pu: string) {
// logic. Only `account.did` and `account.handle` are read.
function binding(did: string, handle: string) {
return {
account: { sub, preferred_username: pu },
} as unknown as { account: { sub: string; preferred_username: string } }
account: { did, handle },
} as unknown as { account: { did: string; handle: string } }
}

// urn-prefixed request_uri so loadStoredPar actually attempts the read
Expand Down Expand Up @@ -878,7 +878,7 @@ describe('createAuthUiGuard', () => {
// one stale and one fresh. The checkLoginRequired predicate marks
// only `did:plc:stale` as loginRequired.
const onlyStaleIsLoginRequired = (b: unknown): boolean =>
(b as { account: { sub: string } }).account.sub === 'did:plc:stale'
(b as { account: { did: string } }).account.did === 'did:plc:stale'
const TWO_BINDINGS = [
binding('did:plc:stale', 'stale.example'),
binding('did:plc:fresh', 'fresh.example'),
Expand Down
10 changes: 7 additions & 3 deletions packages/pds-core/src/__tests__/chooser-enrichment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,9 +507,13 @@ describe('buildChooserEnrichmentScript sign-up hide + another-account rebind', (
// Capture-phase is essential — React's delegated root-level click
// listener fires in bubble phase, so a bubble listener on the button
// would run AFTER React swaps to upstream's stock sign-in component.
expect(script).toContain(
'\'[role="button"][aria-label="Login to account that is not listed"]\'',
)
// oauth-provider-ui 0.8 changed the copy, so the script matches both
// the current and previous aria-label / visible-text variants.
expect(script).toContain('Sign in to an account that is not listed')
expect(script).toContain('Login to account that is not listed')
expect(script).toContain('Select another account')
expect(script).toContain('Another account')
expect(script).toContain('[role="button"][aria-label="')
expect(script).toContain('e.preventDefault()')
expect(script).toContain('e.stopImmediatePropagation()')
expect(script).toContain('window.location.href')
Expand Down
83 changes: 83 additions & 0 deletions packages/pds-core/src/__tests__/identifier-guards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import {
canLookUpAccountByHandle,
canCheckHandle,
canResolveHandle,
isValidConstructedHandle,
} from '../lib/identifier-guards.js'

describe('canLookUpAccountByHandle', () => {
it('accepts a valid handle', () => {
expect(canLookUpAccountByHandle('alice.example.com')).toBe(true)
})

it('accepts a valid DID', () => {
expect(canLookUpAccountByHandle('did:plc:7iza6de2dwap2sbkpav7c6c6')).toBe(
true,
)
})

it('rejects a syntactically invalid identifier', () => {
expect(canLookUpAccountByHandle('not a handle')).toBe(false)
})

it('rejects an empty string', () => {
expect(canLookUpAccountByHandle('')).toBe(false)
})

it('rejects a bare word with no dot and no did: prefix', () => {
expect(canLookUpAccountByHandle('alice')).toBe(false)
})
})

describe('canCheckHandle', () => {
it('accepts a valid handle', () => {
expect(canCheckHandle('alice.example.com')).toBe(true)
})

it('rejects a DID (this endpoint only accepts handles)', () => {
// A DID is not a HandleString, so availability-check treats it as invalid
// — which the endpoint reports as "taken", never "free".
expect(canCheckHandle('did:plc:7iza6de2dwap2sbkpav7c6c6')).toBe(false)
})

it('rejects a syntactically invalid handle', () => {
expect(canCheckHandle('has spaces.example.com')).toBe(false)
})

it('rejects a handle with no dot', () => {
expect(canCheckHandle('alice')).toBe(false)
})
})

describe('canResolveHandle', () => {
it('accepts a valid hosted handle', () => {
expect(canResolveHandle('alice.hosted.example')).toBe(true)
})

it('rejects a syntactically invalid handle', () => {
expect(canResolveHandle('bad handle')).toBe(false)
})

it('rejects an empty domain', () => {
expect(canResolveHandle('')).toBe(false)
})
})

describe('isValidConstructedHandle', () => {
it('accepts a well-formed `${local}.${domain}` handle', () => {
expect(isValidConstructedHandle('alice.example.com')).toBe(true)
})

it('accepts a generated random-style handle', () => {
expect(isValidConstructedHandle('brave-otter-42.example.com')).toBe(true)
})

it('rejects a handle with no domain suffix (construction bug)', () => {
expect(isValidConstructedHandle('alice')).toBe(false)
})

it('rejects a handle containing whitespace', () => {
expect(isValidConstructedHandle('alice bob.example.com')).toBe(false)
})
})
11 changes: 9 additions & 2 deletions packages/pds-core/src/__tests__/preview-test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
* about new-code duplication and gives the suites a single place to
* grow the mock when the handler signature changes.
*/
import { vi } from 'vitest'
import { vi, type Mock } from 'vitest'

export function mockLogger() {
// Explicit return type: under NodeNext module resolution the inferred type
// referenced @vitest/spy by a non-portable path (TS2742). Naming the Mock
// fields keeps the emitted .d.ts self-contained.
export function mockLogger(): {
info: Mock
warn: Mock
debug: Mock
} {
return { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }
}

Expand Down
10 changes: 6 additions & 4 deletions packages/pds-core/src/auth-ui-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@
* See docs/design/session-reuse-bugs.md for the full failure-mode taxonomy.
*/
import type { NextFunction, Request, Response } from 'express'
import type { DeviceAccount, OAuthProvider } from '@atproto/oauth-provider'
import type { DeviceAccount } from '@atproto/oauth-provider/store'
import type { OAuthProvider } from '@atproto/oauth-provider/provider'
import {
DEVICE_ID_BYTES_LENGTH,
DEVICE_ID_PREFIX,
SESSION_ID_BYTES_LENGTH,
SESSION_ID_PREFIX,
} from '@atproto/oauth-provider'
} from '@atproto/oauth-provider/constants'
import type { Logger } from 'pino'
import {
parsePromptTokens as parsePromptTokensShared,
Expand Down Expand Up @@ -380,8 +381,9 @@ function filterCandidateBindings(
): Binding[] {
if (!loginHint) return bindings
const matched = bindings.filter(
({ account }) =>
account.sub === loginHint || account.preferred_username === loginHint,
// Account is now strongly typed (oauth-provider-api 0.7): sub -> did,
// preferred_username -> handle (handle is optional).
({ account }) => account.did === loginHint || account.handle === loginHint,
)
return matched.length === 1 ? matched : bindings
}
33 changes: 24 additions & 9 deletions packages/pds-core/src/chooser-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export function buildChooserEnrichmentScript(): string {
// its compiled bundle. Match by exact text content; the button lives
// inside #root alongside the chooser list. Idempotent via
// dataset.epdsHidden so the MutationObserver doesn't thrash.
//
// NOTE: oauth-provider-ui 0.8 dropped the "Sign up" button from the
// account-selector page (no "Sign up"/"Create account" string in the
// account-page bundle), so this is currently a no-op. Kept as a cheap
// guard in case a signup affordance returns to the chooser upstream.
function hideSignup() {
var root = document.getElementById('root');
if (!root) return;
Expand Down Expand Up @@ -269,19 +274,29 @@ export function buildChooserEnrichmentScript(): string {
if (!root) return;
// Upstream @atproto/oauth-provider-ui renders this as a
// div-with-role, NOT a native button:
// <div role="button" aria-label="Login to account that is not listed">
// Another account
// <div role="button" aria-label="Sign in to an account that is not listed">
// Select another account
// </div>
// The aria-label is more stable across upstream copy changes than
// the visible text, so match on that with a text-content fallback
// scoped to anything with role=button (div OR button).
var btn = root.querySelector(
'[role="button"][aria-label="Login to account that is not listed"]',
);
// The aria-label copy changed in oauth-provider-ui 0.8 ("Login to
// account…" -> "Sign in to an account…", visible text "Another
// account" -> "Select another account"). Match either aria-label,
// then fall back to either visible-text variant scoped to role=button
// (div OR button) so a future copy tweak degrades rather than breaks.
var ARIA_LABELS = [
'Sign in to an account that is not listed',
'Login to account that is not listed',
];
var TEXTS = ['Select another account', 'Another account'];
var btn = null;
for (var a = 0; a < ARIA_LABELS.length && !btn; a++) {
btn = root.querySelector(
'[role="button"][aria-label="' + ARIA_LABELS[a] + '"]',
);
}
if (!btn) {
var candidates = root.querySelectorAll('[role="button"]');
for (var i = 0; i < candidates.length; i++) {
if ((candidates[i].textContent || '').trim() === 'Another account') {
if (TEXTS.indexOf((candidates[i].textContent || '').trim()) >= 0) {
btn = candidates[i];
break;
}
Expand Down
Loading
Loading