From 7d57205a46c55d461dce90a60bf032a0fe3d2fb1 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sun, 11 Oct 2026 03:51:11 +0200 Subject: [PATCH 01/20] feat(manifest): a phone side, mobile, built by Expo --- packages/manifest/src/index.ts | 3 ++ packages/manifest/src/schema.ts | 35 +++++++++++++++++-- packages/manifest/src/validate.ts | 10 +++++- packages/manifest/src/words.ts | 5 +++ .../manifest/test/manifest-schema.test.ts | 31 +++++++++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/manifest/src/index.ts b/packages/manifest/src/index.ts index 02d5c05b0..bb150f97a 100644 --- a/packages/manifest/src/index.ts +++ b/packages/manifest/src/index.ts @@ -20,6 +20,8 @@ export { COLUMN_SEMANTICS, COLUMN_ROLES, FRONTEND_KINDS, + PHONE_KIND, + PHONE_SIDE, FIRST_PARTY_PUBLISHER_ID, LOCAL_PUBLISHER_ID, manifestSchema, @@ -107,6 +109,7 @@ export { STATE_ACTIONS_MAX, stateActionIssues, stateActionKind, stateActionSchem export { validateManifest, isManifestOnly, + hasPhoneSide, parseManifest, manifestWarnings, type ValidateManifestResult, diff --git a/packages/manifest/src/schema.ts b/packages/manifest/src/schema.ts index d5cf8e4a6..b1a665426 100644 --- a/packages/manifest/src/schema.ts +++ b/packages/manifest/src/schema.ts @@ -1298,7 +1298,8 @@ export const manifestWidgetSchema = z // ── frontend ───────────────────────────────────────────────────────────────── -export const FRONTEND_KINDS = ['spa', 'electron', 'none'] as const; +/** `expo` is a phone app's: built by Expo, never by the server's bundler, and only for the side `mobile`. */ +export const FRONTEND_KINDS = ['spa', 'electron', 'none', 'expo'] as const; export const frontendEnvVarSchema = z .object({ @@ -1315,7 +1316,16 @@ export const frontendEnvVarSchema = z * plus a staff side, a customer side, or both. At least one, or it is not a * micro-SaaS but somebody's own build. */ -export const FRONTEND_SIDES = ['staff', 'customer'] as const; +export const FRONTEND_SIDES = ['staff', 'customer', 'mobile'] as const; + +/** + * The phone side: a phone app for customers, in `apps//mobile/`. It is a + * side of the app and not a web side: a server never serves it to the public, + * a package carries nothing of it, and it has no address, no placement and no + * switch. What it has is a key of its own kind for the public API. + */ +export const PHONE_SIDE = 'mobile'; +export const PHONE_KIND = 'expo'; export const frontendSchema = z .object({ @@ -1464,6 +1474,26 @@ function sidesAreDistinct(m: { frontends: readonly { side: string }[] }): boolea return true; } +/** + * The phone side is `kind: "expo"` and nothing else, and `expo` is the phone + * side's alone. It names no entry, routes, placement or switch: those are a + * served web side's. + */ +function phoneSideIssues(m: { frontends: readonly Record[] }): { path: (string | number)[]; message: string }[] { + const out: { path: (string | number)[]; message: string }[] = []; + m.frontends.forEach((frontend, index) => { + const phone = frontend['side'] === PHONE_SIDE; + if (phone !== (frontend['kind'] === PHONE_KIND)) { + out.push({ path: ['frontends', index, 'kind'], message: `the side "${PHONE_SIDE}" is kind "${PHONE_KIND}", and "${PHONE_KIND}" is that side's alone` }); + } + if (!phone) return; + for (const word of ['entry', 'env', 'routes', 'placement', 'enabled']) { + if (frontend[word] !== undefined) out.push({ path: ['frontends', index, word], message: `a phone side has no "${word}": it is not served by Adminium` }); + } + }); + return out; +} + const SIDES_MESSAGE = { message: 'each side may appear at most once in `frontends`', path: ['frontends'] as const, @@ -2980,6 +3010,7 @@ export const appManifestSchema = z .refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] }) .refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] }) .superRefine((m, ctx) => { + for (const issue of phoneSideIssues(m)) ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path }); for (const issue of installFloorIssues(m)) ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path }); for (const issue of appReferenceIssues(m)) { ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path }); diff --git a/packages/manifest/src/validate.ts b/packages/manifest/src/validate.ts index 0c82d3067..1721e1442 100644 --- a/packages/manifest/src/validate.ts +++ b/packages/manifest/src/validate.ts @@ -14,6 +14,8 @@ import { cappedFormulaWarnings, manifestSchema, type Manifest, + PHONE_KIND, + PHONE_SIDE, } from './schema.js'; import { plainTextLengthWarnings } from './public-access.js'; import { ledgerIndexIssues } from './ledger-indexes.js'; @@ -193,7 +195,13 @@ export function validateManifest( * carries no built side. */ export function isManifestOnly(manifest: Manifest): boolean { - return manifest.kind === 'app' && manifest.frontends.every((frontend) => frontend.kind === 'none'); + // A phone side is built on the builder's machine and is no part of a package. + return manifest.kind === 'app' && manifest.frontends.every((frontend) => frontend.kind === 'none' || frontend.kind === PHONE_KIND); +} + +/** Whether the app has a phone side (`frontends` names `mobile`). */ +export function hasPhoneSide(manifest: Manifest): boolean { + return manifest.kind === 'app' && manifest.frontends.some((frontend) => frontend.side === PHONE_SIDE); } /** `sampleData.skipWhenShared` names the app's own tables, its `table` is one it shares, and no table left in links to a skipped one. */ diff --git a/packages/manifest/src/words.ts b/packages/manifest/src/words.ts index 32aa8d396..7999694c2 100644 --- a/packages/manifest/src/words.ts +++ b/packages/manifest/src/words.ts @@ -92,6 +92,7 @@ export const INSTALL_FLOOR_WORD_NAMES = [ 'automations.addOnStep', 'email.showWhen', 'placeholder.backup', + 'frontend.mobile', ] as const; export type ManifestWordName = (typeof INSTALL_FLOOR_WORD_NAMES)[number]; @@ -147,6 +148,10 @@ export function installFloorWords(document: unknown): ManifestWord[] { // An app has always had the other blocks: these two it has only since the install floor. if (document['automations'] !== undefined) out.push({ word: 'automations', path: 'automations' }); if (isDoc(document['sampleData']) && document['sampleData']['addOns'] !== undefined) out.push({ word: 'sampleData.addOns', path: 'sampleData.addOns' }); + // A phone side: a server that does not know the side refuses the whole manifest. + list(document['frontends']).forEach((frontend, f) => { + if (isDoc(frontend) && frontend['side'] === 'mobile') out.push({ word: 'frontend.mobile', path: `frontends.${String(f)}.side` }); + }); } list(schema['tables']).forEach((table, t) => { diff --git a/packages/manifest/test/manifest-schema.test.ts b/packages/manifest/test/manifest-schema.test.ts index 094b94c5f..1f27a7738 100644 --- a/packages/manifest/test/manifest-schema.test.ts +++ b/packages/manifest/test/manifest-schema.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from 'vitest'; -import { compareSemver, isManifestOnly, validateManifest, parseManifest } from '../src/index.js'; +import { compareSemver, hasPhoneSide, installFloorWords, isManifestOnly, validateManifest, parseManifest } from '../src/index.js'; /** A minimal-but-complete valid manifest, shaped after the ecommerce-shop * example. */ @@ -127,6 +127,35 @@ describe('validateManifest — identity & policy', () => { expect(some.ok && isManifestOnly(some.manifest)).toBe(false); }); + it('a phone side is `mobile` + `expo`, each the other’s alone, with nothing a served side has', () => { + const phone = (frontend: Record) => validateManifest({ ...validManifest(), frontends: [{ side: 'staff', kind: 'spa' }, frontend] }); + const ok = phone({ side: 'mobile', kind: 'expo' }); + expect(ok.ok, JSON.stringify(ok)).toBe(true); + expect(ok.ok && hasPhoneSide(ok.manifest)).toBe(true); + expect(ok.ok && isManifestOnly(ok.manifest)).toBe(false); + for (const wrong of [ + { side: 'mobile', kind: 'spa' }, + { side: 'customer', kind: 'expo' }, + { side: 'mobile', kind: 'expo', placement: 'external' }, + { side: 'mobile', kind: 'expo', routes: { home: '/' } }, + { side: 'mobile', kind: 'expo', enabled: false }, + { side: 'mobile', kind: 'expo', entry: 'App.tsx' }, + ]) { + expect(phone(wrong).ok, JSON.stringify(wrong)).toBe(false); + } + // An app whose only side is a phone app carries no built side, as one with none does. + const only = validateManifest({ ...validManifest(), frontends: [{ side: 'mobile', kind: 'expo' }] }); + expect(only.ok && isManifestOnly(only.manifest) && hasPhoneSide(only.manifest)).toBe(true); + // Twice is refused as any side is. + expect(validateManifest({ ...validManifest(), frontends: [{ side: 'mobile', kind: 'expo' }, { side: 'mobile', kind: 'expo' }] }).ok).toBe(false); + }); + + it('names a phone side as a word a server may not know', () => { + const words = installFloorWords({ ...validManifest(), frontends: [{ side: 'staff', kind: 'spa' }, { side: 'mobile', kind: 'expo' }] }); + expect(words).toContainEqual({ word: 'frontend.mobile', path: 'frontends.1.side' }); + expect(installFloorWords(validManifest()).some((found) => found.word === 'frontend.mobile')).toBe(false); + }); + it('rejects an unknown category and a non-semver version', () => { expect(validateManifest({ ...validManifest(), categories: ['finance'] }).ok).toBe(false); expect(validateManifest({ ...validManifest(), version: '1.0' }).ok).toBe(false); From d9e4bf2ae6859c857bb9e38252ff6d48381c9224 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sun, 11 Oct 2026 03:51:11 +0200 Subject: [PATCH 02/20] feat(public-api): an app key, a phone app's own kind of key --- apps/server/src/plugins/surfaces.ts | 4 +- apps/server/src/public-api/derive.ts | 8 +- .../server/src/public-api/endpoint-service.ts | 25 +-- apps/server/src/public-api/keys.ts | 49 ++++- apps/server/src/public-api/limiter.ts | 7 +- apps/server/src/public-api/resolve.ts | 8 +- apps/server/src/routes/public-admin/index.ts | 16 +- apps/server/src/routes/public-admin/schema.ts | 4 +- apps/server/src/routes/public/index.ts | 20 +- apps/server/src/routes/public/pictures.ts | 4 +- apps/server/src/security/csrf.ts | 6 +- apps/server/test/public-api-app-keys.test.ts | 182 ++++++++++++++++++ apps/server/test/public-api-derive.test.ts | 6 + 13 files changed, 290 insertions(+), 49 deletions(-) create mode 100644 apps/server/test/public-api-app-keys.test.ts diff --git a/apps/server/src/plugins/surfaces.ts b/apps/server/src/plugins/surfaces.ts index ada6e5890..b69e3a524 100644 --- a/apps/server/src/plugins/surfaces.ts +++ b/apps/server/src/plugins/surfaces.ts @@ -80,7 +80,7 @@ import { resolveLabel, type HostedSurface, type SurfaceSide } from '../cli/surfa import { AppError, AppUnavailableError, ForbiddenError, NotFoundError } from '../errors.js'; import { bcp47 } from '../i18n/bcp47.js'; import { loadOverrideMap, recipientLocale } from '../i18n/server-i18n.js'; -import { openPublishableKey } from '../public-api/keys.js'; +import { managedKeyKind, openPublishableKey, servedToPages } from '../public-api/keys.js'; import { CSRF_FORM_FIELD, normalizeHost } from '../security/csrf.js'; import { appNameOf, @@ -922,7 +922,7 @@ export const surfacesPlugin = fp( const out: Record = {}; for (const key of await keys.listManagedBy(addOnKey)) { if (key.revokedAt !== null || (key.expiresAt !== null && key.expiresAt <= at) || !here.has(key.id)) continue; - if (key.kind !== 'browser' || key.purpose === CUSTOMER_KEY_PURPOSE || keyStaffBinding(key) !== null || key.tokenEncrypted === null) continue; + if (!servedToPages(managedKeyKind(key.kind)) || key.purpose === CUSTOMER_KEY_PURPOSE || keyStaffBinding(key) !== null || key.tokenEncrypted === null) continue; out[key.purpose] = openPublishableKey(crypto, key.tokenEncrypted); } return out; diff --git a/apps/server/src/public-api/derive.ts b/apps/server/src/public-api/derive.ts index c71792c74..7511cf7c5 100644 --- a/apps/server/src/public-api/derive.ts +++ b/apps/server/src/public-api/derive.ts @@ -41,7 +41,9 @@ import { /* -------------------------------------------------------------- derivation */ -export type PublicKeyKind = 'browser' | 'server'; +import { hasVisitorReach, type PublicKeyKind } from './keys.js'; + +export type { PublicKeyKind } from './keys.js'; /** A key's grant: `{ endpointId: methods }`. */ export type AccessMap = Readonly>; @@ -135,7 +137,7 @@ export function deriveScopeDocument( continue; } - if (endpoint.definition.auth.role === 'service_role' && key.kind === 'browser') { + if (endpoint.definition.auth.role === 'service_role' && hasVisitorReach(key.kind)) { issues.push({ code: 'KEY_SERVICE_ROLE_BROWSER', message: `"${endpoint.ref}" is a service-role endpoint, which only a server key may be granted`, @@ -172,7 +174,7 @@ export function deriveScopeDocument( const document: PublicScopeDocument = { version: 1, - side: key.kind === 'server' ? 'staff' : 'customer', + side: hasVisitorReach(key.kind) ? 'customer' : 'staff', resources: resources.sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0)), }; diff --git a/apps/server/src/public-api/endpoint-service.ts b/apps/server/src/public-api/endpoint-service.ts index 0fb5ca4e4..a636288d8 100644 --- a/apps/server/src/public-api/endpoint-service.ts +++ b/apps/server/src/public-api/endpoint-service.ts @@ -44,6 +44,7 @@ import { type PublicEndpoint, type PublicKey, } from '@adminium/meta'; +import { hasVisitorReach, managedKeyKind, refusesBrowserHeaders } from './keys.js'; import { createHash } from 'node:crypto'; import type { SnapshotView } from '../crud/identifiers.js'; @@ -303,7 +304,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { if (!grants && !documentMentions(storedDocument, ref)) continue; out.push({ ref: { id: row.id, name: row.name, prefix: row.prefix, scopeId: row.scopeId }, - kind: row.kind === 'server' ? 'server' : 'browser', + kind: managedKeyKind(row.kind), appKey: row.appKey, managedBy: row.managedBy, access, @@ -324,7 +325,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { for (const scope of await scopes.listByConnection(connectionId)) { const document = parseJson(scope.document); for (const key of await keys.listByScope(scope.id)) { - if (key.kind === 'server' || key.revokedAt !== null || (key.expiresAt !== null && key.expiresAt <= at)) continue; + if (!hasVisitorReach(managedKeyKind(key.kind)) || key.revokedAt !== null || (key.expiresAt !== null && key.expiresAt <= at)) continue; out.push({ id: key.id, scopeId: scope.id, name: key.name, document }); } } @@ -447,7 +448,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { continue; } if (newIssues.length > 0) stillBroken.push(key.ref); - const gains = key.kind === 'browser' ? wideningOf(key.storedDocument, next.document) : []; + const gains = hasVisitorReach(key.kind) ? wideningOf(key.storedDocument, next.document) : []; if (gains.length > 0) widened.push({ ...key.ref, gains }); if (managed.includes(key)) { const held = (key.access[endpointId] ?? []).filter((m) => definition.methods.includes(m)); @@ -474,7 +475,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { const rewritten = new Map(writes.map((w) => [w.key.ref.id, w.document])); const afterSave = held.map((k) => (rewritten.has(k.id) ? { ...k, document: rewritten.get(k.id) } : k)); for (const w of [...writes]) { - if (w.key.kind === 'server') continue; + if (!hasVisitorReach(w.key.kind)) continue; const self = { id: w.key.ref.id, scopeId: w.key.ref.scopeId }; const added = newlyRaised(linkIssuesAcross(w.key.storedDocument, self, held), linkIssuesAcross(w.document, self, afterSave)); if (added.length === 0) continue; @@ -672,11 +673,11 @@ export function createEndpointService(deps: EndpointServiceDeps) { } if (total === 0) issues.push({ code: 'KEY_NO_METHOD', message: 'grant at least one method' }); // A hosted surface is a browser, so its key is a browser key. - if (kind === 'server' && input.appKey !== undefined && input.appKey !== null) { + if (!hasVisitorReach(kind) && input.appKey !== undefined && input.appKey !== null) { issues.push({ code: 'KEY_SERVER_APP_BOUND', message: 'a server key cannot be bound to a hosted app' }); } - if (kind === 'server' && input.origins !== undefined && input.origins.length > 0) { - issues.push({ code: 'KEY_SERVER_ORIGINS', message: 'a server key is refused from every browser; it takes no origins' }); + if (refusesBrowserHeaders(kind) && input.origins !== undefined && input.origins.length > 0) { + issues.push({ code: 'KEY_SERVER_ORIGINS', message: `${kind === 'app' ? 'an app' : 'a server'} key is refused from every browser; it takes no origins` }); } if (changed.length > 0) throw new EndpointChanged(changed); @@ -696,7 +697,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { if (issues.length === 0) { issues.push(...forDerived(derivedDocumentIssues(derivation.document, view, inherited))); } - if (kind !== 'server') { + if (hasVisitorReach(kind)) { issues.push(...linkIssuesAcross(derivation.document, { id: null, scopeId: null }, await heldKeys(connectionId, now()))); } if (issues.length > 0) throw new KeyCreateRefused(dedupeIssues(issues)); @@ -795,7 +796,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { if (key === undefined || key.managedBy === null) { throw new KeyCreateRefused([{ code: 'KEY_REF_UNKNOWN', message: `"${input.keyId}" is not a live key an app made` }]); } - const kind: PublicKeyKind = key.kind === 'server' ? 'server' : 'browser'; + const kind: PublicKeyKind = managedKeyKind(key.kind); const view = await deps.viewFor(connectionId); const inherited = await deps.tenantConfigOf(connectionId); const stored = await endpoints.listByConnection(connectionId); @@ -828,7 +829,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { const stored = parseJson(key.scopeDocument); issues.push(...forDerived(introducedIssues(derivedDocumentIssues(stored, view, inherited), derivedDocumentIssues(derivation.document, view, inherited)))); } - if (kind !== 'server') { + if (hasVisitorReach(kind)) { const self = { id: key.id, scopeId: key.scopeId }; const held = await heldKeys(connectionId, at); issues.push( @@ -874,7 +875,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { const key = (await keys.listLiveDerived(connectionId, at)).find((k) => k.id === input.keyId); // Only a live key an app made; a hand-made key's access is its owner's. if (key === undefined || key.managedBy === null) return { lost: [] }; - const kind: PublicKeyKind = key.kind === 'server' ? 'server' : 'browser'; + const kind: PublicKeyKind = managedKeyKind(key.kind); const map = endpointMap(await endpoints.listByConnection(connectionId)); const refOf = new Map([...map.values()].map((e) => [e.id, e.ref])); const before = parseAccess(key.access); @@ -926,7 +927,7 @@ export function createEndpointService(deps: EndpointServiceDeps) { const before = parseAccess(key.access); if (!Object.keys(before).some((id) => going.has(id))) continue; const access = Object.fromEntries(Object.entries(before).filter(([id]) => !going.has(id))); - const kind: PublicKeyKind = key.kind === 'server' ? 'server' : 'browser'; + const kind: PublicKeyKind = managedKeyKind(key.kind); changed.push({ id: key.id, scopeId: key.scopeId, access, document: JSON.stringify(deriveScopeDocument({ kind, access }, map, view).document) }); } if (changed.length === 0) return { keys: [] }; diff --git a/apps/server/src/public-api/keys.ts b/apps/server/src/public-api/keys.ts index 01bb20b95..ad6395a46 100644 --- a/apps/server/src/public-api/keys.ts +++ b/apps/server/src/public-api/keys.ts @@ -44,15 +44,60 @@ export const SERVER_KEY_PREFIX = 'adm_srv_'; /** `token_encrypted` for a server key: there is nothing to reveal (NOT NULL column). */ export const SERVER_KEY_SEALED_SENTINEL = ''; -export type PublicKeyKind = 'browser' | 'server'; +/** + * An APP key: for a phone app, which is a program and sends no `Origin`. It + * reaches what a browser key reaches (a visitor's reach, a visitor's limits, + * the human check), is re-readable like one (it ships in a bundle), and is + * refused from a browser like a server key, so a key lifted from a phone + * bundle is useless in a web page. Made only for an app with a phone side. + */ +export const APP_KEY_PREFIX = 'adm_app_'; + +export const PUBLIC_KEY_KINDS = ['browser', 'server', 'app'] as const; +export type PublicKeyKind = (typeof PUBLIC_KEY_KINDS)[number]; /** The kind a token's prefix claims, or null for anything else. */ export function keyKindOf(token: string): PublicKeyKind | null { if (token.startsWith(PUBLISHABLE_KEY_PREFIX)) return 'browser'; if (token.startsWith(SERVER_KEY_PREFIX)) return 'server'; + if (token.startsWith(APP_KEY_PREFIX)) return 'app'; return null; } +/** The kind a stored row says, or null for a word this server does not know: such a key answers nothing. */ +export function storedKeyKind(kind: string | null | undefined): PublicKeyKind | null { + return (PUBLIC_KEY_KINDS as readonly string[]).includes(kind ?? '') ? (kind as PublicKeyKind) : null; +} + +/** + * A stored kind as the code that MANAGES keys reads it (lists, grants, rotation): a word this + * server does not know is treated as a browser key, the narrowest of the three there. The + * resolver does not use this: to a request, such a key answers nothing. + */ +export const managedKeyKind = (kind: string | null | undefined): PublicKeyKind => storedKeyKind(kind) ?? 'browser'; + +/** + * The two questions a key's kind answers. Nothing outside this file compares + * a kind with a word (a source test holds that): a third kind met a dozen + * `=== 'browser'` tests that were each false for it. + * + * `hasVisitorReach`: the key stands for a visitor (a browser key, an app key). + * It gets the customer's reach and never a `service_role` endpoint, owes the + * human check, is counted per visitor and against the whole key's ceilings, + * has its widening reviewed, may read pictures, and its secret can be shown + * again. A server key is the opposite on each count. + */ +export const hasVisitorReach = (kind: PublicKeyKind): boolean => kind !== 'server'; + +/** The key must never arrive from a browser (a server key, an app key): any `Origin` or `Sec-Fetch-*` header refuses it. */ +export const refusesBrowserHeaders = (kind: PublicKeyKind): boolean => kind !== 'browser'; + +/** A phone app's key: made and taken back with the app's phone side. */ +export const isPhoneAppKey = (kind: PublicKeyKind): boolean => kind === 'app'; + +/** The key is served to a page by `surface-config.json` and bound by the origin rule: a browser key only. */ +export const servedToPages = (kind: PublicKeyKind): boolean => kind === 'browser'; + /** Secret length after the prefix; matches `adm_sk_` (≈238 bits of base62). */ export const PUBLISHABLE_KEY_SECRET_LENGTH = 40; @@ -91,7 +136,7 @@ export interface GeneratedPublishableKey { } export function generatePublishableKey(kind: PublicKeyKind = 'browser'): GeneratedPublishableKey { - const head = kind === 'server' ? SERVER_KEY_PREFIX : PUBLISHABLE_KEY_PREFIX; + const head = kind === 'server' ? SERVER_KEY_PREFIX : kind === 'app' ? APP_KEY_PREFIX : PUBLISHABLE_KEY_PREFIX; const token = `${head}${secret(PUBLISHABLE_KEY_SECRET_LENGTH)}`; return { token, diff --git a/apps/server/src/public-api/limiter.ts b/apps/server/src/public-api/limiter.ts index 7a21585c7..1e6da6c7f 100644 --- a/apps/server/src/public-api/limiter.ts +++ b/apps/server/src/public-api/limiter.ts @@ -117,6 +117,7 @@ */ import { createHmac, randomBytes } from 'node:crypto'; +import { hasVisitorReach, type PublicKeyKind } from './keys.js'; import { isIPv4, isIPv6 } from 'node:net'; /** Fixed-window counters. */ @@ -404,13 +405,13 @@ export interface RateIdentity { export interface EndpointRateIdentity extends RateIdentity { ref: string; - /** `browser` counts per visitor; `server` counts key-wide. */ - kind: 'browser' | 'server'; + /** A key with a visitor's reach (`browser`, `app`) counts per visitor; `server` counts key-wide. */ + kind: PublicKeyKind; } /** The counter identity for an endpoint's own limit. See the header. */ export function endpointRateKeyFor(id: EndpointRateIdentity): string { - if (id.kind === 'server') return `ep|${id.keyId}:${id.ref}`; + if (!hasVisitorReach(id.kind)) return `ep|${id.keyId}:${id.ref}`; if (id.sessionId !== undefined) return `ep|${id.keyId}:${id.ref}:s:${id.sessionId}`; return `ep|${id.keyId}:${id.ref}:ip:${rateAddress(id.ip)}`; } diff --git a/apps/server/src/public-api/resolve.ts b/apps/server/src/public-api/resolve.ts index 0a9ba12e2..686263d6d 100644 --- a/apps/server/src/public-api/resolve.ts +++ b/apps/server/src/public-api/resolve.ts @@ -26,6 +26,7 @@ import { hashPublishableKey, keyIsLive, keyKindOf, + storedKeyKind, tokenHashEquals, PUBLISHABLE_DISPLAY_PREFIX_LENGTH, type PublicKeyKind, @@ -33,7 +34,7 @@ import { export interface ResolvedKey { keyId: string; - /** `browser` (`adm_pub_`) or `server` (`adm_srv_`), as the row says and the prefix agreed. */ + /** `browser` (`adm_pub_`), `server` (`adm_srv_`) or `app` (`adm_app_`), as the row says and the prefix agreed. */ kind: PublicKeyKind; scopeId: string; connectionId: string; @@ -182,8 +183,9 @@ export function createPublicKeyResolver(deps: ResolverDeps): PublicKeyResolver { * the server prefix would skip the origin check. The hash * covers the prefix, so this can only fail for a forged row. */ - const kind: PublicKeyKind = matched.kind === 'server' ? 'server' : 'browser'; - if (keyKindOf(token) !== kind) return fail('hash-mismatch', { prefix }); + // A kind this server does not know answers nothing: never read as the nearest one it does. + const kind = storedKeyKind(matched.kind); + if (kind === null || keyKindOf(token) !== kind) return fail('hash-mismatch', { prefix }); const scopeRow = await deps.findScopeById(matched.scopeId); if (scopeRow === null) return fail('scope-missing', { keyId: matched.id, scopeId: matched.scopeId }); diff --git a/apps/server/src/routes/public-admin/index.ts b/apps/server/src/routes/public-admin/index.ts index 2a32d8ee7..35c83eb76 100644 --- a/apps/server/src/routes/public-admin/index.ts +++ b/apps/server/src/routes/public-admin/index.ts @@ -51,6 +51,8 @@ import { rotatePublishableKey, sealPublishableKey, SERVER_KEY_SEALED_SENTINEL, + hasVisitorReach, + managedKeyKind, } from '../../public-api/keys.js'; import { compileScope, ScopeCompileError, type ScopeIssue } from '../../public-api/scope.js'; import { derivedDocumentIssues, parseAccess } from '../../public-api/derive.js'; @@ -153,7 +155,7 @@ function keyToDto(row: PublicKey, extras: KeyExtras = NO_EXTRAS): PublicKeyDto { prefix: row.prefix, scopeId: row.scopeId, connectionId: extras.connectionId, - kind: row.kind === 'server' ? 'server' : 'browser', + kind: managedKeyKind(row.kind), access: extras.access, issues: extras.issues.map((i) => ({ ...i })), side: row.side === 'staff' ? 'staff' : 'customer', @@ -650,7 +652,7 @@ export function publicAdminRoutes(deps: PublicAdminRoutesDeps): FastifyPluginAsy ); } const kind = body.kind ?? 'browser'; - if (kind === 'server' && byScope) { + if (!hasVisitorReach(kind) && byScope) { throw new ValidationFailedError('A server key is made from endpoints.', { issues: [{ code: 'KEY_SHAPE_INVALID', message: 'send connectionId with access for a server key' }], }); @@ -661,7 +663,7 @@ export function publicAdminRoutes(deps: PublicAdminRoutesDeps): FastifyPluginAsy tokenHash: generated.tokenHash, // A server key is shown once and stored hash-only: there is nothing // to reveal later, which is what makes "copy it now" true. - tokenEncrypted: kind === 'server' ? SERVER_KEY_SEALED_SENTINEL : sealPublishableKey(crypto, generated.token), + tokenEncrypted: hasVisitorReach(kind) ? sealPublishableKey(crypto, generated.token) : SERVER_KEY_SEALED_SENTINEL, }; if (byAccess) { @@ -784,7 +786,7 @@ export function publicAdminRoutes(deps: PublicAdminRoutesDeps): FastifyPluginAsy // Checked BEFORE the audit row and the decrypt: a server key was // stored hash-only, and "revealing" its empty sentinel would audit a // read that returned nothing and then fail blaming the secret. - if (row.kind === 'server') { + if (!hasVisitorReach(managedKeyKind(row.kind))) { throw new ConflictError('A server key is shown once, when it is made; there is nothing to reveal.', 'CONFLICT', { id: row.id, }); @@ -813,13 +815,13 @@ export function publicAdminRoutes(deps: PublicAdminRoutesDeps): FastifyPluginAsy throw new ConflictError('This key is revoked; create a new one.', 'CONFLICT', { id: row.id }); } // Rotation keeps the kind: a server key rotates to a new `adm_srv_`, - // still shown once. - const kind = row.kind === 'server' ? 'server' : 'browser'; + // still shown once; an app key to a new `adm_app_`, never to a browser key. + const kind = managedKeyKind(row.kind); const next = rotatePublishableKey(kind); await keys.rotate(row.id, { prefix: next.prefix, tokenHash: next.tokenHash, - tokenEncrypted: kind === 'server' ? SERVER_KEY_SEALED_SENTINEL : sealPublishableKey(crypto, next.token), + tokenEncrypted: hasVisitorReach(kind) ? sealPublishableKey(crypto, next.token) : SERVER_KEY_SEALED_SENTINEL, }); deps.invalidateResolver?.(row.id); await bump(); diff --git a/apps/server/src/routes/public-admin/schema.ts b/apps/server/src/routes/public-admin/schema.ts index 344d8b30a..d89e97e48 100644 --- a/apps/server/src/routes/public-admin/schema.ts +++ b/apps/server/src/routes/public-admin/schema.ts @@ -97,8 +97,8 @@ export const publicKeyDto = z.object({ scopeId: z.string(), /** The connection the key's scope reads. */ connectionId: z.string().nullable(), - /** `browser` | `server`. */ - kind: z.enum(['browser', 'server']), + /** `browser` | `server` | `app` (a phone app's key, made by an app's install; never by this API). */ + kind: z.enum(['browser', 'server', 'app']), access: z.array(publicKeyAccessDto), /** * The key's stored document compiled against the schema as it is now. diff --git a/apps/server/src/routes/public/index.ts b/apps/server/src/routes/public/index.ts index 82690a331..14654d534 100644 --- a/apps/server/src/routes/public/index.ts +++ b/apps/server/src/routes/public/index.ts @@ -115,7 +115,7 @@ import { typedDigest, type PublicSessionContext, } from '../../public-api/claim.js'; -import { generatePublicSessionToken, hashPublishableKey, keyKindOf, openPublishableKey } from '../../public-api/keys.js'; +import { generatePublicSessionToken, hashPublishableKey, hasVisitorReach, keyKindOf, openPublishableKey, refusesBrowserHeaders, servedToPages } from '../../public-api/keys.js'; import type { RequestStats } from '../../public-api/stats.js'; import { fetchByPk, parseRecordId, pkLabel } from '../../crud/records.js'; import { tableRulesFor, unstorableText } from '../../crud/column-rules.js'; @@ -415,7 +415,7 @@ function proofOwed( ref: unknown, ): ProofPurpose | null { // A staff-bound key is a signed-in staff member's screen: no proof asked. - if (kind === undefined || key.kind !== 'browser' || key.requiresStaff !== null) return null; + if (kind === undefined || !hasVisitorReach(key.kind) || key.requiresStaff !== null) return null; if (kind === 'claim') return key.scope.claim?.humanCheck === true ? 'claim' : null; const resource = typeof ref === 'string' ? key.scope.byRef.get(ref) : undefined; return resource?.humanCheck === true && !proofExcused(resource, session) ? 'write' : null; @@ -1151,7 +1151,7 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { fail(reply, 503, 'PUBLIC_API_DISABLED', 'The public API is turned off for this instance.'); return null; } - if (tokenKind === 'server') { + if (tokenKind !== null && refusesBrowserHeaders(tokenKind)) { /* * A server key presented with ANY browser provenance is refused, even * from a listed origin. A server key in a page bundle is a server key @@ -1160,8 +1160,8 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { * signal `plugins/csrf.ts` reads) or an `Origin` on a cross-origin call. */ const h = request.headers; - if (h.origin !== undefined || h['sec-fetch-site'] !== undefined || h['sec-fetch-mode'] !== undefined) { - fail(reply, 403, 'PUBLIC_ORIGIN_REFUSED', 'A server key cannot be used from a browser.'); + if (h.origin !== undefined || h['sec-fetch-site'] !== undefined || h['sec-fetch-mode'] !== undefined || h['sec-fetch-dest'] !== undefined) { + fail(reply, 403, 'PUBLIC_ORIGIN_REFUSED', tokenKind === 'app' ? 'An app key cannot be used from a browser.' : 'A server key cannot be used from a browser.'); return null; } } else if (!originAllowed) { @@ -1218,7 +1218,7 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { * would mean teaching the mint schema and Studio to accept a non-URL, and * that belongs with the binding work, not here. */ - if (key.kind === 'browser' && key.origins.length > 0) { + if (servedToPages(key.kind) && key.origins.length > 0) { const origin = request.headers.origin; if (typeof origin !== 'string' || !key.origins.includes(origin)) { fail(reply, 403, 'PUBLIC_ORIGIN_REFUSED', 'This origin is not allowed to use this key.'); @@ -1366,7 +1366,7 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { return null; } const rung = opts.keyWide === false ? null : KEY_RUNGS[limit] ?? null; - if (key.kind === 'browser' && (rung === 'read' || rung === 'write')) { + if (hasVisitorReach(key.kind) && (rung === 'read' || rung === 'write')) { // Reads and writes: within the visitor's share of the key (a signed-in person's own, behind any address), and handed back when the request is refused (`keyHolds`). const held = limiter.holdKey( key.keyId, @@ -1383,7 +1383,7 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { return null; } keyHolds.set(request, held.ticket); - } else if (key.kind === 'browser' && rung !== null && !admit(reply, limiter.hitKey(key.keyId, rung))) { + } else if (hasVisitorReach(key.kind) && rung !== null && !admit(reply, limiter.hitKey(key.keyId, rung))) { return null; } // Replies with a session carry that person's rows: never kept by a browser or a cache. @@ -3987,7 +3987,7 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { fail(reply, 400, 'PUBLIC_WRITE_REFUSED', message, { index, ...(named ?? {}) }); // A proved resource takes its creates one at a time, each with its proof: // one proof must not buy a batch of rows. - if (resource.humanCheck && ok.key.kind === 'browser' && !proofExcused(resource, ok.session) && rows.some((raw) => table.primaryKey.every((c) => !Object.prototype.hasOwnProperty.call(raw, c)))) { + if (resource.humanCheck && hasVisitorReach(ok.key.kind) && !proofExcused(resource, ok.session) && rows.some((raw) => table.primaryKey.every((c) => !Object.prototype.hasOwnProperty.call(raw, c)))) { return fail(reply, 403, 'PUBLIC_PROOF_REQUIRED', 'Rows here are created one at a time.'); } const keyColumns = table.primaryKey; @@ -5030,7 +5030,7 @@ export function publicRoutes(deps: PublicRoutesDeps): FastifyPluginAsyncZod { ): boolean => { const visitor = { keyId: ok.key.keyId, ip: request.ip, sessionId: ok.session?.id, ref: DOCUMENT_RENDER_REF, kind: ok.key.kind }; if (!admit(reply, limiter.hitEndpoint(visitor, DOCUMENT_RENDER_LIMITS.visitor))) return false; - if (ok.key.kind !== 'browser') return true; + if (!hasVisitorReach(ok.key.kind)) return true; return admit(reply, limiter.hitEndpoint({ keyId: ok.key.keyId, ip: request.ip, ref: DOCUMENT_RENDER_REF, kind: 'server' }, DOCUMENT_RENDER_LIMITS.key)); }; diff --git a/apps/server/src/routes/public/pictures.ts b/apps/server/src/routes/public/pictures.ts index b3546a417..423845e95 100644 --- a/apps/server/src/routes/public/pictures.ts +++ b/apps/server/src/routes/public/pictures.ts @@ -39,7 +39,7 @@ import { parseRecordId, pkLabel } from '../../crud/records.js'; import { runList } from '../../crud/list.js'; import type { FileStore } from '../../files/store.js'; import { combinePredicates } from '../../public-api/claim.js'; -import { openPublishableKey } from '../../public-api/keys.js'; +import { hasVisitorReach, openPublishableKey } from '../../public-api/keys.js'; import type { PublicRateLimiter, RateDecision } from '../../public-api/limiter.js'; import { fileIdOf } from '../../public-api/private-file.js'; import { createPictureCache, isPictureMime, matchesTag, pictureHeaders, PICTURE_MAX_BYTES, readCapped, type PictureMime } from '../../public-api/picture.js'; @@ -121,7 +121,7 @@ export function registerPictures(app: FastifyInstance, deps: PictureDeps): void return none(); } // A customer's key, for every visitor: a staff screen's key is no page's. - if (key.kind !== 'browser' || key.side !== 'customer' || key.requiresStaff !== null) return none(); + if (!hasVisitorReach(key.kind) || key.side !== 'customer' || key.requiresStaff !== null) return none(); if (key.managedBy !== null && request.server.surfaceSettings != null) { const availability = availabilityOf(await request.server.surfaceSettings.read(), key.managedBy, 'customer'); if (availability !== 'ok') return deps.fail(reply, 503, availability === 'app-disabled' ? 'APP_DISABLED' : 'SURFACE_OFF', 'This app is switched off right now.'); diff --git a/apps/server/src/security/csrf.ts b/apps/server/src/security/csrf.ts index a7e4be0ad..03c11fc40 100644 --- a/apps/server/src/security/csrf.ts +++ b/apps/server/src/security/csrf.ts @@ -107,7 +107,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import type { FastifyRequest } from 'fastify'; import { deriveKey } from '../config/secrets.js'; -import { PUBLISHABLE_KEY_PREFIX, SERVER_KEY_PREFIX } from '../public-api/keys.js'; +import { keyKindOf } from '../public-api/keys.js'; import { parseBearerApiKey } from '../rbac/api-keys.js'; /** Header the dashboard echoes the bootstrap `csrfToken` in. */ @@ -359,10 +359,10 @@ function formToken(body: unknown): string | undefined { return match?.[1]; } -/** A public API key (`adm_pub_` / `adm_srv_`) in the Authorization header. */ +/** A public API key (`adm_pub_` / `adm_srv_` / `adm_app_`) in the Authorization header. */ function publicKeyBearer(authorization: string | undefined): boolean { const match = /^Bearer\s+(\S+)$/i.exec((authorization ?? '').trim()); - return match !== null && (match[1]!.startsWith(PUBLISHABLE_KEY_PREFIX) || match[1]!.startsWith(SERVER_KEY_PREFIX)); + return match !== null && keyKindOf(match[1]!) !== null; } /** diff --git a/apps/server/test/public-api-app-keys.test.ts b/apps/server/test/public-api-app-keys.test.ts new file mode 100644 index 000000000..ff1e4eddc --- /dev/null +++ b/apps/server/test/public-api-app-keys.test.ts @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * App keys: `adm_app_`, a phone app's. Valid with no Origin, refused from any + * browser, with a visitor's reach and limits (never a server key's), shown + * again like a browser key, rotated as its own kind. A stored kind this + * server does not know answers nothing. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { publicKeysRepo } from '@adminium/meta'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + generatePublishableKey, + hasVisitorReach, + isPhoneAppKey, + keyKindOf, + managedKeyKind, + refusesBrowserHeaders, + servedToPages, + storedKeyKind, +} from '../src/public-api/keys.js'; +import { endpointRateKeyFor } from '../src/public-api/limiter.js'; +import { PUBLIC_ORIGIN, SOURCE_LEGS, type ServedSource, type SourceSpec } from './public-source-dialects.js'; + +const SPEC: SourceSpec = { + ddl: { + sqlite: ['CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body VARCHAR(100) NOT NULL)'], + postgres: ['CREATE TABLE notes (id serial PRIMARY KEY, body varchar(100) NOT NULL)'], + mysql: ['CREATE TABLE notes (id INT AUTO_INCREMENT PRIMARY KEY, body VARCHAR(100) NOT NULL)'], + }, + seed: ["INSERT INTO notes (body) VALUES ('one')"], +}; +const sqlite = SOURCE_LEGS.find((l) => l.dialect === 'sqlite') as (typeof SOURCE_LEGS)[number]; + +let served: ServedSource | null = null; +afterEach(async () => { + await served?.close(); + served = null; +}); + +const read = (s: ServedSource, token: string, headers: Record = {}) => + s.app.inject({ method: 'GET', url: '/api/v1/public/records/notes', headers: { authorization: `Bearer ${token}`, ...headers } }); + +/** + * A browser key made through the API, turned into an app key in the store: + * the API never makes one (an app's install does), and the gate is what is + * under test here. + */ +async function appKey(s: ServedSource): Promise<{ id: string; token: string; browserToken: string }> { + const res = await s.app.inject({ + method: 'POST', + url: '/api/v1/public-keys', + headers: { cookie: s.cookie }, + payload: { name: 'Phone', connectionId: s.connectionId, access: [{ ref: 'notes', methods: ['GET'] }] }, + }); + expect(res.statusCode, res.body).toBe(201); + const { key, token: browserToken } = res.json() as { key: { id: string }; token: string }; + const next = generatePublishableKey('app'); + const row = await publicKeysRepo(s.meta).findById(key.id); + await publicKeysRepo(s.meta).rotate(key.id, { prefix: next.prefix, tokenHash: next.tokenHash, tokenEncrypted: row?.tokenEncrypted ?? 'x' }); + await s.meta.db.updateTable('adminium_public_keys').set({ kind: 'app' }).where('id', '=', key.id).execute(); + return { id: key.id, token: next.token, browserToken }; +} + +describe('the three kinds, as the two questions read them', () => { + it('an app key has a visitor’s reach and is refused from a browser; a page is never served one', () => { + expect(keyKindOf(generatePublishableKey('app').token)).toBe('app'); + expect(generatePublishableKey('app').token.startsWith('adm_app_')).toBe(true); + expect([hasVisitorReach('browser'), hasVisitorReach('app'), hasVisitorReach('server')]).toEqual([true, true, false]); + expect([refusesBrowserHeaders('browser'), refusesBrowserHeaders('app'), refusesBrowserHeaders('server')]).toEqual([false, true, true]); + expect([servedToPages('browser'), servedToPages('app'), servedToPages('server')]).toEqual([true, false, false]); + expect([isPhoneAppKey('browser'), isPhoneAppKey('app'), isPhoneAppKey('server')]).toEqual([false, true, false]); + }); + + it('a stored word this server does not know is no kind to a request, and a browser key to the code that manages keys', () => { + expect(storedKeyKind('app')).toBe('app'); + expect(storedKeyKind('desktop')).toBeNull(); + expect(storedKeyKind(null)).toBeNull(); + expect(managedKeyKind('desktop')).toBe('browser'); + expect(managedKeyKind('app')).toBe('app'); + }); + + it('an app key is counted per visitor, as a browser key is', () => { + const id = { keyId: 'pbk_1', ref: 'orders', ip: '198.51.100.7' }; + expect(endpointRateKeyFor({ ...id, kind: 'app' })).toBe(endpointRateKeyFor({ ...id, kind: 'browser' })); + expect(endpointRateKeyFor({ ...id, kind: 'app' })).not.toBe(endpointRateKeyFor({ ...id, kind: 'server' })); + }); + + it('no file but keys.ts compares a key’s kind with a word', () => { + const src = fileURLToPath(new URL('../src', import.meta.url)); + const found: string[] = []; + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (/\.tsx?$/.test(name) && !path.endsWith(join('public-api', 'keys.ts'))) { + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, index) => { + if (/[kK]ind\s*[!=]==?\s*'(browser|server)'|'(browser|server)'\s*[!=]==?\s*[\w.]*[kK]ind/.test(line)) found.push(`${path.slice(src.length + 1)}:${String(index + 1)}`); + }); + } + } + }; + walk(src); + // A new comparison belongs in keys.ts, as one of its questions: a third kind met a dozen of these, each false for it. + expect(found).toEqual([]); + }); +}); + +describe('app keys [sqlite]', () => { + it('work with no Origin, and are refused with any browser provenance', async () => { + served = await sqlite.serve(SPEC); + const s = served; + const { token, browserToken } = await appKey(s); + + expect((await read(s, token)).statusCode).toBe(200); + for (const headers of [{ origin: PUBLIC_ORIGIN }, { 'sec-fetch-site': 'same-origin' }, { 'sec-fetch-mode': 'cors' }, { 'sec-fetch-dest': 'empty' }]) { + const refused = await read(s, token, headers); + expect(refused.statusCode, JSON.stringify(headers)).toBe(403); + expect((refused.json() as { error: { code: string; message: string } }).error).toMatchObject({ + code: 'PUBLIC_ORIGIN_REFUSED', + message: 'An app key cannot be used from a browser.', + }); + } + // The token it replaced is nobody's now, from a browser or not. + expect((await read(s, browserToken, { origin: PUBLIC_ORIGIN })).statusCode).toBe(401); + }, 90_000); + + it('the stored kind must be the one the token says, and a kind nobody knows answers nothing', async () => { + served = await sqlite.serve(SPEC); + const s = served; + // Each key's row is changed before its first use: a resolved key is kept for a while. + for (const kind of ['browser', 'server', 'desktop']) { + const { id, token } = await appKey(s); + await s.meta.db.updateTable('adminium_public_keys').set({ kind }).where('id', '=', id).execute(); + // The token says "app", so the gate asks no Origin of it; the row disagrees, and it is nobody's key. + expect((await read(s, token)).statusCode, kind).toBe(401); + expect((await read(s, token, { origin: PUBLIC_ORIGIN })).statusCode, kind).toBe(403); + } + }, 90_000); + + it('is listed as an app key, shown again, and rotated to another app key', async () => { + served = await sqlite.serve(SPEC); + const s = served; + const { id, token } = await appKey(s); + + const list = await s.app.inject({ method: 'GET', url: '/api/v1/public-keys', headers: { cookie: s.cookie } }); + expect(list.statusCode, list.body).toBe(200); + expect(JSON.stringify(list.json())).toContain('"kind":"app"'); + + const rotate = await s.app.inject({ method: 'POST', url: `/api/v1/public-keys/${id}/rotate`, headers: { cookie: s.cookie } }); + expect(rotate.statusCode, rotate.body).toBe(200); + const next = (rotate.json() as { token: string }).token; + expect(next.startsWith('adm_app_')).toBe(true); + expect((await read(s, token)).statusCode).toBe(401); + expect((await read(s, next)).statusCode).toBe(200); + expect((await read(s, next, { origin: PUBLIC_ORIGIN })).statusCode).toBe(403); + + const reveal = await s.app.inject({ method: 'GET', url: `/api/v1/public-keys/${id}/reveal`, headers: { cookie: s.cookie } }); + expect(reveal.statusCode, reveal.body).toBe(200); + expect((reveal.json() as { token: string }).token).toBe(next); + }, 90_000); + + it('the API makes no app key: an app’s install does', async () => { + served = await sqlite.serve(SPEC); + const s = served; + const res = await s.app.inject({ + method: 'POST', + url: '/api/v1/public-keys', + headers: { cookie: s.cookie }, + payload: { name: 'Phone', kind: 'app', connectionId: s.connectionId, access: [{ ref: 'notes', methods: ['GET'] }] }, + }); + // Refused at the door: `kind` is `browser` or `server` there. + expect(res.statusCode).toBe(422); + expect(res.body).not.toContain('adm_app_'); + }, 90_000); +}); diff --git a/apps/server/test/public-api-derive.test.ts b/apps/server/test/public-api-derive.test.ts index 3bc32536e..cc5ca1dd0 100644 --- a/apps/server/test/public-api-derive.test.ts +++ b/apps/server/test/public-api-derive.test.ts @@ -123,6 +123,12 @@ describe('deriveScopeDocument', () => { expect(deriveScopeDocument({ kind: 'server', access: { pep_p: ['GET'] } }, eps, view).document.side).toBe('staff'); }); + it('an app key (a phone app’s) is customer-side, and is refused a service-role endpoint as a browser key is', () => { + expect(deriveScopeDocument({ kind: 'app', access: { pep_p: ['GET'] } }, eps, view).document.side).toBe('customer'); + const staff = map({ id: 'pep_o', ref: 'orders', definition: orders({ auth: { role: 'service_role' } }) }); + expect(deriveScopeDocument({ kind: 'app', access: { pep_o: ['GET'] } }, staff, view).issues.map((issue) => issue.code)).toContain('KEY_SERVICE_ROLE_BROWSER'); + }); + it('one key, several endpoints, different methods on each — sorted by ref', () => { const { document, issues, suspended } = deriveScopeDocument( { kind: 'browser', access: { pep_p: ['GET'], pep_o: ['GET', 'PATCH'] } }, From 84bcfb093f0ecd5bcb84697face737178919afe2 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sun, 11 Oct 2026 03:52:21 +0200 Subject: [PATCH 03/20] feat(apps): an app with a phone side gets an app key at install --- apps/server/src/apps/install-service.ts | 22 +- apps/server/src/apps/manifest-public.ts | 54 ++++- apps/server/src/crud/unbuilt-rules.ts | 2 + apps/server/src/project/apps/check-app.ts | 16 +- apps/server/src/project/apps/phone.ts | 219 ++++++++++++++++++ apps/server/test/app-install-pipeline.test.ts | 55 +++++ 6 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/project/apps/phone.ts diff --git a/apps/server/src/apps/install-service.ts b/apps/server/src/apps/install-service.ts index e39cf2ba1..c4655e592 100644 --- a/apps/server/src/apps/install-service.ts +++ b/apps/server/src/apps/install-service.ts @@ -17,6 +17,7 @@ * that carries an app — builds its own actor and host and calls the same code. */ +import { hasVisitorReach, isPhoneAppKey, managedKeyKind, servedToPages } from '../public-api/keys.js'; import { compareSemver, isAddOnManifest, @@ -1199,7 +1200,7 @@ export function createAppInstallService(deps: AppRoutesDeps) { const own = await publicKeysRepo(deps.meta).listManagedBy(manifest.key); const stored = new Set((await publicEndpointsRepo(deps.meta).listByConnection(connectionId)).map((e) => e.ref)); for (const entry of planned) { - const live = own.filter((k) => k.purpose === entry.key && k.kind === 'browser' && here.has(k.id)); + const live = own.filter((k) => k.purpose === entry.key && servedToPages(managedKeyKind(k.kind)) && here.has(k.id)); if (live.length === 0) { onUpdate.set(entry.ref, own.some((k) => k.purpose === entry.key) ? 'withheld' : 'granted'); continue; @@ -1576,7 +1577,10 @@ export function createAppInstallService(deps: AppRoutesDeps) { // Each of the app's keys is made once: the public side's, and a kiosk's. const at = Date.now(); const own = await publicKeysRepo(deps.meta).listManagedBy(manifest.key); - const live = own.filter((k) => k.kind === 'browser' && k.revokedAt === null && (k.expiresAt === null || k.expiresAt > at)); + const fresh = own.filter((k) => k.revokedAt === null && (k.expiresAt === null || k.expiresAt > at)); + const live = fresh.filter((k) => servedToPages(managedKeyKind(k.kind))); + // The phone app's key: a key of its own kind beside the customer key, settled with it. + const phoneKeys = own.filter((k) => isPhoneAppKey(managedKeyKind(k.kind))); // An add-on has no `customer` key of its own: its one key is its link's. const declaredPurposes = [...(manifest.kind === 'app' ? [CUSTOMER_KEY_PURPOSE] : []), ...Object.keys(manifest.publicKeys ?? {})]; const livePurposes = new Set(declaredPurposes.filter((purpose) => live.some((k) => k.purpose === purpose))); @@ -1587,6 +1591,14 @@ export function createAppInstallService(deps: AppRoutesDeps) { const here = new Set((await publicKeysRepo(deps.meta).listLiveDerived(connectionId, at)).map((k) => k.id)); const liveKeys = new Map(); for (const purpose of livePurposes) liveKeys.set(purpose, live.filter((k) => k.purpose === purpose && here.has(k.id)).map((k) => k.id)); + const phoneLive = phoneKeys.filter((k) => fresh.includes(k) && here.has(k.id)).map((k) => k.id); + if (phoneLive.length > 0) liveKeys.set(CUSTOMER_KEY_PURPOSE, [...(liveKeys.get(CUSTOMER_KEY_PURPOSE) ?? []), ...phoneLive]); + const phone = { + live: phoneLive.length > 0, + // An install starts afresh; an update never makes again a key the operator took back. + withheld: !strict && phoneLive.length === 0 && phoneKeys.length > 0, + customerKeyId: live.find((k) => k.purpose === CUSTOMER_KEY_PURPOSE && here.has(k.id))?.id ?? null, + }; const record = publicAccessRecorder(actor, manifest.key, connectionId, userId); try { const { changedKeys, ...reply } = await installPublicAccess({ @@ -1602,6 +1614,7 @@ export function createAppInstallService(deps: AppRoutesDeps) { livePurposes, withheld, liveKeys, + phone, grant: grant === true, ...(grant === true ? {} : { refusal: grant.refusal }), ...(ownFolder && grant === true ? { ownFolder: true } : {}), @@ -2934,7 +2947,8 @@ export function createAppInstallService(deps: AppRoutesDeps) { const at = Date.now(); const here = new Set((await publicKeysRepo(deps.meta).listLiveDerived(connectionId, at)).map((k) => k.id)); return (await publicKeysRepo(deps.meta).listManagedBy(appKey)).filter( - (k) => k.kind === 'browser' && k.revokedAt === null && (k.expiresAt === null || k.expiresAt > at) && here.has(k.id), + // The phone app's key holds what the customer key holds: an add-on's entries go on both. + (k) => hasVisitorReach(managedKeyKind(k.kind)) && k.revokedAt === null && (k.expiresAt === null || k.expiresAt > at) && here.has(k.id), ); } @@ -3114,7 +3128,7 @@ export function createAppInstallService(deps: AppRoutesDeps) { if (app === null || app === 'unreadable' || !namedAddOns(app.kind === 'app' ? app.addOns : undefined).some((need) => need.key === manifest.key)) continue; const at = Date.now(); const here = new Set((await publicKeysRepo(deps.meta).listLiveDerived(connectionId, at)).map((k) => k.id)); - const live = (await publicKeysRepo(deps.meta).listManagedBy(appKey)).filter((k) => k.kind === 'browser' && k.purpose === CUSTOMER_KEY_PURPOSE && here.has(k.id)); + const live = (await publicKeysRepo(deps.meta).listManagedBy(appKey)).filter((k) => hasVisitorReach(managedKeyKind(k.kind)) && k.purpose === CUSTOMER_KEY_PURPOSE && here.has(k.id)); const held = new Set(); for (const entry of planned.filter((candidate) => candidate.key !== linkKey)) { let all = live.length > 0; diff --git a/apps/server/src/apps/manifest-public.ts b/apps/server/src/apps/manifest-public.ts index 485e6098e..f8fb66df2 100644 --- a/apps/server/src/apps/manifest-public.ts +++ b/apps/server/src/apps/manifest-public.ts @@ -24,7 +24,7 @@ * stop the key working — the public API off, no `self` in the origin list, * a database with no time zone — before anything is written. */ -import { ownLinksOfPerson, type Manifest } from '@adminium/manifest'; +import { hasPhoneSide, ownLinksOfPerson, type Manifest } from '@adminium/manifest'; import { CUSTOMER_KEY_PURPOSE, connectionTenantConfig, @@ -46,7 +46,7 @@ import type { SnapshotView } from '../crud/identifiers.js'; import { copyPlainText } from '../public-api/anonymous-caps.js'; import { endpointIssues, type PublicEndpointDefinition, type PublicMethod } from '../public-api/endpoint.js'; import { EndpointSaveRefused, KeyCreateRefused, type EndpointService } from '../public-api/endpoint-service.js'; -import { generatePublishableKey, sealPublishableKey } from '../public-api/keys.js'; +import { generatePublishableKey, isPhoneAppKey, managedKeyKind, sealPublishableKey } from '../public-api/keys.js'; import { managedGrantIssues } from '../public-api/managed-key.js'; import { guestBase } from '../public-api/guest-base.js'; import { addAndReadRefusal, anonymousAddAndRead } from './anonymous-access.js'; @@ -747,6 +747,13 @@ export async function installPublicAccess(input: { onCommitted?: ((change: PublicAccessCommit) => Promise) | undefined; /** For an app: the add-ons that are here for it, whose entries its key may hold too. */ addOns?: readonly AddOnEntries[] | undefined; + /** + * The phone app's key, for an app with a phone side: whether one is live + * (it is then among `liveKeys`' `customer` keys and settled with them), + * whether the operator took one back, and the live customer key whose + * access a new one is made with. + */ + phone?: { live: boolean; withheld: boolean; customerKeyId: string | null } | undefined; }): Promise<{ endpoints: string[]; keyId: string | null; @@ -928,6 +935,42 @@ export async function installPublicAccess(input: { } } } + /* + * The phone app's key: a key of its own kind with the customer key's + * access, made once, when the app has a phone side and a customer key. + * It is never made alone (no customer key, no phone key), never again + * after the operator took it back, and only when the operator allowed + * what the version adds. + */ + const phoneFrom = keys[CUSTOMER_KEY_PURPOSE] ?? input.phone?.customerKeyId ?? null; + if (hasPhoneSide(manifest) && input.phone !== undefined && !input.phone.live && !input.phone.withheld && grant && phoneFrom !== null) { + const held = await input.service.heldByKey(input.connectionId, phoneFrom); + const access = [...held].filter(([, methods]) => methods.length > 0).map(([ref, methods]) => ({ ref, methods: [...methods] as PublicMethod[] })); + if (access.length > 0) { + const generated = generatePublishableKey('app'); + try { + const made = await input.service.createKey({ + connectionId: input.connectionId, + name: `${input.appName} · phone app`, + access, + secret: { prefix: generated.prefix, tokenHash: generated.tokenHash, tokenEncrypted: sealPublishableKey(input.crypto, generated.token) }, + appKey: manifest.key, + origins: [], + kind: 'app', + actorId: input.actorId, + managedBy: manifest.key, + purpose: CUSTOMER_KEY_PURPOSE, + }); + const peak = manifest.kind === 'app' ? manifest.publicKeys?.[CUSTOMER_KEY_PURPOSE]?.peak : undefined; + if (peak !== undefined) await publicKeysRepo(input.meta).setPeak(made.key.id, peak); + await input.onCommitted?.({ kind: 'key', purpose: CUSTOMER_KEY_PURPOSE, keyId: made.key.id, access: access.map((entry) => entry.ref) }); + } catch (error) { + // The customer key stands; the phone app has no key, and the reply says why. + if (!(error instanceof KeyCreateRefused)) throw error; + skipped.push({ ref: 'phone app key', reason: error.issues.map((issue) => issue.message).join('; ') }); + } + } + } const granted: Record = {}; for (const { purpose, gained } of changedKeys) { if (gained.length > 0) granted[purpose] = [...new Set([...(granted[purpose] ?? []), ...gained])].sort(); @@ -994,6 +1037,13 @@ export async function takeBackPublicAccess(input: { const staffed = (purpose: string) => manifest.kind === 'app' && manifest.publicKeys?.[purpose]?.requiresStaff !== undefined; for (const key of live) { const purpose = key.purpose; + // The phone app's key goes with the phone side. + if (isPhoneAppKey(managedKeyKind(key.kind)) && !hasPhoneSide(manifest)) { + await keysRepo.revoke(key.id); + await publicApiStateRepo(input.meta).bump(); + await input.onCommitted({ kind: 'revoke', keyId: key.id, purpose }); + continue; + } // An add-on has no `customer` key: a key of its own is its link's, or it goes. if ((purpose !== CUSTOMER_KEY_PURPOSE || manifest.kind !== 'app') && !staffed(purpose) && !opensByToken(manifest, purpose)) { await keysRepo.revoke(key.id); diff --git a/apps/server/src/crud/unbuilt-rules.ts b/apps/server/src/crud/unbuilt-rules.ts index 185942c28..fb70861f2 100644 --- a/apps/server/src/crud/unbuilt-rules.ts +++ b/apps/server/src/crud/unbuilt-rules.ts @@ -68,6 +68,8 @@ export const MANIFEST_WORDS_RUN: readonly ManifestWordName[] = [ // What a template writes when a value is missing: a block tied to a value, a backup after a bar. The email renderer's. 'email.showWhen', 'placeholder.backup', + // A phone side: its key is made at install, and its preview built in a project being designed. + 'frontend.mobile', // The price question: a host's rule on its orders, and the add-on's side of it. Asked inside every save that moves an order's price. 'table.adjust', 'addOn.adjuster', diff --git a/apps/server/src/project/apps/check-app.ts b/apps/server/src/project/apps/check-app.ts index 1977b49e1..9ed0cfe9b 100644 --- a/apps/server/src/project/apps/check-app.ts +++ b/apps/server/src/project/apps/check-app.ts @@ -31,6 +31,7 @@ import { unbuiltInManifest } from '../../crud/unbuilt-rules.js'; import { declaredLedgers, hostAdjustIssue, hostPostingIssue } from './ledger-parts.js'; import { serverCodeSources } from '../build-shared.js'; import { MANIFEST_FILE, MANIFEST_PARTS_DIR, SIDES, appPath, readAppFolder, sideEntry, type AppFolder, type AppProblem, type AppSide } from './read-app.js'; +import { PHONE_SIDE, hasPhoneCode, phoneShapeProblems } from './phone.js'; import { hasOwnBuild } from './own-build.js'; export interface AppFinding extends AppProblem { @@ -47,6 +48,8 @@ export interface AppCheck { access: string[]; /** The sides that have code to build. */ sides: AppSide[]; + /** Whether the app has a phone side's code (`mobile/`): built by Expo, never with `sides`. */ + phone: boolean; } /** `a`, `a and b`, `a, b and c`. */ @@ -116,7 +119,8 @@ export function checkApp(root: string, key: string, opts: { version: string; add // An app with a build of its own keeps its screens where its build wants them: the manifest says which sides it has. const ownBuild = hasOwnBuild(root, key); let sides = SIDES.filter((side) => sideEntry(root, key, side) !== null); - const result = (manifest: AppManifest | null, access: string[] = []): AppCheck => ({ key, folder, manifest, findings, access, sides }); + const phone = hasPhoneCode(root, key); + const result = (manifest: AppManifest | null, access: string[] = []): AppCheck => ({ key, folder, manifest, findings, access, sides, phone }); if (folder.document === null) return result(null); /** A validator path, said against the file it is written in. */ @@ -164,7 +168,17 @@ export function checkApp(root: string, key: string, opts: { version: string; add if (ownBuild) sides = SIDES.filter((side) => manifest.frontends.some((frontend) => frontend.side === side && frontend.kind === 'spa')); // Every screen the manifest declares has its code, and the reverse. + // The phone side: declared and there, or neither; and its folder holds nothing Expo would run unasked. + const declaresPhone = manifest.frontends.some((frontend) => frontend.side === PHONE_SIDE); + if (declaresPhone && !phone) { + const index = manifest.frontends.findIndex((frontend) => frontend.side === PHONE_SIDE); + findings.push(error(at(`frontends.${String(index)}`).file, `frontends.${String(index)}`, `declares a phone side, and ${appPath(key, PHONE_SIDE, 'App.tsx')} does not exist.`)); + } + if (!declaresPhone && phone) findings.push(error(appPath(key, PHONE_SIDE), '', "has code, and the manifest's frontends do not list a mobile side.")); + if (declaresPhone || phone) for (const problem of phoneShapeProblems(root, key)) findings.push(error(problem.file, '', problem.message)); + for (const [index, frontend] of ownBuild ? [] : manifest.frontends.entries()) { + if (frontend.side === PHONE_SIDE) continue; const built = sides.includes(frontend.side); if (frontend.kind === 'spa' && !built) { findings.push( diff --git a/apps/server/src/project/apps/phone.ts b/apps/server/src/project/apps/phone.ts new file mode 100644 index 000000000..c69a4ce8a --- /dev/null +++ b/apps/server/src/project/apps/phone.ts @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * An app's phone side: `apps//mobile/`, an Expo project. + * + * It is a side of the app and not a web side. The server's bundler never + * reads it; Expo builds it, and Expo's build runs code where the bundler only + * reads it. So this file holds the three things that keep that safe: + * + * - the SHAPE the folder must have ({@link phoneShapeProblems}): no file + * Expo, Metro or Babel would run as configuration, none of the fields that + * name one, nothing that leaves the folder; + * - the APPROVAL ({@link isPhoneApproved}): a person's yes, kept where an own + * build's is kept, of the exact files that decide what is installed and + * run. A change to any of them asks again; + * - where things are ({@link phoneDir}, {@link phoneBuildDir}): the preview's + * build has a folder of its own, which `start`, `build` and `pack` never + * read, so a phone preview is never served as a website. + */ +import { createHash } from 'node:crypto'; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { PHONE_SIDE } from '@adminium/manifest'; + +import { BUILD_DIR } from '../build-shared.js'; +import { approvalsFile } from './own-build.js'; +import { APPS_DIR, appDir } from './read-app.js'; + +export { PHONE_SIDE }; + +/** The phone side's folder, under the app's. */ +export const phoneDir = (root: string, key: string): string => join(appDir(root, key), PHONE_SIDE); + +/** Where the phone preview's build is kept: beside the apps' builds, never inside them. */ +export const PHONE_BUILD_DIR = 'phone'; +export const phoneBuildDir = (root: string, key: string): string => join(root, BUILD_DIR, PHONE_BUILD_DIR, key); + +/** The screen a phone side starts from. */ +export const PHONE_ENTRY = 'App.tsx'; +/** The files of a phone side that are the engine's: written by the scaffold, never by the Designer. */ +export const PHONE_ENGINE_FILES = ['package.json', 'app.json', 'index.ts', 'tsconfig.json', 'src/adminium.ts'] as const; +/** The lock a phone side's install writes. */ +export const PHONE_LOCK = 'package-lock.json'; + +/** Whether the app has a phone side's code. */ +export const hasPhoneCode = (root: string, key: string): boolean => existsSync(join(phoneDir(root, key), PHONE_ENTRY)); + +/** + * A file Expo, Metro, Babel or PostCSS runs or reads as configuration, by its + * name. Refused in the phone side's folder and in the app's (Metro looks for + * its own upward from the project it bundles). + */ +const CONFIG_NAME = /^(metro\.config|babel\.config|app\.config|react-native\.config|postcss\.config)\.[a-z]+$|^\.babelrc(\.[a-z]+)?$/i; + +/** The fields of `package.json` that name code to run, or a configuration. */ +const PACKAGE_FIELDS = ['scripts', 'babel', 'metro', 'expo', 'workspaces', 'overrides', 'resolutions'] as const; + +/** A problem with a phone side's folder, said against a file. */ +export interface PhoneProblem { + file: string; + message: string; +} + +const where = (key: string, ...parts: string[]): string => [APPS_DIR, key, PHONE_SIDE, ...parts].join('/'); + +function json(file: string): Record | null { + try { + const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')); + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** Every file and folder under `dir`, as paths from it, leaving `node_modules` and Expo's own `.expo` out. */ +function walk(dir: string, prefix = '', out: { path: string; link: boolean; folder: boolean }[] = []): { path: string; link: boolean; folder: boolean }[] { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return out; + } + for (const name of names.sort()) { + if (name === 'node_modules' || name === '.expo') continue; + const path = prefix === '' ? name : `${prefix}/${name}`; + const stat = lstatSync(join(dir, name)); + out.push({ path, link: stat.isSymbolicLink(), folder: stat.isDirectory() }); + // A link is never followed: where it leads is not the app's. + if (stat.isDirectory() && !stat.isSymbolicLink()) walk(join(dir, name), path, out); + } + return out; +} + +/** + * What is wrong with the phone side's folder, or nothing. Every entry is a + * way for a file to be run by Expo's build without a person having said yes + * to it, or to reach outside the folder. + */ +export function phoneShapeProblems(root: string, key: string): PhoneProblem[] { + const dir = phoneDir(root, key); + const problems: PhoneProblem[] = []; + if (!existsSync(dir)) return problems; + + // Metro looks for its configuration upward: the app's own folder counts. + for (const name of readdirSync(appDir(root, key))) { + if (CONFIG_NAME.test(name)) problems.push({ file: `${APPS_DIR}/${key}/${name}`, message: 'is a file Expo would run as configuration. An app with a phone side has none: take it out.' }); + } + const above = json(join(appDir(root, key), 'package.json')); + for (const field of ['metro', 'babel'] as const) { + if (above !== null && above[field] !== undefined) problems.push({ file: `${APPS_DIR}/${key}/package.json`, message: `has "${field}", which Expo would read as configuration. Take it out.` }); + } + + for (const entry of walk(dir)) { + const name = entry.path.split('/').pop() as string; + if (entry.link) { + problems.push({ file: where(key, entry.path), message: 'is a link. A phone side holds its own files only.' }); + continue; + } + if (CONFIG_NAME.test(name)) { + problems.push({ file: where(key, entry.path), message: 'is a file Expo would run as configuration. A phone side has none: take it out.' }); + } else if (name.startsWith('.') && name !== '.gitignore') { + // `.env*` is loaded into the build's own environment; any other dot file is somebody's configuration. + problems.push({ file: where(key, entry.path), message: 'is a dot file. A phone side has none but .gitignore: Expo loads .env files into its build.' }); + } else if (name === 'package.json' && entry.path !== 'package.json') { + problems.push({ file: where(key, entry.path), message: 'is a second package.json. A phone side has one, at its top.' }); + } + } + + const pkg = json(join(dir, 'package.json')); + if (pkg === null) { + problems.push({ file: where(key, 'package.json'), message: 'is missing or does not read. A phone side is an Expo project: add it with adminium app new --mobile or the Designer.' }); + } else { + for (const field of PACKAGE_FIELDS) { + if (pkg[field] !== undefined) problems.push({ file: where(key, 'package.json'), message: `has "${field}", which names code to run or a configuration. Take it out.` }); + } + if (pkg['main'] !== 'index.ts') problems.push({ file: where(key, 'package.json'), message: 'its "main" is "index.ts", the file the phone side starts from.' }); + const deps = pkg['dependencies']; + if (deps === null || typeof deps !== 'object' || (deps as Record)['expo'] === undefined) { + problems.push({ file: where(key, 'package.json'), message: 'does not list "expo". A phone side is an Expo project.' }); + } + } + + const app = json(join(dir, 'app.json')); + const expo = app === null ? null : app['expo']; + if (expo === null || typeof expo !== 'object' || Array.isArray(expo)) { + problems.push({ file: where(key, 'app.json'), message: 'is missing or has no "expo" block.' }); + } else { + for (const field of ['plugins', 'hooks', 'extra', 'updates'] as const) { + if ((expo as Record)[field] !== undefined) problems.push({ file: where(key, 'app.json'), message: `has "${field}", which runs code in Expo's build or sends the app elsewhere. Take it out.` }); + } + } + + const ts = json(join(dir, 'tsconfig.json')); + if (ts !== null) { + const options = ts['compilerOptions']; + if (options !== null && typeof options === 'object' && ((options as Record)['paths'] !== undefined || (options as Record)['baseUrl'] !== undefined)) { + problems.push({ file: where(key, 'tsconfig.json'), message: 'has "paths" or "baseUrl": a phone side imports its own files by their place, and packages by name.' }); + } + if (ts['extends'] !== undefined && ts['extends'] !== 'expo/tsconfig.base') problems.push({ file: where(key, 'tsconfig.json'), message: 'may extend "expo/tsconfig.base" only.' }); + } + + if (!existsSync(join(dir, PHONE_ENTRY))) problems.push({ file: where(key, PHONE_ENTRY), message: 'does not exist. It is the phone side’s first screen.' }); + if (!existsSync(join(dir, 'index.ts'))) problems.push({ file: where(key, 'index.ts'), message: 'does not exist. It starts the phone side.' }); + return problems; +} + +// ── the approval ───────────────────────────────────────────────────────────── + +/** The name a phone side's approval is kept under, beside the app's own build's. */ +const approvalName = (key: string): string => `${key}#${PHONE_SIDE}`; + +/** What Expo's install and build are run as: said to the person who approves, and part of what is approved. */ +export const PHONE_INSTALL_WORDS = 'npm install (no install scripts)'; +export const PHONE_BUILD_WORDS = 'expo export --platform web'; + +const sha256 = (text: string | Buffer): string => createHash('sha256').update(text).digest('hex'); + +/** + * What an approval is of: the two lines, and the bytes of the files that + * decide what is installed and run. A change to any of them, by a person, a + * model or whoever sent the folder, asks again. + */ +export function phoneFingerprint(root: string, key: string): string { + const dir = phoneDir(root, key); + const parts = [PHONE_INSTALL_WORDS, PHONE_BUILD_WORDS]; + for (const file of ['package.json', PHONE_LOCK, 'app.json', 'index.ts', 'tsconfig.json']) { + let bytes: Buffer | null = null; + try { + bytes = readFileSync(join(dir, file)); + } catch { + bytes = null; + } + parts.push(`${file}:${bytes === null ? '-' : sha256(bytes)}`); + } + return sha256(parts.join('\n')); +} + +function approvals(root: string): Record { + try { + const parsed = JSON.parse(readFileSync(approvalsFile(root), 'utf8')) as unknown; + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** Whether a person approved this phone side as it reads now. */ +export const isPhoneApproved = (root: string, key: string): boolean => approvals(root)[approvalName(key)] === phoneFingerprint(root, key); + +/** Record that a person approved this app's phone side as it reads now. */ +export function approvePhone(root: string, key: string): void { + const file = approvalsFile(root); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ ...approvals(root), [approvalName(key)]: phoneFingerprint(root, key) }, null, 2)}\n`, { mode: 0o600 }); +} + +/** The sentence an unapproved phone side is noted with: what a person is asked to approve. */ +export const phoneUnapprovedWords = (key: string): string => + `${APPS_DIR}/${key}/${PHONE_SIDE} is not approved, so its phone preview was not built. It would install Expo's packages (about 350 MB) and build with Expo on this computer. Approve it with: adminium app approve-build ${key}`; diff --git a/apps/server/test/app-install-pipeline.test.ts b/apps/server/test/app-install-pipeline.test.ts index 3fe5f46ef..8435e22d8 100644 --- a/apps/server/test/app-install-pipeline.test.ts +++ b/apps/server/test/app-install-pipeline.test.ts @@ -8,6 +8,7 @@ * Nothing here is faked below the HTTP route: the connection manager, the * introspection, the schema editor's plan and apply, and the DDL all run. */ +import { APP_VERSION } from '../src/version.js'; import { createHash, randomBytes } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -3873,6 +3874,60 @@ for (const [dialect, available] of legs) { expect(await endpoints()).toEqual([]); }, 60_000); + it('an app with a phone side gets an app key beside its customer key, holding the same, and loses it with the side', async () => { + const h = (open = await harness(dialect)); + const phone = (version: string, mobile: boolean, select: string[] = ['id', 'name']) => ({ + ...MANIFEST, + version, + // A phone side is a word of this release: the floor is this server's own version. + compatibility: { minAdminiumVersion: APP_VERSION }, + frontends: [...MANIFEST.frontends, ...(mobile ? [{ side: 'mobile', kind: 'expo' }] : [])], + publicAccess: [{ table: 'menu_items', methods: ['GET'], select }], + }); + const keysOf = async () => (await publicKeysRepo(h.meta).list()).filter((k) => k.managedBy === 'pos').sort((a, b) => a.kind.localeCompare(b.kind)); + + await stageManifest(h, phone('1.0.0', true)); + const installed = await post(h, '/apps/install'); + expect(installed.statusCode, installed.body).toBe(200); + const [appKey, browserKey] = await keysOf(); + // The reply's key is the customer's: the one a page is served. + expect(installed.json().publicAccess.keyId).toBe(browserKey!.id); + expect(browserKey).toMatchObject({ kind: 'browser', purpose: 'customer', name: 'Point of Sale · guests' }); + expect(appKey).toMatchObject({ kind: 'app', purpose: 'customer', name: 'Point of Sale · phone app', appKey: 'pos', side: 'customer', revokedAt: null }); + expect(appKey!.prefix.startsWith('adm_app_')).toBe(true); + // Re-readable, as a key that ships in a bundle must be. + expect(appKey!.tokenEncrypted).not.toBe(''); + expect(JSON.parse(String(appKey!.origins))).toEqual([]); + const access = (key: { access: unknown }) => (typeof key.access === 'string' ? JSON.parse(key.access) : key.access) as Record; + expect(access(appKey!)).toEqual(access(browserKey!)); + // A page is never served the app key. + expect((await publicKeysRepo(h.meta).newestLiveByApp('pos', 'customer'))?.id).toBe(browserKey!.id); + + // An update keeps both, and makes neither again. + await stageManifest(h, phone('1.1.0', true)); + expect((await h.app.inject({ method: 'POST', url: '/apps/pos/update' })).statusCode).toBe(200); + expect((await keysOf()).map((k) => [k.id, k.revokedAt])).toEqual([ + [appKey!.id, null], + [browserKey!.id, null], + ]); + + // The version drops the phone side: its key goes, the customer's stays. + await stageManifest(h, phone('1.2.0', false)); + expect((await h.app.inject({ method: 'POST', url: '/apps/pos/update' })).statusCode).toBe(200); + const after = await keysOf(); + expect(after.find((k) => k.id === appKey!.id)!.revokedAt).not.toBeNull(); + expect(after.find((k) => k.id === browserKey!.id)!.revokedAt).toBeNull(); + + // Back again: a key the app once had and lost is not made again by an update (the operator's own revoke reads the same). + await stageManifest(h, phone('1.3.0', true)); + expect((await h.app.inject({ method: 'POST', url: '/apps/pos/update' })).statusCode).toBe(200); + expect((await keysOf()).filter((k) => k.kind === 'app' && k.revokedAt === null)).toEqual([]); + + // Uninstalled: every key of the app is revoked. + expect((await h.app.inject({ method: 'DELETE', url: '/apps/pos' })).statusCode).toBe(200); + expect((await keysOf()).every((k) => k.revokedAt !== null)).toBe(true); + }, 60_000); + it('holds the app’s own key to the safe list, and an update may not widen it', async () => { const h = (open = await harness(dialect)); const withAccess = (version: string, select: string[]) => ({ From f387841d8ccaa5e08241f705849872c16b06513a Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sun, 11 Oct 2026 04:00:42 +0200 Subject: [PATCH 04/20] fix(public-api): the app key's review: links, the update check, kinds --- .../src/studio/api-keys/ApiKeysPage.tsx | 6 ++-- .../src/studio/api-keys/apiKeysApi.ts | 3 +- .../studio/apps/PublicAccessInstallCard.tsx | 11 ++++++ .../src/studio/apps/appAcquisition.tsx | 4 ++- apps/dashboard/src/studio/apps/appsApi.ts | 2 ++ apps/server/src/apps/install-service.ts | 31 +++++++++++----- apps/server/src/apps/manifest-public.ts | 4 +-- apps/server/src/plugins/surfaces.ts | 4 +-- .../server/src/public-api/endpoint-service.ts | 4 +-- apps/server/src/public-api/keys.ts | 13 +++++++ .../src/public-api/sign-in-link-minter.ts | 4 ++- apps/server/src/routes/apps/schema.ts | 8 +++++ apps/server/src/routes/public/index.ts | 9 ++--- apps/server/test/app-install-pipeline.test.ts | 30 +++++++++++++--- .../test/public-api-server-keys.test.ts | 2 +- .../test/public-sign-in-link-minter.test.ts | 35 +++++++++++++++++++ packages/meta/src/repos/public-api.ts | 16 +++++++++ 17 files changed, 157 insertions(+), 29 deletions(-) diff --git a/apps/dashboard/src/studio/api-keys/ApiKeysPage.tsx b/apps/dashboard/src/studio/api-keys/ApiKeysPage.tsx index 3f4053b54..d45d3da3a 100644 --- a/apps/dashboard/src/studio/api-keys/ApiKeysPage.tsx +++ b/apps/dashboard/src/studio/api-keys/ApiKeysPage.tsx @@ -323,7 +323,9 @@ export function ApiKeysPage() { > {key.kind === 'server' ? t('studio:apiKeys.keys.kind.server', 'SERVER') - : t('studio:apiKeys.keys.kind.browser', 'BROWSER')} + : key.kind === 'app' + ? t('studio:apiKeys.keys.kind.app', 'PHONE APP') + : t('studio:apiKeys.keys.kind.browser', 'BROWSER')} {key.name} {key.requiresStaff != null ? ( @@ -349,7 +351,7 @@ export function ApiKeysPage() { {shown ?? maskKey(key.prefix)} - {key.kind === 'browser' ? ( + {key.kind !== 'server' ? (