Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
edddead
Add opt-in Access validation and preserve account ownership
cody257 Sep 30, 2026
d0329fe
test: run e2e suites on a migration-built D1 database
cody257 Sep 30, 2026
acc3868
feat(ui): apply the CSI Ledger design system to CardCap
cody257 Sep 30, 2026
2f17c9e
test: scope browser image checks to the source panel
cody257 Sep 30, 2026
559ef3d
test: add Cloudflare Access simulator and contract tests
cody257 Sep 30, 2026
c3de28e
feat(ui): session-ended, refused-account and offline states
cody257 Sep 30, 2026
5e0d32b
test: browser specs for session states and phone layout
cody257 Sep 30, 2026
f43dcdf
test: run Access contract tests against the Access worker
cody257 Sep 30, 2026
3e97142
feat(ui): Access-aware sign-in states
cody257 Sep 30, 2026
4453447
test: browser specs behind a simulated Access edge
cody257 Sep 30, 2026
ab1cf1c
test: Access spec uses the configured base URL; refused-account logou…
cody257 Sep 30, 2026
ca9a605
Preserve ownership through auth rollback and check Access browser beh…
cody257 Sep 30, 2026
7bc2725
feat(ui): layout around the next action
cody257 Sep 30, 2026
0907954
test: browser specs for the new layout and menus
cody257 Sep 30, 2026
2176999
test: legacy email-link ambiguity and account reuse checks
cody257 Sep 30, 2026
2f38635
feat(ui): self-host the Atkinson Hyperlegible fonts
cody257 Sep 30, 2026
a09fbdb
feat(ui): one primary Add cards, initials avatars, phone toolbar fixes
cody257 Sep 30, 2026
964e157
test: concurrent first email-link sign-ins create one account
cody257 Sep 30, 2026
fb30de8
test: make the concurrent first-login check independent of the harness
cody257 Sep 30, 2026
d1c8272
feat(ui): ask before the shared sign-out, naming Blackboard
cody257 Sep 30, 2026
c21b7d6
test: browser specs for the shared sign-out question
cody257 Sep 30, 2026
270e66a
feat(ui): shared sign-out question uses Cancel and the agreed sentence
cody257 Sep 30, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ jobs:
- run: npm run lint
- run: npm run build
- run: npm run test:e2e
- run: npm run test:rollback
- run: npx playwright install --with-deps chromium
- run: npm run test:browser
- run: npm run test:browser:access
- uses: actions/upload-artifact@v4
if: failure()
with:
Expand Down
43 changes: 43 additions & 0 deletions e2e/access-rollback.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, expect, it, onTestFailed } from 'vitest'
import { saveTrace, startHarness, type Harness } from './harness'

// Rollback hazard check (evidence for the release plan, not a product fix): after someone has signed in through Access,
// their account row is access:<hash>. If the release is rolled back to the legacy sign-in (AUTH_MODE unset), can that
// person still reach their contacts by the legacy email link, and does the legacy path avoid creating a second account
// with the same email (which would make Access sign-in fail closed with "Account access requires review" after a re-enable)?
// Skipped unless ROLLBACK_CHECK=1 because it documents a known gap that is being reported, not a passing expectation.

let h: Harness
beforeEach(async () => {
h = await startHarness()
})
afterEach(async () => {
await h.dispose()
})

async function seedAccessAccount(email: string, contactName: string) {
const db = await h.mf.getD1Database('DB')
const id = `access:${'a'.repeat(64)}`
await db.prepare("INSERT INTO users (id, email, name, auth_provider, created_at, updated_at) VALUES (?, ?, ?, 'access', 't', 't')").bind(id, email, email).run()
await db
.prepare("INSERT INTO contacts (id, user_id, name, created_at, updated_at) VALUES ('c1', ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')")
.bind(id, contactName)
.run()
return { db, id }
}

it.skipIf(process.env.ROLLBACK_CHECK !== '1')('after a rollback, the legacy email link reaches the contacts of a person who signed in through Access', async () => {
onTestFailed((r) => saveTrace('access-rollback-contacts', h.trace, r.errors?.[0]?.message))
await seedAccessAccount('alice@example.test', 'Alice Contact')
const alice = await h.signIn('alice@example.test')
const list = (await (await alice.request('/api/contacts')).json()) as { contacts: Array<{ name: string }> }
expect(list.contacts.map((c) => c.name)).toEqual(['Alice Contact'])
})

it.skipIf(process.env.ROLLBACK_CHECK !== '1')('after a rollback, the legacy email link does not create a second account with the same email', async () => {
onTestFailed((r) => saveTrace('access-rollback-duplicate', h.trace, r.errors?.[0]?.message))
const { db } = await seedAccessAccount('alice@example.test', 'Alice Contact')
await h.signIn('alice@example.test')
const count = (await db.prepare("SELECT COUNT(*) AS n FROM users WHERE lower(trim(email)) = 'alice@example.test'").first<{ n: number }>())!.n
expect(count).toBe(1)
})
332 changes: 332 additions & 0 deletions e2e/access.e2e.test.ts

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions e2e/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createHmac, createSign, generateKeyPairSync, type KeyObject } from 'node:crypto'

// Cloudflare Access simulator for tests: a throwaway RSA key pair, the JWKS the Worker will fetch from
// <issuer>/cdn-cgi/access/certs (served by the harness's outbound interceptor), and a signed-JWT minter.
// Nothing here can reach production: the keys exist only in the test process.

export const ACCESS_ISSUER = 'https://csi-test.cloudflareaccess.com'
export const ACCESS_AUDIENCE = 'aud-tag-for-cardcap-tests-0123456789'
export const ACCESS_HOSTS = ['cardcap.test', 'cardcap.example.test']
export const JWKS_URL = `${ACCESS_ISSUER}/cdn-cgi/access/certs`

export type AccessKey = { kid: string; privateKey: KeyObject; jwk: Record<string, unknown> }

export function makeAccessKey(kid: string): AccessKey {
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>
return { kid, privateKey, jwk: { ...jwk, kid, alg: 'RS256', use: 'sig' } }
}

export type TokenClaims = {
iss?: string
aud?: string | string[]
sub?: string
email?: string | null
type?: string
iat?: number
nbf?: number
exp?: number
[key: string]: unknown
}

export type MintOptions = {
key: AccessKey
alg?: 'RS256' | 'HS256' | 'none'
kid?: string | null
claims?: TokenClaims
/** flip a byte in the signature after signing */
tamperSignature?: boolean
}

const b64url = (input: Buffer | string) => Buffer.from(input).toString('base64url')

export function defaultClaims(email: string, sub = `sub-${email}`, now = Math.floor(Date.now() / 1000)): TokenClaims {
return { iss: ACCESS_ISSUER, aud: [ACCESS_AUDIENCE], sub, email, type: 'app', iat: now - 5, nbf: now - 5, exp: now + 300 }
}

export function mintToken(options: MintOptions): string {
const { key, alg = 'RS256', claims = {} } = options
const header: Record<string, unknown> = { alg, typ: 'JWT' }
const kid = options.kid === undefined ? key.kid : options.kid
if (kid !== null) header.kid = kid
const payload = claims
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`
let signature: Buffer
if (alg === 'none') {
signature = Buffer.alloc(0)
} else if (alg === 'HS256') {
// Classic algorithm-confusion attempt: HMAC using the public key material as the secret.
signature = createHmac('sha256', String(key.jwk.n)).update(signingInput).digest()
} else {
signature = createSign('RSA-SHA256').update(signingInput).sign(key.privateKey)
}
if (options.tamperSignature && signature.length > 0) {
signature = Buffer.from(signature)
signature[0] ^= 0xff
}
return `${signingInput}.${b64url(signature)}`
}
210 changes: 210 additions & 0 deletions e2e/browser/access.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { expect, test, type Browser, type Page } from '@playwright/test'
import { PNG_BUFFER } from '../fixtures'
import { chooseFromMenu, expectSignedInAs, openContact, WELCOME_HEADING } from './helpers'

// Sign-in through a SIMULATED Cloudflare Access edge (see e2e/browser/server.ts, ACCESS_SIM=1; the port comes from the Playwright project):
// the Worker runs in AUTH_MODE=access, the edge redirects signed-out people to a fake sign-in page, and signed-in
// requests carry a signed Access JWT. This shows how CardCap behaves behind Access; it is not live Access behavior.
test.skip(process.env.ACCESS_SIM !== '1', 'needs the simulated Access server (ACCESS_SIM=1)')

let counter = 0
const uniqueEmail = (name: string) => `${name}-${Date.now()}-${++counter}@example.test`

async function edgeSignIn(page: Page, email: string) {
await expect(page.getByRole('heading', { name: 'Simulated Access sign-in' })).toBeVisible()
await page.getByLabel('Email').fill(email)
await page.getByRole('button', { name: 'Continue' }).click()
}

async function openApp(page: Page, email: string, path = '/') {
await page.goto(path)
await edgeSignIn(page, email)
await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible()
}

async function upload(page: Page) {
await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name: 'maria.png', mimeType: 'image/png', buffer: PNG_BUFFER })
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor')
await expect(page.getByRole('status')).toContainText('Cards added')
}

test.beforeEach(async ({ request }) => {
await request.get('/__test/openai?mode=card')
})

test('first visit goes through the edge, returns to the same address, and shows no CardCap sign-in or password screens', async ({ page, baseURL }) => {
const email = uniqueEmail('alice')
await page.goto('/?from=bookmark')
await expect(page).toHaveURL(/\/sim-idp\?return=/)
await edgeSignIn(page, email)
await expect(page).toHaveURL(new URL('/?from=bookmark', baseURL).toString())
await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible()
await expectSignedInAs(page, email)
await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toHaveCount(0)
await page.getByRole('button', { name: /^Account menu/ }).click()
await expect(page.getByRole('menuitem', { name: 'Set password' })).toHaveCount(0)
await expect(page.getByRole('menuitem', { name: 'Sign out' })).toBeVisible()
})

test('upload → review/edit → save → reload keeps the exact values behind Access', async ({ page }) => {
await openApp(page, uniqueEmail('alice'))
await upload(page)
await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee')
await page.getByRole('button', { name: 'Save Contact' }).click()
await expect(page.getByText('Saved.', { exact: true })).toBeVisible()
await page.reload()
await openContact(page, 'Maria Okafor-Lee')
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee')
await expect(page.locator('.source-body img')).toBeVisible()
})

test('when the session ends the person is told, keeps their edits, signs in again at the edge and is back where they were', async ({ page, request }) => {
const email = uniqueEmail('alice')
await openApp(page, email)
await upload(page)
await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee')

await request.get('/__test/access-expire') // the edge session ends; the next API call gets an edge redirect
await page.getByRole('button', { name: 'Save Contact' }).click()
const dialog = page.getByRole('alertdialog', { name: 'Your session ended' })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('We kept the edits you had not saved')
await expect(page.locator('.banner.error')).toHaveCount(0)

await dialog.getByRole('button', { name: 'Sign in again' }).click()
await edgeSignIn(page, email)
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee')
await expect(page.getByRole('status')).toContainText('We restored the edits you had not saved')
await page.getByRole('button', { name: 'Save Contact' }).click()
await expect(page.getByText('Saved.', { exact: true })).toBeVisible()
await page.reload()
await openContact(page, 'Maria Okafor-Lee')
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee')
})

const SIGN_OUT_TITLE = 'Sign out of CardCap and Blackboard?'

test('sign out asks first, naming Blackboard, then leaves through the Access logout address and the app is closed again', async ({ page }) => {
await openApp(page, uniqueEmail('alice'))
await chooseFromMenu(page, 'Account', 'Sign out')
const dialog = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('This also signs you out of Blackboard')
await expect(dialog).toContainText('up to 30 seconds')
// The safe choice has focus; nothing has been signed out yet.
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused()
await expect(page).not.toHaveURL(/\/cdn-cgi\/access\/logout/)
await dialog.getByRole('button', { name: 'Sign out', exact: true }).click()
await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/)
await expect(page.getByRole('heading', { name: 'You are signed out' })).toBeVisible()
await page.goto('/')
await expect(page).toHaveURL(/\/sim-idp/)
})

test('cancelling (button or Escape) keeps the session and puts focus back on the Account button', async ({ page }) => {
await openApp(page, uniqueEmail('alice'))
const account = page.getByRole('button', { name: /^Account menu/ })
for (const way of ['button', 'escape'] as const) {
await chooseFromMenu(page, 'Account', 'Sign out')
const dialog = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE })
await expect(dialog).toBeVisible()
// Tab stays inside the dialog.
await page.keyboard.press('Tab')
await expect(dialog.getByRole('button', { name: 'Sign out', exact: true })).toBeFocused()
await page.keyboard.press('Tab')
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused()
if (way === 'button') await dialog.getByRole('button', { name: 'Cancel' }).click()
else await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(account).toBeFocused()
await expect(page).not.toHaveURL(/\/cdn-cgi\/access\/logout/)
}
// Still signed in: the app works.
expect((await page.request.get('/api/me')).status()).toBe(200)
})

test('an account that needs review is refused with a clear message and a way out', async ({ page, request }) => {
const email = uniqueEmail('dana')
await request.get(`/__test/seed-duplicate-email?email=${encodeURIComponent(email)}`)
await page.goto('/')
await edgeSignIn(page, email)
const dialog = page.getByRole('alertdialog', { name: 'This account cannot use CardCap' })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('Account access requires review.')
await expect(dialog.getByRole('link', { name: 'cody@copperstateit.com' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Sign out and use another account' })).toBeFocused()
// The way out works even though the Worker refuses this account, and it asks first because it is the shared sign-out.
await dialog.getByRole('button', { name: 'Sign out and use another account' }).click()
const confirm = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE })
await expect(confirm).toBeVisible()
await expect(confirm).toContainText('Blackboard')
// Staying signed in goes back to the refusal message.
await confirm.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Sign out and use another account' }).click()
await confirm.getByRole('button', { name: 'Sign out', exact: true }).click()
await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/)
})

async function person(browser: Browser, baseURL: string | undefined, name: string) {
const context = await browser.newContext({ baseURL })
const page = await context.newPage()
await openApp(page, uniqueEmail(name))
return { context, page }
}

test("another signed-in person cannot see, open the image of, or change someone else's contact", async ({ browser, baseURL }) => {
const alice = await person(browser, baseURL, 'alice')
await upload(alice.page)
const imageUrl = (await alice.page.locator('.source-body img').getAttribute('src'))!
const contacts = (await (await alice.page.request.get('/api/contacts')).json()) as { contacts: Array<{ id: string }> }
const id = contacts.contacts[0].id

const bob = await person(browser, baseURL, 'bob')
await expect(bob.page.getByRole('heading', { name: WELCOME_HEADING })).toBeVisible()
expect((await bob.page.request.get(imageUrl)).status()).toBe(404)
expect((await bob.page.request.get(`/api/contacts/${id}`)).status()).toBe(404)
expect((await bob.page.request.put(`/api/contacts/${id}`, { data: { name: 'Hijacked' } })).status()).toBe(404)
expect((await bob.page.request.delete(`/api/contacts/${id}`)).status()).toBe(404)

await alice.page.reload()
await openContact(alice.page, 'Maria Okafor')
await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor')
await alice.context.close()
await bob.context.close()
})

test.describe('on a phone', () => {
test.use({ viewport: { width: 375, height: 812 }, hasTouch: true })

test('signed-in screen fits, shows who is signed in, and every control is at least 44px tall', async ({ page }) => {
const email = uniqueEmail('alice')
await openApp(page, email)
await upload(page)
await expectSignedInAs(page, email)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375)
const small = await page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('button, a.btn, input:not([type=file]), select, textarea, .contact-row')]
.filter((e) => e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height < 44)
.map((e) => `${e.tagName}:${(e.innerText || e.className).slice(0, 24)}:${Math.round(e.getBoundingClientRect().height)}`),
)
expect(small).toEqual([])
})

test('the sign-out question fits on a phone with 44px buttons and no sideways scroll', async ({ page }) => {
await openApp(page, uniqueEmail('alice'))
await chooseFromMenu(page, 'Account', 'Sign out')
const dialog = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE })
await expect(dialog).toBeVisible()
const box = (await dialog.boundingBox())!
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(375)
expect(box.y).toBeGreaterThanOrEqual(0)
expect(box.y + box.height).toBeLessThanOrEqual(812)
for (const name of ['Cancel', 'Sign out']) {
const button = (await dialog.getByRole('button', { name, exact: true }).boundingBox())!
expect(button.height, name).toBeGreaterThanOrEqual(44)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375)
})
})
Loading
Loading