From 4f5df1215c45cb4ddf4ed35097fbd4c437419d94 Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 07:13:49 +0000 Subject: [PATCH 01/13] feat(library-server): a public library's /v1/files redirects to PUBLIC_URL, kept a week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On S3 a public library answered /v1/files/{key} with a 302 to a 5-minute pre-signed URL on the bucket's raw endpoint (private, max-age=240), so apps linking one file by key paid an extra round trip every few minutes and fetched uncached from R2. It now sends them to the file under PUBLIC_URL, where /v1/stores already names it and a CDN keeps it: public, max-age=604800 for the standard path, 300 s for a file adopted in place. A private library still pre-signs; so does a public one whose PUBLIC_URL is its own /files/. Withdrawn keys stay 410. Spec 1.2 registry API §7 says it is allowed. Co-Authored-By: Claude Opus 5.5 --- packages/library-server/src/adopt.test.ts | 40 +++++++++++++++++++++-- packages/library-server/src/delivery.ts | 25 +++++++++++++- spec/1.2/registry-api.md | 5 +++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/library-server/src/adopt.test.ts b/packages/library-server/src/adopt.test.ts index 71c4c10..7743ed6 100644 --- a/packages/library-server/src/adopt.test.ts +++ b/packages/library-server/src/adopt.test.ts @@ -509,10 +509,11 @@ describe('adopting on S3: a public library behind a CDN, and a private one', () expect(seen).toEqual([`${CDN}/${standardPath(k.key)}`, `${LIB}/index/${k.key.slice(3, 5)}.json`, `${CDN}/${PATH}`]); }); - it('/v1/files and the link pre-sign the adopted path', async () => { + it('/v1/files sends a public library\'s reader to the adopted path at the CDN (kept 5 minutes); the link pre-signs it', async () => { const r = await fetch(`${LIB}/v1/files/${k.key}`, { redirect: 'manual' }); expect(r.status).toBe(302); - expect(new URL(r.headers.get('location')!).pathname).toBe(`/books/${PATH}`); + expect(r.headers.get('location')).toBe(`${CDN}/${PATH}`); + expect(r.headers.get('cache-control')).toBe('public, max-age=300'); expect(text(await (await fetch(`${LIB}/v1/files/${k.key}`)).arrayBuffer())).toBe(text(IMG)); const { url } = (await (await fetch(`${LIB}/v1/assets/${k.key}/link`)).json()) as { url: string }; expect(text(await (await fetch(url)).arrayBuffer())).toBe(text(IMG)); @@ -536,7 +537,7 @@ describe('adopting on S3: a public library behind a CDN, and a private one', () vi.setSystemTime(Date.now() + 2000); objects.set(B, bytes); types.set(B, 'image/png'); await createRegistryClient(LIB, { token: 't-1' }).adopt({ ...s, type: 'image/png', size: bytes.byteLength, path: B }); - const at = async () => new URL((await fetch(`${LIB}/v1/files/${s.key}`, { redirect: 'manual' })).headers.get('location')!).pathname; + const at = async () => new URL((await fetch(`${PRIV}/v1/files/${s.key}`, { headers: bearer(), redirect: 'manual' })).headers.get('location')!).pathname; const entry = async () => ((await (await fetch(`${LIB}/index/${s.key.slice(3, 5)}.json`)).json()) as { entries: Record }).entries[s.key.slice(3)]!.p; // B is pruned first this time; within the check interval nothing is HEADed and B is still named objects.delete(B); @@ -564,6 +565,39 @@ describe('adopting on S3: a public library behind a CDN, and a private one', () expect(await book.urlFor(want.assets[3]!.id)).toBe(`${CDN}/chalk-books/s3-book/assets/${want.assets[3]!.id.replace(':', '-')}`); }, 60_000); + it('an uploaded key: a public library redirects /v1/files to the CDN\'s standard path, kept a week; a private one pre-signs the bucket', async () => { + const bytes = new TextEncoder().encode('uploaded, then read through the CDN'); + const rec = await createRegistryClient(LIB, { token: 't-1' }).add(bytes, { type: 'image/png', title: 'cdn' }); + const r = await fetch(`${LIB}/v1/files/${rec.key.slice(3)}`, { redirect: 'manual' }); + expect(r.status).toBe(302); + expect(r.headers.get('location')).toBe(`${CDN}/${standardPath(rec.key)}`); + expect(r.headers.get('cache-control')).toBe('public, max-age=604800'); + expect(r.headers.get('vary') ?? '').not.toMatch(/authorization/i); + log.length = 0; + expect(text(await (await fetch(`${LIB}/v1/files/${rec.key}`)).arrayBuffer())).toBe(text(bytes)); + expect(log).toEqual([]); // the bytes came from the CDN, the bucket's own endpoint was not asked + // a private library never names the public domain: a pre-signed URL of the bucket, kept less than it lasts + const p = await fetch(`${PRIV}/v1/files/${rec.key}`, { headers: bearer(), redirect: 'manual' }); + expect(new URL(p.headers.get('location')!).origin).toBe(S3); + expect(p.headers.get('cache-control')).toBe('private, max-age=240'); + expect((await fetch(`${PRIV}/v1/files/${rec.key}`, { redirect: 'manual' })).status).toBe(401); + // withdrawn: 410, not a redirect + expect((await fetch(`${LIB}/v1/assets/${rec.key}/withdraw`, { method: 'POST', headers: { ...json, ...bearer('a-1') }, body: '{}' })).status).toBe(200); + const gone = await fetch(`${LIB}/v1/files/${rec.key}`, { redirect: 'manual' }); + expect([gone.status, gone.headers.get('location')]).toEqual([410, null]); + }); + + it('a public S3 library without a public domain (PUBLIC_URL left at its own /files/) still pre-signs', async () => { + const own = createLibraryApp({ db, storage: s3Storage({ endpoint: S3, bucket: 'books', ...CREDS }), publicUrl: 'https://lib.example.org/files/', serverUrl: 'https://lib.example.org', callers: tokenCallers('teacher:t-1') } as Parameters[0]); + const bytes = new TextEncoder().encode('no public domain'), k2 = await assetKeyAndSha256(bytes), path = standardPath(k2.key); + objects.set(path, bytes); + await db.query("INSERT INTO assets (key, sha256, type, size, status) VALUES ($1, $2, 'image/png', $3, 'available')", [k2.key, k2.sha256, bytes.byteLength]); + const r = await own.fetch(new Request(`https://lib.example.org/v1/files/${k2.key}`)); + expect(r.status).toBe(302); + expect(new URL(r.headers.get('location')!).origin).toBe(S3); + expect(r.headers.get('cache-control')).toBe('private, max-age=240'); + }); + it('withdrawing deletes only the standard path, never the adopted object', async () => { log.length = 0; const w = await fetch(`${LIB}/v1/assets/${k.key}/withdraw`, { method: 'POST', headers: { ...json, ...bearer('a-1') }, body: '{}' }); diff --git a/packages/library-server/src/delivery.ts b/packages/library-server/src/delivery.ts index 12fc101..da77cab 100644 --- a/packages/library-server/src/delivery.ts +++ b/packages/library-server/src/delivery.ts @@ -2,6 +2,7 @@ import { createReadStream, statSync } from 'node:fs'; import { Readable } from 'node:stream'; import type { Context, Hono } from 'hono'; import { isC1Key, type StoreDescriptor } from '@chalk-format/model'; +import { standardPath } from '@chalk-format/store'; import { assetAt, locate } from './adopt.js'; import type { LibraryOptions } from './app.js'; import { requireRole, type AuthVariables } from './auth/guard.js'; @@ -17,7 +18,10 @@ import { contentDisposition, servedType, type DiskStorage } from './storage.js'; * GET /files/* (disk) only signed links: 403 without a valid one, 410 for a withdrawn key * * With PUBLIC_READ=true (the default) `/v1/stores` and `/files/*` are left to app.ts as they were; the two new - * routes work too, open to anyone like the rest of a public library's reading. + * routes work too, open to anyone like the rest of a public library's reading. On S3, `/v1/files/{key}` then + * redirects to the file under PUBLIC_URL (the bucket's public domain, behind a CDN), where the store already + * names it, instead of pre-signing the bucket's own endpoint: the bytes come from the CDN's cache, and the + * redirect is kept (a week; 5 minutes for a file adopted in place). * * Code is never served as a page: HTML and JavaScript go out as `application/octet-stream` (S3: fixed in the * signature with `response-content-type`), and the server's own answers add `Content-Security-Policy: sandbox` @@ -31,6 +35,13 @@ export type DeliveryOptions = { /** A pre-signed URL behind `GET /v1/files/{key}`, and how long the redirect to it may be kept (less, so it is never stale). */ export const FILE_SECONDS = 5 * 60; export const FILE_REDIRECT_CACHE = 'private, max-age=240'; +/** + * A public library's redirect to PUBLIC_URL. A key's bytes never change, so the redirect to the standard path is kept a week by anyone (a + * withdrawn key's URL then 404s once its object is deleted and the CDN purged). A file adopted in place may move to another copy within + * minutes (adopt.ts), so that redirect is kept 5 minutes. + */ +export const PUBLIC_REDIRECT_CACHE = 'public, max-age=604800'; +export const ADOPTED_REDIRECT_CACHE = 'public, max-age=300'; /** A link for places that cannot send a header (the contract allows 5 to 15 minutes; a video plays from it). */ export const LINK_SECONDS = 15 * 60; @@ -57,6 +68,12 @@ export function registerDelivery(app: Hono<{ Variables: AuthVariables }>, o: Lib return { key, path: await locate(o.db, o.storage, key), type: row.type }; // where the bytes are: the standard path, or where they were adopted }; const base = (c: Context) => baseOf(c, o.serverUrl); + /** + * Where a public S3 library sends `/v1/files` readers: PUBLIC_URL. Not when PUBLIC_URL is this server's own `/files/` (the default when + * it is not set), which only a disk library serves; that library answers with the bytes itself. + */ + const ownFiles = o.serverUrl && o.publicUrl.startsWith(`${o.serverUrl.replace(/\/+$/, '')}/files/`); + const cdn = publicRead && !disk && !ownFiles ? o.publicUrl.replace(/\/?$/, '/') : null; // ---- the store readers use: a private library's names this server, with a token ---- app.get('/v1/stores', async (c, next) => { @@ -74,6 +91,12 @@ export function registerDelivery(app: Hono<{ Variables: AuthVariables }>, o: Lib const file = disk.file(a.path); return file ? serveFile(c, file, a.type, { 'Cache-Control': 'private, max-age=31536000, immutable', Vary: BY_TOKEN }) : fail(c, 404, 'not_found', 'no such asset'); } + // a public library: its bytes are already public at PUBLIC_URL (the store /v1/stores names), so send the reader there, where a CDN + // keeps them, and let the redirect be kept too. A private library never does (its bucket is not public; PUBLIC_URL may still answer) + if (cdn) { + const std = a.path === standardPath(a.key); + return c.body(null, 302, { Location: `${cdn}${a.path.split('/').map(encodeURIComponent).join('/')}`, 'Cache-Control': std ? PUBLIC_REDIRECT_CACHE : ADOPTED_REDIRECT_CACHE }); + } // the bearer header is not sent on to the bucket: fetch drops Authorization on a redirect to another origin const { url } = await o.storage.presignGet(a.path, FILE_SECONDS, { contentType: a.type }); return c.body(null, 302, { Location: url, 'Cache-Control': FILE_REDIRECT_CACHE, Vary: BY_TOKEN, 'Referrer-Policy': 'no-referrer' }); diff --git a/spec/1.2/registry-api.md b/spec/1.2/registry-api.md index c9e2912..d9968c7 100644 --- a/spec/1.2/registry-api.md +++ b/spec/1.2/registry-api.md @@ -170,6 +170,11 @@ pinned. It MUST NOT delete an asset any book uses, nor a current book item (spec this registry's assets, in order. Apps fetch this when they start and pass it to their reader as its host stores. It is public and cacheable. +A registry whose bytes are public MAY also answer **`GET v1/files/{key}`** without a token (for apps that link one +file by key, such as an ``), with a `302` to the file in the store it names here, which the redirect may let +any cache keep (the reference: `public, max-age=604800`, as a key's bytes never change). It MUST NOT redirect a +private registry's readers (§7.1) to a public address. + ### 7.1 A private library's files A registry whose bytes are not public (its bucket is private) serves them only to callers with a token, and From 2e08d2790e13c7bc1ede9dee8f16756bde68aad8 Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 07:13:49 +0000 Subject: [PATCH 02/13] docs(library-server): R2 custom domain behind Cloudflare's cache Co-Authored-By: Claude Opus 5.5 --- docs/guides/run-a-library-server.md | 5 +++- packages/library-server/README.md | 46 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/guides/run-a-library-server.md b/docs/guides/run-a-library-server.md index 1d415c4..14b72fd 100644 --- a/docs/guides/run-a-library-server.md +++ b/docs/guides/run-a-library-server.md @@ -103,7 +103,10 @@ only then makes it available. What the bucket needs: 1. **Public read at `PUBLIC_URL`.** - - **R2:** a custom domain on the bucket. Cloudflare's cache then serves it. + - **R2:** a custom domain on the bucket, plus a Cache Rule for its `c1/` paths (R2 sends no `Cache-Control`, so + Cloudflare does not keep the files without one): the exact settings are in the + [package README](../../packages/library-server/README.md#files-from-the-edge-an-r2-custom-domain-behind-cloudflares-cache). + A public library's `GET /v1/files/{key}` then redirects there too, instead of pre-signing the bucket. - **AWS:** a bucket policy or CloudFront. - **MinIO:** an anonymous download policy. - Set `PUBLIC_URL` to that address, with a trailing `/`. diff --git a/packages/library-server/README.md b/packages/library-server/README.md index 75eef8d..19e2403 100644 --- a/packages/library-server/README.md +++ b/packages/library-server/README.md @@ -28,6 +28,52 @@ them, as records an editor and a matcher change one by one (`v1/books//lin them from the library. They are migration step 5 of its database (two tables, `book_lines` and `book_links`), applied when the server starts. +## Files from the edge: an R2 custom domain behind Cloudflare's cache + +A public library (`PUBLIC_READ` unset or `true`) names its bucket's public address in `GET /v1/stores` +(`PUBLIC_URL` + `c1/{h0}/{h1}/{h}`), and readers fetch files there. On S3 storage, `GET /v1/files/{key}` (the address +apps use for a single file: an `` poster, a lab's code) answers `302` to the same file under `PUBLIC_URL`, +kept `public, max-age=604800` (a key's bytes never change; a file adopted in place, which may move to another +copy, `public, max-age=300`); `410` for a withdrawn key, `404` for an unknown one. A private library +(`PUBLIC_READ=false`) never sends anyone to `PUBLIC_URL`: it pre-signs the bucket (5 minutes, +`private, max-age=240`). Nor does a public one whose `PUBLIC_URL` is left at its own `/files/` (only a disk +library serves that). Those settings for the reference deployment: `STORAGE=s3`, +`PUBLIC_URL=https://assets.pathshalax.com/` (trailing slash), `SERVER_URL=https://library.pathshalax.com`. + +For the bytes to come from Cloudflare's edge rather than from the bucket each time, on Cloudflare (R2 objects carry +no `Cache-Control`, so without a rule a custom domain answers `cf-cache-status: DYNAMIC`): + +1. **Public access through the custom domain only.** R2 → the bucket → Settings → *Public access* → *Custom + domains* → connect `assets.example.org` (a domain in the same Cloudflare account; it is proxied by itself). + *R2.dev subdomain*: *Disallowed* (it is rate-limited and never cached). +2. **Cache Rule** (the zone of that domain → Caching → Cache Rules → Create rule → *Edit expression*): + ``` + (http.host eq "assets.example.org" and starts_with(http.request.uri.path, "/c1/")) + ``` + *Eligible for cache*; Edge TTL *Ignore cache-control header and use this TTL* → 1 year, with a status code TTL + `404` → *No cache* (a key asked for before its upload finished, or adopted elsewhere, must not stay missing; + without status code TTLs on the plan, use 1 day); Browser TTL *Override origin* → 1 year; Cache key + *Ignore query string*. Only `c1/` is matched: files adopted in place (`chalk-books/…`) can be replaced by the + tool that wrote them, so they are left to Cloudflare's defaults. +3. **CORS** (the bucket → Settings → CORS): readers fetch from the browser, sometimes with `Range`. + ```json + [{ "AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["Range"], + "ExposeHeaders": ["Content-Length", "Content-Range", "ETag"], "MaxAgeSeconds": 86400 }] + ``` + Keep `*` rather than a list of origins: with a list the bucket answers per origin (`Vary: Origin`), which + Cloudflare's cache ignores, so one site's answer would be served to another. Authors' uploads go to the bucket's + S3 endpoint (pre-signed `PUT`), not through this domain; their CORS rule is the one in the guide below. +4. **Withdrawing** a key deletes its object, but the edge keeps a copy: purge it too (Caching → Configuration → + *Custom purge* → URL `https://assets.example.org/c1//

/`). + +Check, with a key that is there: + +```bash +curl -sI /v1/files/c1: | grep -i '^location\|^cache-control' # PUBLIC_URL/c1//

/, public, max-age=604800 +curl -sI https://assets.example.org/c1//

/ | grep -i '^cf-cache-status' # MISS, then: +curl -sI https://assets.example.org/c1//

/ | grep -i '^cf-cache-status' # HIT +``` + Every setting, Postgres and S3 setup, bucket CORS, deploying and security: [docs/guides/run-a-library-server.md](../../docs/guides/run-a-library-server.md). Using libraries in the reader and the editor: [docs/guides/media-libraries.md](../../docs/guides/media-libraries.md). From 0ad59cc0f77615356187fabbedaf408ac3bd0345 Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 07:20:27 +0000 Subject: [PATCH 03/13] feat(library-server): API keys act for people (Chalk-Actor); HUMAN_ACCOUNTS=off Staff of a deployment can sign in to their own app instead of the library: the app's server calls with one key and says who it acts for, and the library's records name that person. - auth_api_keys.act_for (migration step 7, additive) and an actors table (id, name, first_seen_at, last_seen_at); actFor on key records, settable on create (POST v1/admin/keys, key add --act-for) and by PATCH v1/admin/keys/{id} (key act-for on|off). - Chalk-Actor: base64url JSON {id, name}. From an act-for key the caller's who-value becomes the id, so uploads, adopted files, highlight reviews and drawn boxes (the body's `by` is replaced) and ?mine=1 all mean the person; actors are upserted at most once a minute per id and process. Any other caller gets 403 act_for_not_allowed, a malformed header 400. CORS allows it. - GET v1/auth/me answers actFor and actingFor; GET v1/admin/actors lists actors. - HUMAN_ACCOUNTS=off: password and refresh grants are unsupported_grant_type (and not in discovery), user admin endpoints 404, users' tokens refused, the user CLI commands refuse; keys and TOKENS keep working. Co-Authored-By: Claude Opus 5.5 --- packages/library-server/src/app.ts | 5 +- .../library-server/src/auth/act-for.test.ts | 185 ++++++++++++++++++ packages/library-server/src/auth/cli.ts | 17 +- packages/library-server/src/auth/guard.ts | 43 +++- packages/library-server/src/auth/roles.ts | 12 ++ packages/library-server/src/auth/routes.ts | 34 +++- packages/library-server/src/auth/service.ts | 53 +++-- packages/library-server/src/config.ts | 14 +- packages/library-server/src/db.ts | 10 + packages/library-server/src/highlights.ts | 7 +- 10 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 packages/library-server/src/auth/act-for.test.ts diff --git a/packages/library-server/src/app.ts b/packages/library-server/src/app.ts index e0ef0ab..7f83b5d 100644 --- a/packages/library-server/src/app.ts +++ b/packages/library-server/src/app.ts @@ -72,9 +72,10 @@ export function createLibraryApp(o: LibraryOptions) { const fail = (c: Context, status: number, code: string, message: string) => c.json({ error: { code, message } }, status as never); // Range: a reader with a token asks /v1/files for part of a file (a suffix range is not a CORS-safelisted header) - app.use('/v1/*', cors({ origin: o.origins ?? '*', allowHeaders: ['Authorization', 'Content-Type', 'Range'], exposeHeaders: ['Content-Length', 'Content-Range'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], maxAge: 86400 })); + app.use('/v1/*', cors({ origin: o.origins ?? '*', allowHeaders: ['Authorization', 'Content-Type', 'Range', 'Chalk-Actor'], exposeHeaders: ['Content-Length', 'Content-Range'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], maxAge: 86400 })); // every route asks requireRole(c, 'reader' | 'author' | 'admin'); the caller is a bootstrap token, an API key or a user - app.use('/v1/*', authenticate({ publicRead: o.publicRead !== false, resolve: async (token, c) => (await o.callers(token)) ?? auth.resolve(token, issuer(c)) })); + // an API key that acts for people sends Chalk-Actor (registry-api §10.7): every record below then names that person + app.use('/v1/*', authenticate({ publicRead: o.publicRead !== false, resolve: async (token, c) => (await o.callers(token)) ?? auth.resolve(token, issuer(c)), onActor: auth.noteActor })); mountAuthRoutes(app, auth, { issuer, ...o.auth }); const body = async (c: Context, schema: { safeParse(v: unknown): { success: true; data: T } | { success: false; error: { issues: { message: string; path: PropertyKey[] }[] } } }) => { const r = schema.safeParse(await c.req.json().catch(() => null)); diff --git a/packages/library-server/src/auth/act-for.test.ts b/packages/library-server/src/auth/act-for.test.ts new file mode 100644 index 0000000..5d3e136 --- /dev/null +++ b/packages/library-server/src/auth/act-for.test.ts @@ -0,0 +1,185 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { c1FromSha256 } from '@chalk-format/model'; +import { checkKeySetup, createLibraryApp, diskStorage, libraryFromEnv, openDb, tokenCallers, type Db } from '../index.js'; + +// a library whose dashboard key acts for the signed-in person (registry-api §10.7), and the same library with +// HUMAN_ACCOUNTS=off; a bootstrap administrator (a-1) and author (t-1) +const BASE = 'http://library.test'; +const SECRET = 'a-test-secret-that-is-long-enough-0123456789'; +const BOOK = 'physics-9-10-bn'; +const Q1 = '11111111-1111-4111-8111-111111111111'; +const RITA = { id: 'staff:5b0c3a52-6d2e-4f7e-9a51-0c1d2e3f4a5b', name: 'রিতা Rita' }; +let db: Db, dir: string, app: ReturnType; +const make = (humanAccounts = true) => createLibraryApp({ + db, storage: diskStorage(dir, { uploadBase: BASE }), publicUrl: `${BASE}/files/`, callers: tokenCallers('ci:t-1,root!:a-1'), + publicRead: true, issuer: BASE, auth: { rateLimit: { perUser: 1000, perAddress: 1000 }, keySecret: SECRET, humanAccounts }, +}); +const fetchApp = (async (input: RequestInfo | URL, init?: RequestInit) => app.fetch(new Request(input, init))) as typeof fetch; +const actor = (a: unknown) => Buffer.from(typeof a === 'string' ? a : JSON.stringify(a)).toString('base64url'); +const call = async (path: string, o: { method?: string; token?: string; body?: unknown; actor?: string } = {}) => { + const res = await fetchApp(`${BASE}${path}`, { + method: o.method ?? (o.body !== undefined ? 'POST' : 'GET'), + headers: { ...(o.token ? { Authorization: `Bearer ${o.token}` } : {}), ...(o.body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(o.actor !== undefined ? { 'Chalk-Actor': o.actor } : {}) }, + ...(o.body !== undefined ? { body: JSON.stringify(o.body) } : {}), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers of many shapes, checked by the assertions + return { status: res.status, body: (await res.json().catch(() => null)) as any }; +}; +/** Uploads and registers bytes as `token` (acting for `as`): the asset record. */ +const upload = async (text: string, token: string, as?: string) => { + const bytes = new TextEncoder().encode(text), sha256 = createHash('sha256').update(bytes).digest('hex'), key = c1FromSha256(`sha256:${sha256}`); + const r = await call('/v1/uploads', { token, ...(as ? { actor: as } : {}), body: { key, sha256, type: 'image/png', size: bytes.byteLength } }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + await fetchApp(r.body.upload.url, { method: 'PUT', headers: r.body.upload.headers ?? {}, body: bytes }); + const reg = await call('/v1/assets', { token, ...(as ? { actor: as } : {}), body: { key, sha256, type: 'image/png', size: bytes.byteLength, title: text } }); + expect(reg.status, JSON.stringify(reg.body)).toBe(200); + return reg.body as { key: string; uploadedBy: string }; +}; +let dash: { id: string; key: string }, plain: { id: string; key: string }; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'chalk-library-actfor-')); + const pg = process.env.CHALK_LIBRARY_TEST_DB; + db = await openDb(pg ?? 'pglite:', pg ? { schema: `actfor_test_${Date.now().toString(36)}` } : {}); + app = make(); + const k1 = await call('/v1/admin/keys', { token: 'a-1', body: { name: 'pathshalax-dashboard', roles: ['author'], actFor: true } }); + expect(k1.status).toBe(201); + dash = k1.body; + plain = (await call('/v1/admin/keys', { token: 'a-1', body: { name: 'library-sync', roles: ['author'] } })).body; +}, 60_000); +afterAll(async () => { await db?.close(); rmSync(dir, { recursive: true, force: true }); }); + +describe('an API key acting for a person (Chalk-Actor)', () => { + it('keys say whether they act for people; an administrator turns it on and off', async () => { + expect(dash).toMatchObject({ name: 'pathshalax-dashboard', actFor: true }); + expect(plain).toMatchObject({ actFor: false }); + const list = (await call('/v1/admin/keys', { token: 'a-1' })).body.items as { id: string; actFor: boolean }[]; + expect(list.find((k) => k.id === dash.id)?.actFor).toBe(true); + expect((await call(`/v1/admin/keys/${plain.id}`, { method: 'PATCH', token: 'a-1', body: { actFor: true } })).body).toMatchObject({ id: plain.id, actFor: true }); + expect((await call('/v1/auth/me', { token: plain.key, actor: actor({ id: 'system:test', name: 'test' }) })).status).toBe(200); + expect((await call(`/v1/admin/keys/${plain.id}`, { method: 'PATCH', token: 'a-1', body: { actFor: false } })).body).toMatchObject({ actFor: false }); + expect((await call(`/v1/admin/keys/${plain.id}`, { method: 'PATCH', token: 't-1', body: { actFor: true } })).status).toBe(403); + expect((await call('/v1/admin/keys/no-such-key', { method: 'PATCH', token: 'a-1', body: { actFor: true } })).status).toBe(404); + expect((await call(`/v1/admin/keys/${plain.id}`, { method: 'PATCH', token: 'a-1', body: { roles: ['admin'] } })).status).toBe(400); + }); + + it('an upload records the person\'s id, not the key\'s name; ?mine=1 is that person\'s', async () => { + const a = await upload('rita made this', dash.key, actor(RITA)); + expect(a.uploadedBy).toBe(RITA.id); + const b = await upload('the dashboard itself', dash.key); + expect(b.uploadedBy).toBe('pathshalax-dashboard'); + const mine = async (as?: string) => ((await call('/v1/search?mine=1', { token: dash.key, ...(as ? { actor: as } : {}) })).body.items as { key: string }[]).map((x) => x.key); + expect(await mine(actor(RITA))).toEqual([a.key]); + expect(await mine()).toEqual([b.key]); + expect(await mine(actor({ id: 'staff:someone-else', name: 'Someone' }))).toEqual([]); + }); + + it('highlight changes record the person: links, reviews, drawn boxes (the body\'s `by` is replaced, or may be left out)', async () => { + const line = (idx: number) => ({ key: `p6/${idx}/0`, pageId: 'p6', pageNo: 6, idx, block: 0, kind: 'body', text: `line ${idx}`, textHash: `h${idx}`, rects: [{ x: 100, y: 100 + idx * 30, w: 500, fs: 18 }] }); + expect((await call(`/v1/books/${BOOK}/lines/sync`, { token: dash.key, body: { lines: [line(0), line(1)] } })).status).toBe(200); + const ids = Object.fromEntries(((await call(`/v1/books/${BOOK}/lines/all`)).body.items as { id: string; key: string }[]).map((l) => [l.key, l.id])); + const as = actor(RITA); + const link = await call(`/v1/books/${BOOK}/links`, { token: dash.key, actor: as, body: { questionId: Q1, lineId: ids['p6/0/0'], by: 'someone-else' } }); + expect(link.status, JSON.stringify(link.body)).toBe(201); + expect(link.body).toMatchObject({ reviewedBy: RITA.id }); + const moved = await call(`/v1/books/${BOOK}/links/${link.body.id}`, { method: 'PATCH', token: dash.key, actor: actor({ id: 'staff:arun-1', name: 'Arun' }), body: { lineId: ids['p6/1/0'] } }); + expect(moved.status, JSON.stringify(moved.body)).toBe(200); + expect(moved.body).toMatchObject({ reviewedBy: 'staff:arun-1' }); + const drawn = await call(`/v1/books/${BOOK}/lines/${ids['p6/0/0']}/rects`, { method: 'PUT', token: dash.key, actor: as, body: { rects: [{ x: 140, y: 100, w: 380, fs: 18 }] } }); + expect(drawn.body.line).toMatchObject({ rectsManual: true, rectsBy: RITA.id }); + // without the header the body's `by` is kept, as before + const plainLink = await call(`/v1/books/${BOOK}/links`, { token: dash.key, body: { questionId: Q1, lineId: ids['p6/0/0'], by: 'library-sync' } }); + expect(plainLink.body).toMatchObject({ reviewedBy: 'library-sync' }); + // and is still required then + expect((await call(`/v1/books/${BOOK}/links`, { token: dash.key, body: { questionId: Q1, lineId: ids['p6/0/0'] } })).status).toBe(400); + }); + + it('records each person once in actors, with the latest name; listed for administrators', async () => { + expect((await call('/v1/auth/me', { token: dash.key, actor: actor({ ...RITA, name: 'Rita Akter' }) })).status).toBe(200); + const list = await call('/v1/admin/actors', { token: 'a-1' }); + expect(list.status).toBe(200); + const items = list.body.items as { id: string; name: string; firstSeenAt: string; lastSeenAt: string }[]; + expect(items.filter((a) => a.id === RITA.id)).toEqual([expect.objectContaining({ id: RITA.id, name: 'Rita Akter' })]); + expect(items.map((a) => a.id)).toContain('staff:arun-1'); + expect((await call('/v1/admin/actors', { token: 't-1' })).status).toBe(403); + }); + + it('who am I: the key, and the person it acts for', async () => { + const me = await call('/v1/auth/me', { token: dash.key, actor: actor(RITA) }); + expect(me.body).toMatchObject({ sub: dash.id, name: 'pathshalax-dashboard', kind: 'api-key', actFor: true, actingFor: RITA }); + expect((await call('/v1/auth/me', { token: dash.key })).body).not.toHaveProperty('actingFor'); + }); + + it('from any other caller the header is refused (403 act_for_not_allowed), never ignored', async () => { + for (const token of [plain.key, 't-1', 'a-1', undefined]) { + const r = await call('/v1/search', { ...(token ? { token } : {}), actor: actor(RITA) }); + expect(r.status, String(token)).toBe(403); + expect(r.body.error.code).toBe('act_for_not_allowed'); + } + // a token that is not valid stays a 401 (the client should sign in again), header or not + expect((await call('/v1/uploads', { token: 'clk_not-a-key', actor: actor(RITA), body: {} })).status).toBe(401); + }); + + it('a malformed header is 400 and records nothing', async () => { + const bad = ['', 'not base64!', actor('not json'), actor([RITA]), actor({ id: 'rita', name: 'Rita' }), actor({ id: 'staff:', name: 'Rita' }), actor({ id: `staff:${'x'.repeat(81)}`, name: 'Rita' }), + actor({ id: 'user:1', name: 'Rita' }), actor({ id: 'staff:a b', name: 'Rita' }), actor({ id: 'staff:1' }), actor({ id: 'staff:1', name: '' }), actor({ id: 'staff:1', name: 'x'.repeat(121) }), + actor({ id: 'staff:1', name: 'line\nbreak' }), actor({ id: 'staff:1', name: 7 })]; + for (const h of bad) { + const r = await call('/v1/uploads', { token: dash.key, actor: h, body: {} }); + expect(r.status, h).toBe(400); + expect(r.body.error.code).toBe('invalid'); + } + expect((await call('/v1/auth/me', { token: dash.key, actor: actor({ id: 'system:library-sync', name: 'x'.repeat(120) }) })).status).toBe(200); + expect((await db.query("SELECT 1 FROM actors WHERE id = 'staff:1'")).length).toBe(0); + }); + + it('browsers may send it (CORS)', async () => { + const res = await fetchApp(`${BASE}/v1/uploads`, { method: 'OPTIONS', headers: { Origin: 'https://dashboard.example.org', 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'chalk-actor' } }); + expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain('chalk-actor'); + }); +}); + +describe('HUMAN_ACCOUNTS=off', { timeout: 30_000 }, () => { + it('no sign-in, refresh or user administration; users\' tokens are refused; keys and bootstrap tokens work', async () => { + // a user signed in while accounts were on + app = make(true); + expect((await call('/v1/admin/users', { token: 'a-1', body: { username: 'rita', password: 'reader-password-1', roles: ['author'] } })).status).toBe(201); + const t = await call('/v1/auth/token', { body: { grant_type: 'password', username: 'rita', password: 'reader-password-1' } }); + expect(t.status).toBe(200); + expect((await call('/v1/auth/me', { token: t.body.access_token })).status).toBe(200); + + app = make(false); + const pw = await call('/v1/auth/token', { body: { grant_type: 'password', username: 'rita', password: 'reader-password-1' } }); + expect(pw).toMatchObject({ status: 400, body: { error: 'unsupported_grant_type' } }); + expect(await call('/v1/auth/token', { body: { grant_type: 'refresh_token', refresh_token: t.body.refresh_token } })).toMatchObject({ status: 400, body: { error: 'unsupported_grant_type' } }); + expect((await call('/v1/auth/me', { token: t.body.access_token })).status).toBe(401); + expect((await call('/v1/uploads', { token: t.body.access_token, body: {} })).status).toBe(401); + expect((await call('/v1/admin/users', { token: 'a-1' })).status).toBe(404); + expect((await call('/v1/admin/users', { token: 'a-1', body: { username: 'arun', password: 'author-password-1', roles: ['author'] } })).status).toBe(404); + expect((await call('/v1/admin/users/x', { method: 'PATCH', token: 'a-1', body: { disabled: true } })).status).toBe(404); + const conf = (await call('/.well-known/openid-configuration')).body as { grant_types_supported: string[] }; + expect(conf.grant_types_supported).toEqual([]); + // machines keep working, acting for people included + expect((await call('/v1/auth/me', { token: 't-1' })).body).toMatchObject({ kind: 'bootstrap' }); + expect((await call('/v1/admin/keys', { token: 'a-1' })).status).toBe(200); + expect((await upload('made while accounts are off', dash.key, actor(RITA))).uploadedBy).toBe(RITA.id); + app = make(true); + }); + + it('a library with users but HUMAN_ACCOUNTS=off starts without AUTH_KEY_SECRET (no user can sign in)', async () => { + const d = await openDb('pglite:'); + try { + await d.query("INSERT INTO auth_users (id, username, password_hash, roles) VALUES ('u1', 'rita', 'x', '{reader}')"); + await expect(checkKeySetup(d, { publicRead: true })).rejects.toThrow(/user accounts/); + await expect(checkKeySetup(d, { publicRead: true, humanAccounts: false })).resolves.toBeUndefined(); + } finally { await d.close(); } + await expect(libraryFromEnv({ DATABASE_URL: 'pglite:', HUMAN_ACCOUNTS: 'maybe' })).rejects.toThrow(/HUMAN_ACCOUNTS must be on or off/); + const o = await libraryFromEnv({ DATABASE_URL: 'pglite:', HUMAN_ACCOUNTS: 'off' }); + expect(o.auth?.humanAccounts).toBe(false); + await o.db.close(); + }, 60_000); +}); diff --git a/packages/library-server/src/auth/cli.ts b/packages/library-server/src/auth/cli.ts index eea4a33..9d8fe53 100644 --- a/packages/library-server/src/auth/cli.ts +++ b/packages/library-server/src/auth/cli.ts @@ -1,3 +1,4 @@ +import { onOff } from '../config.js'; import { openDb } from '../db.js'; import { parseRoles } from './roles.js'; import { AuthError, createAuth } from './service.js'; @@ -11,7 +12,8 @@ export const AUTH_USAGE = ` chalk-library user add --roles reader[,a chalk-library user list chalk-library user disable|enable chalk-library user password (password: prompt or stdin) - chalk-library key add --roles reader[,author,admin] (prints the key once) + chalk-library key add --roles reader[,author,admin] [--act-for] (prints the key once; --act-for: may send Chalk-Actor) + chalk-library key act-for on|off chalk-library key list chalk-library key revoke chalk-library signing-key rotate`; @@ -51,6 +53,9 @@ export async function runAuthCommand(args: string[], env: Record { const i = args.indexOf(n); return i > 0 ? args[i + 1] : undefined; }; if (!env.DATABASE_URL) { console.error('DATABASE_URL is not set (the library\'s database)'); return 2; } + let humans: boolean; + try { humans = onOff(env, 'HUMAN_ACCOUNTS', true); } catch (e) { console.error(e instanceof Error ? e.message : String(e)); return 2; } + if (group === 'user' && !humans) { console.error('HUMAN_ACCOUNTS=off: this library has no user accounts'); return 2; } const db = await openDb(env.DATABASE_URL, { ...(env.DB_SCHEMA ? { schema: env.DB_SCHEMA } : {}) }); // AUTH_KEY_SECRET: adding a user and rotating the signing key need it (as the server does) let auth: ReturnType; @@ -73,11 +78,15 @@ export async function runAuthCommand(args: string[], env: Record Promise }): MiddlewareHandler { +/** `Chalk-Actor`'s id: a person (`staff:`) or a job (`system:`) of the deployment (registry-api §10.7). */ +export const ACTOR_ID = /^(staff|system):[A-Za-z0-9._:-]{1,80}$/; + +/** `Chalk-Actor: base64url(JSON {"id", "name"})` → the actor, or null when it is not one. */ +export function parseActor(header: string): Actor | null { + const h = header.trim(); + if (!h || h.length > 2048 || !/^[A-Za-z0-9_-]+={0,2}$/.test(h)) return null; + let v: unknown; + try { v = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); } catch { return null; } + if (!v || typeof v !== 'object' || Array.isArray(v)) return null; + const { id, name } = v as { id?: unknown; name?: unknown }; + if (typeof id !== 'string' || !ACTOR_ID.test(id) || typeof name !== 'string') return null; + const n = [...name].length; + // a display name: 1 to 120 characters, no control characters + if (n < 1 || n > 120 || !name.trim() || [...name].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f)) return null; + return { id, name }; +} + +/** + * Reads `Authorization: Bearer …` once per request and puts the caller (or null) on it. + * + * `Chalk-Actor` (registry-api §10.7): from a key that may act for people, the caller becomes that person for every + * record (`user` = the actor's id) and `onActor` notes them; from any other caller it is refused (403 + * `act_for_not_allowed`), so a client set up wrong is noticed instead of writing under the wrong name. A bearer token + * that is not valid is left to the route (401), header or not. + */ +export function authenticate(o: { publicRead: boolean; resolve: (token: string, c: Context) => Promise; onActor?: (a: Actor) => Promise }): MiddlewareHandler { return async (c, next) => { const h = c.req.header('authorization'); const token = h && /^Bearer /i.test(h) ? h.slice(7).trim() : ''; c.set('publicRead', o.publicRead); c.set('bearer', Boolean(token)); - c.set('caller', token ? await o.resolve(token, c) : null); + let caller = token ? await o.resolve(token, c) : null; + const actorHeader = c.req.header('chalk-actor'); + if (actorHeader !== undefined && (caller || !token)) { + const fail = (status: number, code: string, message: string) => c.json({ error: { code, message } }, status as never); + if (!caller?.actFor) return fail(403, 'act_for_not_allowed', 'Chalk-Actor is accepted only from an API key that may act for people'); + const actor = parseActor(actorHeader); + if (!actor) return fail(400, 'invalid', 'Chalk-Actor must be base64url JSON {"id": "staff:" | "system:", "name": "<1 to 120 characters>"}'); + try { await o.onActor?.(actor); } catch (e) { console.error('Chalk-Actor: the actor could not be recorded:', e); } + caller = { ...caller, user: actor.id, actingFor: actor, via: caller.user }; + } + c.set('caller', caller); await next(); }; } diff --git a/packages/library-server/src/auth/roles.ts b/packages/library-server/src/auth/roles.ts index cb2ba66..854b2b6 100644 --- a/packages/library-server/src/auth/roles.ts +++ b/packages/library-server/src/auth/roles.ts @@ -17,8 +17,20 @@ export type Caller = { kind?: CallerKind; /** The user's id, the API key's id or the bootstrap token's name. */ sub?: string; + /** An API key allowed to act for people (`actFor`): it may send `Chalk-Actor` (registry-api §10.7). */ + actFor?: boolean; + /** + * Set when an act-for key sent `Chalk-Actor`: the person (or job) it acts for. `user` is then the actor's id, so + * every record names the person; `via` keeps the key's own name. + */ + actingFor?: Actor; + /** The credential's own name when `user` is an actor's id. */ + via?: string; }; +/** Who an act-for key acts for (`Chalk-Actor`): a lasting id (`staff:`, `system:`) and a display name. */ +export type Actor = { id: string; name: string }; + const RANK: Record = { reader: 0, author: 1, admin: 2 }; export const isRole = (r: unknown): r is Role => typeof r === 'string' && (ROLES as readonly string[]).includes(r); diff --git a/packages/library-server/src/auth/routes.ts b/packages/library-server/src/auth/routes.ts index 8fcad48..8adf659 100644 --- a/packages/library-server/src/auth/routes.ts +++ b/packages/library-server/src/auth/routes.ts @@ -19,14 +19,18 @@ export type AuthRouteOptions = { rateLimit?: { perUser?: number; perAddress?: number }; /** Take the caller's address from the last `X-Forwarded-For` entry (behind one reverse proxy). Default false. */ trustProxy?: boolean; + /** HUMAN_ACCOUNTS (default true). false: no password or refresh grants, no user administration (404). */ + humanAccounts?: boolean; }; const rolesSchema = z.array(z.enum(ROLES)).min(1); const newUserSchema = z.object({ username: z.string().min(1).max(64), password: z.string(), roles: rolesSchema }); const patchUserSchema = z.object({ password: z.string().optional(), roles: rolesSchema.optional(), disabled: z.boolean().optional() }).strict(); -const newKeySchema = z.object({ name: z.string().min(1).max(100), roles: rolesSchema }); +const newKeySchema = z.object({ name: z.string().min(1).max(100), roles: rolesSchema, actFor: z.boolean().optional() }); +const patchKeySchema = z.object({ actFor: z.boolean().optional() }).strict(); export function mountAuthRoutes(app: Hono<{ Variables: AuthVariables }>, auth: Auth, o: AuthRouteOptions) { + const humans = o.humanAccounts !== false; const perUser = rateLimiter(o.rateLimit?.perUser ?? 10), perAddress = rateLimiter(o.rateLimit?.perAddress ?? 60); const address = (c: Context) => { if (o.trustProxy) { const xff = c.req.header('x-forwarded-for')?.split(',').map((s) => s.trim()).filter(Boolean); if (xff?.length) return xff.at(-1)!; } @@ -53,7 +57,7 @@ export function mountAuthRoutes(app: Hono<{ Variables: AuthVariables }>, auth: A c.header('Cache-Control', 'public, max-age=300'); c.header('Access-Control-Allow-Origin', '*'); return c.json({ issuer: iss, jwks_uri: `${iss}/.well-known/jwks.json`, token_endpoint: `${iss}/v1/auth/token`, revocation_endpoint: `${iss}/v1/auth/revoke`, - userinfo_endpoint: `${iss}/v1/auth/me`, grant_types_supported: ['password', 'refresh_token'], token_endpoint_auth_methods_supported: ['none'], + userinfo_endpoint: `${iss}/v1/auth/me`, grant_types_supported: humans ? ['password', 'refresh_token'] : [], token_endpoint_auth_methods_supported: ['none'], id_token_signing_alg_values_supported: ['EdDSA'], response_types_supported: [], scopes_supported: [], subject_types_supported: ['public'], }); }); @@ -65,7 +69,8 @@ export function mountAuthRoutes(app: Hono<{ Variables: AuthVariables }>, auth: A const str = (k: string) => (b && typeof b[k] === 'string' ? (b[k] as string) : undefined); const grant = str('grant_type'); if (!grant) return oauthError(c, 400, 'invalid_request', 'grant_type is required'); - if (grant !== 'password' && grant !== 'refresh_token') return oauthError(c, 400, 'unsupported_grant_type'); + // HUMAN_ACCOUNTS=off: both grants are for users, so neither is offered + if ((grant !== 'password' && grant !== 'refresh_token') || !humans) return oauthError(c, 400, 'unsupported_grant_type'); const limited = (r: { ok: boolean; retryAfter: number }) => { c.header('Retry-After', String(r.retryAfter)); return oauthError(c, 429, 'rate_limited', 'too many attempts; try again later'); }; const byAddress = perAddress.hit(address(c)); if (!byAddress.ok) return limited(byAddress); @@ -100,22 +105,25 @@ export function mountAuthRoutes(app: Hono<{ Variables: AuthVariables }>, auth: A const caller = c.get('caller'); if (!caller) { const denied = requireRole(c, 'author'); if (denied) return denied; } c.header('Cache-Control', 'no-store'); - return c.json({ sub: caller!.sub ?? caller!.user, name: caller!.user, roles: rolesOf(caller!), kind: caller!.kind ?? 'bootstrap' }); + return c.json({ sub: caller!.sub ?? caller!.user, name: caller!.via ?? caller!.user, roles: rolesOf(caller!), kind: caller!.kind ?? 'bootstrap', + ...(caller!.actFor ? { actFor: true } : {}), ...(caller!.actingFor ? { actingFor: caller!.actingFor } : {}) }); }); // ---- administrators: users and API keys ---- const by = (c: Context<{ Variables: AuthVariables }>) => c.get('caller')!.user; + // HUMAN_ACCOUNTS=off: there are no user accounts to manage + const noUsers = (c: Context) => (humans ? null : fail(c, 404, 'not_found', 'this library has no user accounts (HUMAN_ACCOUNTS=off)')); app.post('/v1/admin/users', small, async (c) => { - const denied = requireRole(c, 'admin'); if (denied) return denied; + const denied = noUsers(c) ?? requireRole(c, 'admin'); if (denied) return denied; const b = await json(c, newUserSchema); if (!b.ok) return b.res; return guarded(c, async () => c.json(await auth.createUser(b.data), 201)); }); app.get('/v1/admin/users', async (c) => { - const denied = requireRole(c, 'admin'); if (denied) return denied; + const denied = noUsers(c) ?? requireRole(c, 'admin'); if (denied) return denied; return c.json({ items: await auth.listUsers() }); }); app.patch('/v1/admin/users/:id', small, async (c) => { - const denied = requireRole(c, 'admin'); if (denied) return denied; + const denied = noUsers(c) ?? requireRole(c, 'admin'); if (denied) return denied; const b = await json(c, patchUserSchema); if (!b.ok) return b.res; const p = b.data; return guarded(c, async () => c.json(await auth.updateUser(c.req.param('id'), { @@ -136,4 +144,16 @@ export function mountAuthRoutes(app: Hono<{ Variables: AuthVariables }>, auth: A const denied = requireRole(c, 'admin'); if (denied) return denied; return (await auth.revokeApiKey(c.req.param('id'))) ? c.json({ id: c.req.param('id'), revoked: true }) : fail(c, 404, 'not_found', 'no such key, or it is already revoked'); }); + // whether a key may act for people (Chalk-Actor, §10.7) + app.patch('/v1/admin/keys/:id', small, async (c) => { + const denied = requireRole(c, 'admin'); if (denied) return denied; + const b = await json(c, patchKeySchema); if (!b.ok) return b.res; + const k = await auth.updateApiKey(c.req.param('id'), { ...(b.data.actFor !== undefined ? { actFor: b.data.actFor } : {}) }); + return k ? c.json(k) : fail(c, 404, 'not_found', 'no such key'); + }); + // the people (and jobs) act-for keys acted for: the ids records keep, with their latest names + app.get('/v1/admin/actors', async (c) => { + const denied = requireRole(c, 'admin'); if (denied) return denied; + return c.json({ items: await auth.listActors() }); + }); } diff --git a/packages/library-server/src/auth/service.ts b/packages/library-server/src/auth/service.ts index 2bce491..492fb0e 100644 --- a/packages/library-server/src/auth/service.ts +++ b/packages/library-server/src/auth/service.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto'; import type { Db } from '../db.js'; import { CLOCK_SKEW_S, jwkOf, keyFromPem, kidOf, newKeyPair, signAccessToken, verifyAccessToken, type Jwk, type SigningKey } from './jwt.js'; import { hashPassword, MAX_PASSWORD, passwordProblem, verifyPassword } from './password.js'; -import { expandRoles, type Caller, type Role } from './roles.js'; +import { expandRoles, type Actor, type Caller, type Role } from './roles.js'; import { isSealed, keySealer, openSuccessor, sealSuccessor } from './sealed.js'; /** @@ -28,12 +28,16 @@ export type AuthOptions = { refreshGrace?: number; /** AUTH_KEY_SECRET: seals the private signing keys (at least 32 characters). */ keySecret?: string; + /** HUMAN_ACCOUNTS (default true). false: users' access tokens are refused; only API keys and bootstrap tokens work. */ + humanAccounts?: boolean; now?: () => number; }; export type TokenResponse = { access_token: string; token_type: 'Bearer'; expires_in: number; refresh_token: string; roles: Role[] }; export type UserRecord = { id: string; username: string; roles: Role[]; disabled: boolean; createdAt: string; updatedAt: string; lastSignInAt: string | null }; -export type ApiKeyRecord = { id: string; name: string; hint: string; roles: Role[]; createdBy: string | null; createdAt: string; lastUsedAt: string | null; revokedAt: string | null }; +export type ApiKeyRecord = { id: string; name: string; hint: string; roles: Role[]; actFor: boolean; createdBy: string | null; createdAt: string; lastUsedAt: string | null; revokedAt: string | null }; +/** Someone an act-for key acted for (registry-api §10.7): the id the records keep, and the latest name sent with it. */ +export type ActorRecord = { id: string; name: string; firstSeenAt: string; lastSeenAt: string }; /** A request the service refuses: a status and a code for the API's error shape. */ export class AuthError extends Error { @@ -41,7 +45,7 @@ export class AuthError extends Error { } type UserRow = { id: string; username: string; password_hash: string; roles: string[]; disabled: boolean; created_at: Date | string; updated_at: Date | string; last_sign_in_at: Date | string | null }; -type KeyRow = { id: string; name: string; hint: string; roles: string[]; created_by: string | null; created_at: Date | string; last_used_at: Date | string | null; revoked_at: Date | string | null }; +type KeyRow = { id: string; name: string; hint: string; roles: string[]; act_for: boolean | null; created_by: string | null; created_at: Date | string; last_used_at: Date | string | null; revoked_at: Date | string | null }; /** How often an instance re-reads the signing keys (another instance or the CLI may have rotated them). */ const RELOAD_MS = 5 * 60_000; @@ -53,7 +57,7 @@ const userRecord = (r: UserRow): UserRecord => ({ createdAt: iso(r.created_at)!, updatedAt: iso(r.updated_at)!, lastSignInAt: iso(r.last_sign_in_at), }); const keyRecord = (r: KeyRow): ApiKeyRecord => ({ - id: r.id, name: r.name, hint: r.hint, roles: expandRoles(r.roles), createdBy: r.created_by, + id: r.id, name: r.name, hint: r.hint, roles: expandRoles(r.roles), actFor: r.act_for === true, createdBy: r.created_by, createdAt: iso(r.created_at)!, lastUsedAt: iso(r.last_used_at), revokedAt: iso(r.revoked_at), }); export const normalizeUsername = (u: string) => u.trim().normalize('NFC').toLowerCase(); @@ -65,6 +69,9 @@ export function createAuth(o: AuthOptions) { const accessTtl = o.accessTtl ?? 900, refreshTtl = o.refreshTtl ?? 30 * 24 * 3600; const userCacheMs = o.userCacheMs ?? 10_000, refreshGrace = o.refreshGrace ?? 30; const now = o.now ?? Date.now; + const humanAccounts = o.humanAccounts !== false; + // actors written to the table lately (id → name and when), so a busy dashboard writes each at most once a minute + const actorSeen = new Map(); const sealer = o.keySecret ? keySealer(o.keySecret) : null; const needSecret = (what: string) => { if (!sealer) throw new AuthError(503, 'not_configured', `${what} needs AUTH_KEY_SECRET (the key that encrypts the token signing keys); set it and restart`); @@ -264,13 +271,15 @@ export function createAuth(o: AuthOptions) { /** The caller a bearer token names (an access token or an API key), or null. */ async resolve(token: string, issuer: string): Promise { if (token.startsWith('clk_')) { - const k = (await db.query<{ id: string; name: string; roles: string[] }>('SELECT id, name, roles FROM auth_api_keys WHERE key_hash = $1 AND revoked_at IS NULL', [sha256(token)]))[0]; + const k = (await db.query<{ id: string; name: string; roles: string[]; act_for: boolean }>('SELECT id, name, roles, act_for FROM auth_api_keys WHERE key_hash = $1 AND revoked_at IS NULL', [sha256(token)]))[0]; if (!k) return null; // when it was last used, to the minute: not a write on every request void db.query("UPDATE auth_api_keys SET last_used_at = now() WHERE id = $1 AND (last_used_at IS NULL OR last_used_at < now() - interval '1 minute')", [k.id]).catch(() => {}); const roles = expandRoles(k.roles); - return { user: k.name, sub: k.id, roles, admin: roles.includes('admin'), kind: 'api-key' }; + return { user: k.name, sub: k.id, roles, admin: roles.includes('admin'), kind: 'api-key', ...(k.act_for ? { actFor: true } : {}) }; } + // HUMAN_ACCOUNTS=off: a user's token, even one issued before, is not a credential here + if (!humanAccounts) return null; const kid = kidOf(token); if (!kid) return null; let ks = await keys(); @@ -312,13 +321,13 @@ export function createAuth(o: AuthOptions) { }, // ---- API keys ---- - async createApiKey(i: { name: string; roles: Role[]; createdBy?: string }): Promise { + async createApiKey(i: { name: string; roles: Role[]; actFor?: boolean | undefined; createdBy?: string }): Promise { const name = i.name.trim(); if (!name || name.length > 100) throw new AuthError(400, 'invalid', 'a key needs a name (at most 100 characters)'); const roles = expandRoles(i.roles); if (!roles.length) throw new AuthError(400, 'invalid', 'a key needs a role'); const key = `clk_${randomBytes(32).toString('base64url')}`; - const rows = await db.query('INSERT INTO auth_api_keys (id, name, key_hash, hint, roles, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', - [randomUUID(), name, sha256(key), `${key.slice(0, 8)}…`, roles, i.createdBy ?? null]); + const rows = await db.query('INSERT INTO auth_api_keys (id, name, key_hash, hint, roles, act_for, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *', + [randomUUID(), name, sha256(key), `${key.slice(0, 8)}…`, roles, i.actFor === true, i.createdBy ?? null]); return { ...keyRecord(rows[0]!), key }; }, async listApiKeys(): Promise { return (await db.query('SELECT * FROM auth_api_keys ORDER BY created_at')).map(keyRecord); }, @@ -326,6 +335,27 @@ export function createAuth(o: AuthOptions) { async revokeApiKey(id: string): Promise { return (await db.query('UPDATE auth_api_keys SET revoked_at = now() WHERE id::text = $1 AND revoked_at IS NULL RETURNING id', [id])).length > 0; }, + /** May the key act for people (send Chalk-Actor)? Takes effect on its next request. null: no such key. */ + async updateApiKey(id: string, p: { actFor?: boolean }): Promise { + const rows = await db.query('UPDATE auth_api_keys SET act_for = COALESCE($2, act_for) WHERE id::text = $1 RETURNING *', [id, p.actFor ?? null]); + return rows[0] ? keyRecord(rows[0]) : null; + }, + + // ---- people act-for keys acted for (registry-api §10.7) ---- + /** Records that an act-for key acted for `a` (its latest name, when last seen): at most once a minute per id in this process. */ + async noteActor(a: Actor): Promise { + const seen = actorSeen.get(a.id), t = now(); + if (seen && seen.name === a.name && t - seen.at < 60_000) return; + await db.query(`INSERT INTO actors (id, name) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, last_seen_at = now()`, [a.id, a.name]); + if (actorSeen.size > 10_000) actorSeen.clear(); + actorSeen.set(a.id, { name: a.name, at: t }); + }, + async listActors(): Promise { + return (await db.query<{ id: string; name: string; first_seen_at: Date | string; last_seen_at: Date | string }>('SELECT * FROM actors ORDER BY last_seen_at DESC, id')) + .map((r) => ({ id: r.id, name: r.name, firstSeenAt: iso(r.first_seen_at)!, lastSeenAt: iso(r.last_seen_at)! })); + }, + humanAccounts, }; } @@ -335,11 +365,12 @@ export function createAuth(o: AuthOptions) { * start only as it did before it had accounts: no users, PUBLIC_READ not false, no sealed keys. Throws a message * for the operator otherwise. */ -export async function checkKeySetup(db: Db, o: { keySecret?: string; publicRead: boolean }): Promise { +export async function checkKeySetup(db: Db, o: { keySecret?: string; publicRead: boolean; humanAccounts?: boolean }): Promise { if (o.keySecret) { await createAuth({ db, keySecret: o.keySecret }).loadSigningKeys(); return; } const sealed = await db.query("SELECT 1 FROM auth_keys WHERE retired_at IS NULL AND private_key LIKE 'enc:v1:%' LIMIT 1"); if (sealed.length) throw new Error('AUTH_KEY_SECRET is not set, but the token signing keys are encrypted with it'); - const users = await db.query('SELECT 1 FROM auth_users LIMIT 1'); + // HUMAN_ACCOUNTS=off: users that exist cannot sign in, so their tokens need no signing key + const users = o.humanAccounts === false ? [] : await db.query('SELECT 1 FROM auth_users LIMIT 1'); if (users.length) throw new Error('AUTH_KEY_SECRET is not set: this library has user accounts, so its token signing keys must be encrypted (openssl rand -base64 32)'); if (!o.publicRead) throw new Error('AUTH_KEY_SECRET is not set: a private library (PUBLIC_READ=false) needs it (openssl rand -base64 32)'); } diff --git a/packages/library-server/src/config.ts b/packages/library-server/src/config.ts index ebf827a..7a385e1 100644 --- a/packages/library-server/src/config.ts +++ b/packages/library-server/src/config.ts @@ -20,6 +20,7 @@ import type { DeliveryOptions } from './delivery.js'; * SERVER_URL this server's own address (disk uploads and file links come here; a private library's store names it) * TOKENS name:token,name:token (bootstrap callers: author; a name ending in ! is an administrator) * PUBLIC_READ true | false (search and read without a token; default true) + * HUMAN_ACCOUNTS on | off (off: no user accounts, sign-in or refresh; API keys and TOKENS only; default on) * ACCESS_TOKEN_TTL 900 (seconds an access token lasts) * REFRESH_TOKEN_TTL 2592000 (seconds a refresh token lasts: 30 days) * REFRESH_TOKEN_GRACE 30 (seconds a used refresh token still gives the same successor; 0 to 60, 0: never) @@ -45,6 +46,7 @@ export async function libraryFromEnv(env: Record = p const refreshGrace = env.REFRESH_TOKEN_GRACE ? grace() : undefined; const adopt = adoptPrefixes(env.ADOPT_PREFIXES); // checked before anything is opened const serverUrl = env.SERVER_URL ?? `http://localhost:${port}`; + const humanAccounts = onOff(env, 'HUMAN_ACCOUNTS', true); const db = await openDb(env.DATABASE_URL ?? 'pglite:', { ...(env.DB_SCHEMA ? { schema: env.DB_SCHEMA } : {}) }); let storage: Storage; if ((env.STORAGE ?? 'disk') === 's3') { @@ -59,7 +61,7 @@ export async function libraryFromEnv(env: Record = p } const publicRead = env.PUBLIC_READ !== 'false'; const keySecret = env.AUTH_KEY_SECRET || undefined; - try { await checkKeySetup(db, { publicRead, ...(keySecret ? { keySecret } : {}) }); } catch (e) { await db.close(); throw e; } + try { await checkKeySetup(db, { publicRead, humanAccounts, ...(keySecret ? { keySecret } : {}) }); } catch (e) { await db.close(); throw e; } return { db, storage, port, publicUrl: env.PUBLIC_URL ?? `${serverUrl.replace(/\/$/, '')}/files/`, @@ -73,6 +75,7 @@ export async function libraryFromEnv(env: Record = p ...(keySecret ? { keySecret } : {}), rateLimit: { ...(env.AUTH_RATE_LIMIT ? { perUser: positive('AUTH_RATE_LIMIT') } : {}), ...(env.AUTH_RATE_LIMIT_IP ? { perAddress: positive('AUTH_RATE_LIMIT_IP') } : {}) }, trustProxy: env.TRUST_PROXY === 'true', + humanAccounts, }, ...(env.MAX_BYTES ? { maxBytes: Number(env.MAX_BYTES) } : {}), ...(adopt.length ? { adoptPrefixes: adopt } : {}), @@ -83,6 +86,15 @@ export async function libraryFromEnv(env: Record = p }; } +/** A yes/no setting: on, true, 1 or off, false, 0 (any case); not set: `fallback`. */ +export function onOff(env: Record, k: string, fallback: boolean): boolean { + const v = env[k]?.trim().toLowerCase(); + if (!v) return fallback; + if (['on', 'true', '1', 'yes'].includes(v)) return true; + if (['off', 'false', '0', 'no'].includes(v)) return false; + throw new Error(`${k} must be on or off (true or false); got ${env[k]}`); +} + /** `name:token,…` (bootstrap tokens): a name ending in `!` is an administrator, any other an author. Compared in constant time. */ export function tokenCallers(spec: string): (token: string) => Caller | null { const digest = (s: string) => createHash('sha256').update(s).digest(); diff --git a/packages/library-server/src/db.ts b/packages/library-server/src/db.ts index d825058..63ce77d 100644 --- a/packages/library-server/src/db.ts +++ b/packages/library-server/src/db.ts @@ -85,6 +85,16 @@ INSERT INTO chalk_library_schema (version) VALUES (4);`); ALTER TABLE book_lines ADD COLUMN IF NOT EXISTS rects_by text; ALTER TABLE book_lines ADD COLUMN IF NOT EXISTS rects_at timestamptz; INSERT INTO chalk_library_schema (version) VALUES (6);`); + // step 7: API keys that act for people (registry-api §10.7, Chalk-Actor), and the people they acted for: the records + // keep an actor's id (staff:, system:), this table its latest display name + if (!done.has(7)) await exec(`ALTER TABLE auth_api_keys ADD COLUMN IF NOT EXISTS act_for boolean NOT NULL DEFAULT false; +CREATE TABLE IF NOT EXISTS actors ( + id text PRIMARY KEY, + name text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO chalk_library_schema (version) VALUES (7);`); } /** Step 5: book highlights (spec/1.2/registry-api.md §11). Questions are named by their lasting id only (no foreign key: they are manifests). */ diff --git a/packages/library-server/src/highlights.ts b/packages/library-server/src/highlights.ts index 6425b8f..eb32e85 100644 --- a/packages/library-server/src/highlights.ts +++ b/packages/library-server/src/highlights.ts @@ -1,6 +1,7 @@ import type { Context, Hono } from 'hono'; import { z } from 'zod'; import { requireRole, type AuthVariables } from './auth/guard.js'; +import type { Caller } from './auth/roles.js'; import type { Db } from './db.js'; /** @@ -166,7 +167,11 @@ export function registerHighlights(app: Hono<{ Variables: AuthVariables }>, o: { } }; const parse = async (c: Context, schema: z.ZodType): Promise => { - const r = schema.safeParse(await c.req.json().catch(() => null)); + const raw: unknown = await c.req.json().catch(() => null); + // a key acting for a person (Chalk-Actor, §10.7): the person is `by`, whatever the body says (and it may leave `by` out) + const actor = (c.get('caller') as Caller | null | undefined)?.actingFor; + const takesBy = schema instanceof z.ZodObject && 'by' in schema.shape; + const r = schema.safeParse(actor && takesBy && raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw, by: actor.id } : raw); if (!r.success) throw new HighlightError(400, 'invalid', r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')); return r.data; }; From b4608011a11a2fcf10417b9f2cf3823136fce498 Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 07:20:36 +0000 Subject: [PATCH 04/13] feat(library-server): MIGRATE_ON_START=false and chalk-library migrate A deployment can run the server as a login with row rights only: the schema's owner runs `chalk-library migrate` as a release step, and the server (and the key/user CLI) then creates nothing at start and refuses a schema that lacks a migration step it knows, with a message saying what to run. Default stays true. CREATE SCHEMA is now issued only when the schema is missing: Postgres checks the right to create schemas before IF NOT EXISTS, which an owner given its schema by the setup does not have. The runtime-login test makes its own database on a real Postgres (CHALK_LIBRARY_TEST_DB): owner migrates, a DML-only login serves keys, act-for uploads, trigram search, highlights and users. Co-Authored-By: Claude Opus 5.5 --- packages/library-server/src/auth/cli.ts | 6 +- packages/library-server/src/cli.ts | 18 +++- packages/library-server/src/config.ts | 5 +- packages/library-server/src/db.ts | 45 ++++++-- packages/library-server/src/migrate.test.ts | 109 ++++++++++++++++++++ 5 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 packages/library-server/src/migrate.test.ts diff --git a/packages/library-server/src/auth/cli.ts b/packages/library-server/src/auth/cli.ts index 9d8fe53..e8476b1 100644 --- a/packages/library-server/src/auth/cli.ts +++ b/packages/library-server/src/auth/cli.ts @@ -53,10 +53,10 @@ export async function runAuthCommand(args: string[], env: Record { const i = args.indexOf(n); return i > 0 ? args[i + 1] : undefined; }; if (!env.DATABASE_URL) { console.error('DATABASE_URL is not set (the library\'s database)'); return 2; } - let humans: boolean; - try { humans = onOff(env, 'HUMAN_ACCOUNTS', true); } catch (e) { console.error(e instanceof Error ? e.message : String(e)); return 2; } + let humans: boolean, migrate: boolean; + try { humans = onOff(env, 'HUMAN_ACCOUNTS', true); migrate = onOff(env, 'MIGRATE_ON_START', true); } catch (e) { console.error(e instanceof Error ? e.message : String(e)); return 2; } if (group === 'user' && !humans) { console.error('HUMAN_ACCOUNTS=off: this library has no user accounts'); return 2; } - const db = await openDb(env.DATABASE_URL, { ...(env.DB_SCHEMA ? { schema: env.DB_SCHEMA } : {}) }); + const db = await openDb(env.DATABASE_URL, { ...(env.DB_SCHEMA ? { schema: env.DB_SCHEMA } : {}), migrate }); // AUTH_KEY_SECRET: adding a user and rotating the signing key need it (as the server does) let auth: ReturnType; try { auth = createAuth({ db, ...(env.AUTH_KEY_SECRET ? { keySecret: env.AUTH_KEY_SECRET } : {}) }); } catch (e) { console.error(e instanceof Error ? e.message : String(e)); await db.close(); return 2; } diff --git a/packages/library-server/src/cli.ts b/packages/library-server/src/cli.ts index 0cf7fef..965a67b 100644 --- a/packages/library-server/src/cli.ts +++ b/packages/library-server/src/cli.ts @@ -3,10 +3,21 @@ import { serve } from '@hono/node-server'; import { createLibraryApp } from './app.js'; import { AUTH_USAGE, readSecret, runAuthCommand } from './auth/cli.js'; import { libraryFromEnv } from './config.js'; +import { openDb, SCHEMA_VERSION } from './db.js'; import { scanStorage } from './scan.js'; -// chalk-library serve | scan [prefix] | check (--token … | --user …) [--admin-token …] | user … | key … (configuration: see config.ts) +// chalk-library serve | migrate | scan [prefix] | check (--token … | --user …) [--admin-token …] | user … | key … (configuration: see config.ts) const [cmd = 'serve', arg] = process.argv.slice(2); +if (cmd === 'migrate') { + // the release step when the server starts with MIGRATE_ON_START=false: run with the owner login (DATABASE_URL) + const url = process.env.DATABASE_URL; + if (!url) { console.error('DATABASE_URL is not set (the library\'s database, as its owner login)'); process.exit(2); } + const schema = process.env.DB_SCHEMA; + const db = await openDb(url, { ...(schema ? { schema } : {}), migrate: true }); + await db.close(); + console.log(`the library's schema${url.startsWith('pglite:') ? '' : ` (${schema ?? 'chalk_library'})`} is at migration step ${SCHEMA_VERSION}`); + process.exit(0); +} if (cmd === 'check') { const opt = (n: string) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : undefined; }; if (!arg || !(opt('--token') || opt('--user'))) { console.error('usage: chalk-library check (--token … | --user … [password: CHALK_LIBRARY_PASSWORD, prompt or stdin]) [--admin-token …]'); process.exit(2); } @@ -17,7 +28,8 @@ if (cmd === 'check') { process.exit(results.every((r) => r.ok) ? 0 : 1); } if (cmd === 'user' || cmd === 'key' || cmd === 'signing-key') process.exit(await runAuthCommand(process.argv.slice(2))); -const o = await libraryFromEnv(); +// a setting or the database is not right: say what, without a stack trace +const o = await libraryFromEnv().catch((e: unknown) => { console.error(`chalk-library: ${e instanceof Error ? e.message : String(e)}`); process.exit(1); }); if (cmd === 'serve') { const app = createLibraryApp(o); serve({ fetch: app.fetch, port: o.port }, (i) => console.log(`chalk library on :${i.port} · storage ${o.storage.id} · readers fetch from ${o.publicUrl}`)); @@ -26,6 +38,6 @@ if (cmd === 'serve') { console.log(`\n${r.found} media files found, ${r.added} added to the library`); await o.db.close(); } else { - console.error(`usage: chalk-library serve | scan [prefix] | check …\n${AUTH_USAGE}`); + console.error(`usage: chalk-library serve | migrate | scan [prefix] | check …\n${AUTH_USAGE}`); process.exit(2); } diff --git a/packages/library-server/src/config.ts b/packages/library-server/src/config.ts index 7a385e1..add6bac 100644 --- a/packages/library-server/src/config.ts +++ b/packages/library-server/src/config.ts @@ -12,6 +12,7 @@ import type { DeliveryOptions } from './delivery.js'; * * DATABASE_URL postgres://user:pass@host/db (or pglite:./data to keep it in a folder, pglite: in memory) * DB_SCHEMA chalk_library (Postgres: the library keeps everything in this schema) + * MIGRATE_ON_START true | false (false: the schema is left as it is and must be current; `chalk-library migrate` with the owner login) * STORAGE s3 | disk * S3_ENDPOINT https://.r2.cloudflarestorage.com · http://minio:9000 · https://s3..amazonaws.com * S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION (R2: auto), S3_VIRTUAL_HOSTED (true for AWS) @@ -46,8 +47,8 @@ export async function libraryFromEnv(env: Record = p const refreshGrace = env.REFRESH_TOKEN_GRACE ? grace() : undefined; const adopt = adoptPrefixes(env.ADOPT_PREFIXES); // checked before anything is opened const serverUrl = env.SERVER_URL ?? `http://localhost:${port}`; - const humanAccounts = onOff(env, 'HUMAN_ACCOUNTS', true); - const db = await openDb(env.DATABASE_URL ?? 'pglite:', { ...(env.DB_SCHEMA ? { schema: env.DB_SCHEMA } : {}) }); + const humanAccounts = onOff(env, 'HUMAN_ACCOUNTS', true), migrate = onOff(env, 'MIGRATE_ON_START', true); + const db = await openDb(env.DATABASE_URL ?? 'pglite:', { ...(env.DB_SCHEMA ? { schema: env.DB_SCHEMA } : {}), migrate }); let storage: Storage; if ((env.STORAGE ?? 'disk') === 's3') { storage = s3Storage({ diff --git a/packages/library-server/src/db.ts b/packages/library-server/src/db.ts index 63ce77d..a5c1b2f 100644 --- a/packages/library-server/src/db.ts +++ b/packages/library-server/src/db.ts @@ -20,12 +20,20 @@ function schemaSql(): string { throw new Error('registry.sql not found'); } +/** The newest migration step this server knows (the steps of `migrate`). */ +export const SCHEMA_VERSION = 7; + /** * `schema`: Postgres only: everything the library makes (tables, its migration record, the pg_trgm extension) goes * into this schema, so a library can share a database with an app without touching the app's tables. Default * `chalk_library`. + * + * `migrate` (default true, MIGRATE_ON_START): create the schema and apply the migration steps. false: touch nothing + * (the login may hold only DML rights on the schema's tables) and refuse to open a schema that is not at + * `SCHEMA_VERSION`; `chalk-library migrate`, run with the owner login, brings it there. */ -export async function openDb(url: string, o: { schema?: string } = {}): Promise { +export async function openDb(url: string, o: { schema?: string; migrate?: boolean } = {}): Promise { + const migrating = o.migrate !== false; let db: Db; if (url.startsWith('pglite:')) { const { PGlite } = await import('@electric-sql/pglite'); @@ -38,14 +46,19 @@ export async function openDb(url: string, o: { schema?: string } = {}): Promise< begin: (fn) => pg.transaction((tx) => fn(async (sql: string, params: unknown[] = []) => (await tx.query(sql, params)).rows)), async close() { await pg.close(); }, }; - await migrate(db, (sql) => pg.transaction((tx) => tx.exec(sql)).then(() => undefined)); + if (migrating) await migrate(db, (sql) => pg.transaction((tx) => tx.exec(sql)).then(() => undefined)); } else { const { default: postgres } = await import('postgres'); const schema = o.schema ?? 'chalk_library'; if (!/^[a-z_][a-z0-9_]{0,62}$/.test(schema)) throw new Error(`not a schema name: ${schema}`); - const setup = postgres(url, { max: 1, onnotice: () => {} }); - await setup.unsafe(`CREATE SCHEMA IF NOT EXISTS ${schema}`); - await setup.end(); + if (migrating) { + const setup = postgres(url, { max: 1, onnotice: () => {} }); + // only when it is missing: Postgres checks the right to create schemas before IF NOT EXISTS, and an owner login + // given its schema by the database's setup has no such right + try { + if (!(await setup`SELECT 1 FROM pg_namespace WHERE nspname = ${schema}`).length) await setup.unsafe(`CREATE SCHEMA IF NOT EXISTS ${schema}`); + } finally { await setup.end(); } + } // every connection works in the library's schema (public after it, for what the database already has) const sql = postgres(url, { max: 10, onnotice: () => {}, connection: { search_path: `${schema}, public` } }); db = { @@ -53,11 +66,31 @@ export async function openDb(url: string, o: { schema?: string } = {}): Promise< begin: (fn: (query: Db['query']) => Promise) => sql.begin((tx) => fn(async (text: string, params: unknown[] = []) => (await tx.unsafe(text, params as never[])) as unknown as R[])) as Promise, async close() { await sql.end(); }, }; - await migrate(db, (text) => sql.begin((tx) => tx.unsafe(text).simple()).then(() => undefined)); + if (migrating) await migrate(db, (text) => sql.begin((tx) => tx.unsafe(text).simple()).then(() => undefined)); + } + if (!migrating) { + try { await checkSchema(db, o.schema ?? 'chalk_library'); } catch (e) { await db.close(); throw e; } } return db; } +/** The migration steps a schema has, 0 when it has none (no record table). */ +async function versionsOf(db: Db): Promise> { + const [t] = await db.query<{ t: string | null }>("SELECT to_regclass('chalk_library_schema')::text AS t"); + if (!t?.t) return new Set(); + return new Set((await db.query<{ version: number }>('SELECT version FROM chalk_library_schema')).map((r) => Number(r.version))); +} + +/** MIGRATE_ON_START=false: the schema must already have every step this server knows. */ +async function checkSchema(db: Db, schema: string) { + const done = await versionsOf(db); + const missing = Array.from({ length: SCHEMA_VERSION }, (_, i) => i + 1).filter((v) => !done.has(v)); + if (missing.length) { + throw new Error(`the library's database schema (${schema}) is not current: ${done.size ? `migration steps ${missing.join(', ')} are missing` : 'it has no tables yet'} ` + + `(this server needs step ${SCHEMA_VERSION}). MIGRATE_ON_START=false, so the server does not change it: run \`chalk-library migrate\` with the owner login, then start again`); + } +} + /** The reference schema, once. Later versions add numbered steps here (backward compatible only). */ async function migrate(db: Db, exec: (sql: string) => Promise) { await db.query('CREATE TABLE IF NOT EXISTS chalk_library_schema (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())'); diff --git a/packages/library-server/src/migrate.test.ts b/packages/library-server/src/migrate.test.ts new file mode 100644 index 0000000..13c8330 --- /dev/null +++ b/packages/library-server/src/migrate.test.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { c1FromSha256 } from '@chalk-format/model'; +import { checkKeySetup, createLibraryApp, diskStorage, libraryFromEnv, openDb } from './index.js'; +import { SCHEMA_VERSION } from './db.js'; + +const BASE = 'http://library.test'; +const dirs: string[] = []; +const tmp = (p: string) => { const d = mkdtempSync(join(tmpdir(), p)); dirs.push(d); return d; }; +afterAll(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); }); + +// MIGRATE_ON_START=false: the server changes nothing in its schema, and refuses one that is not current +describe('migrations at start, or as a release step', { timeout: 60_000 }, () => { + it('an unmigrated schema: the server refuses to start and says what to run', async () => { + await expect(openDb('pglite:', { migrate: false })).rejects.toThrow(/has no tables yet.*chalk-library migrate/); + await expect(libraryFromEnv({ DATABASE_URL: `pglite:${tmp('chalk-mig-')}`, MIGRATE_ON_START: 'false' })).rejects.toThrow(/not current/); + await expect(libraryFromEnv({ DATABASE_URL: 'pglite:', MIGRATE_ON_START: 'later' })).rejects.toThrow(/MIGRATE_ON_START must be on or off/); + }); + + it('a migrated schema starts; one a step behind does not', async () => { + const url = `pglite:${tmp('chalk-mig-')}`; + // the release step (chalk-library migrate) + await (await openDb(url)).close(); + const o = await libraryFromEnv({ DATABASE_URL: url, MIGRATE_ON_START: 'false' }); + expect((await o.db.query<{ n: number }>('SELECT count(*)::int AS n FROM chalk_library_schema'))[0]!.n).toBe(SCHEMA_VERSION); + await o.db.query('DELETE FROM chalk_library_schema WHERE version = $1', [SCHEMA_VERSION]); + await o.db.close(); + await expect(openDb(url, { migrate: false })).rejects.toThrow(new RegExp(`steps ${SCHEMA_VERSION} are missing`)); + // migrating again applies only what is missing (each step is additive and IF NOT EXISTS) + await (await openDb(url)).close(); + await (await openDb(url, { migrate: false })).close(); + }); +}); + +// A runtime login that may only read and write the rows of the library's schema (no CREATE anywhere), as in a +// deployment where an owner login runs `chalk-library migrate`. Needs a real Postgres where the given login can +// make roles: CHALK_LIBRARY_TEST_DB=postgres://postgres:…@localhost:5432/db +const SUPER = process.env.CHALK_LIBRARY_TEST_DB; +describe.skipIf(!SUPER)('a runtime login with DML rights only (real Postgres)', { timeout: 120_000 }, () => { + it('migrated by the owner, then served by a login that cannot create anything', async () => { + const { default: postgres } = await import('postgres'); + // a database of its own: pg_trgm exists once per database, in one schema + const id = Date.now().toString(36), database = `rt_db_${id}`, schema = 'chalk_library', owner = `rt_owner_${id}`, runtime = `rt_app_${id}`; + const admin = postgres(SUPER!, { max: 1, onnotice: () => {} }); + await admin.unsafe(`CREATE DATABASE ${database}`); + const as = (user?: string) => { const u = new URL(SUPER!); u.pathname = `/${database}`; if (user) { u.username = user; u.password = `pw-${user}`; } return u.toString(); }; + const sql = postgres(as(), { max: 1, onnotice: () => {} }); + try { + // the database's setup (a superuser): the owner owns the schema, pg_trgm lives in it, the runtime login gets DML + await sql.unsafe(`CREATE ROLE ${owner} LOGIN PASSWORD 'pw-${owner}'`); + await sql.unsafe(`CREATE ROLE ${runtime} LOGIN PASSWORD 'pw-${runtime}'`); + await sql.unsafe(`CREATE SCHEMA ${schema} AUTHORIZATION ${owner}`); + await sql.unsafe(`CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA ${schema}`); + await sql.unsafe(`REVOKE ALL ON DATABASE ${database} FROM PUBLIC`); + await sql.unsafe(`GRANT CONNECT ON DATABASE ${database} TO ${owner}, ${runtime}`); + await sql.unsafe('REVOKE CREATE ON SCHEMA public FROM PUBLIC'); + await sql.unsafe(`GRANT USAGE ON SCHEMA ${schema} TO ${runtime}`); + await sql.unsafe(`ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${schema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${runtime}`); + await sql.unsafe(`ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${schema} GRANT USAGE, SELECT ON SEQUENCES TO ${runtime}`); + + // before the release step the runtime login cannot start; it cannot migrate either + await expect(openDb(as(runtime), { schema, migrate: false })).rejects.toThrow(/has no tables yet/); + await expect(openDb(as(runtime), { schema })).rejects.toThrow(); + // the release step, as the owner + await (await openDb(as(owner), { schema })).close(); + + const db = await openDb(as(runtime), { schema, migrate: false }); + try { + await expect(db.query('CREATE TABLE x (a int)')).rejects.toThrow(/permission denied/); + const keySecret = 'a-test-secret-that-is-long-enough-0123456789'; + await checkKeySetup(db, { publicRead: true, keySecret }); + const dir = tmp('chalk-rt-'); + const app = createLibraryApp({ db, storage: diskStorage(dir, { uploadBase: BASE }), publicUrl: `${BASE}/files/`, callers: (t) => (t === 'root' ? { user: 'root', sub: 'root', admin: true, roles: ['reader', 'author', 'admin'], kind: 'bootstrap' } : null), publicRead: true, issuer: BASE, auth: { keySecret } }); + const call = async (path: string, o: { method?: string; token?: string; body?: unknown; actor?: unknown } = {}) => { + const res = await app.fetch(new Request(`${BASE}${path}`, { + method: o.method ?? (o.body !== undefined ? 'POST' : 'GET'), + headers: { ...(o.token === '' ? {} : { Authorization: `Bearer ${o.token ?? 'root'}` }), 'Content-Type': 'application/json', ...(o.actor ? { 'Chalk-Actor': Buffer.from(JSON.stringify(o.actor)).toString('base64url') } : {}) }, + ...(o.body !== undefined ? { body: JSON.stringify(o.body) } : {}), + })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers of many shapes + return { status: res.status, body: (await res.json().catch(() => null)) as any }; + }; + // keys, acting for a person, an upload, search (pg_trgm), highlights, the users and their signing key + const key = await call('/v1/admin/keys', { body: { name: 'dashboard', roles: ['author'], actFor: true } }); + expect(key.status).toBe(201); + const rita = { id: 'staff:rita-1', name: 'Rita' }; + const bytes = new TextEncoder().encode('runtime login'), sha256 = createHash('sha256').update(bytes).digest('hex'), k = c1FromSha256(`sha256:${sha256}`); + const up = await call('/v1/uploads', { token: key.body.key, actor: rita, body: { key: k, sha256, type: 'image/png', size: bytes.byteLength } }); + expect(up.status, JSON.stringify(up.body)).toBe(200); + await app.fetch(new Request(up.body.upload.url, { method: 'PUT', body: bytes })); + const reg = await call('/v1/assets', { token: key.body.key, actor: rita, body: { key: k, sha256, type: 'image/png', size: bytes.byteLength, title: 'ফলক runtime login' } }); + expect(reg.body).toMatchObject({ status: 'available', uploadedBy: rita.id }); + expect((await call('/v1/search?q=runtime')).body.items.map((x: { key: string }) => x.key)).toEqual([k]); + expect((await call('/v1/admin/actors')).body.items).toEqual([expect.objectContaining(rita)]); + expect((await call('/v1/books/rt-book/lines/sync', { body: { lines: [{ key: 'p1/0/0', pageId: 'p1', pageNo: 1, idx: 0, block: 0, kind: 'body', text: 'a', textHash: 'h', rects: [] }] } })).status).toBe(200); + expect((await call('/v1/admin/users', { body: { username: 'arun', password: 'author-password-1', roles: ['author'] } })).status).toBe(201); + expect((await call('/v1/auth/token', { token: '', body: { grant_type: 'password', username: 'arun', password: 'author-password-1' } })).status).toBe(200); + } finally { await db.close(); } + } finally { + await sql.end(); + await admin.unsafe(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + for (const r of [owner, runtime]) await admin.unsafe(`DROP ROLE IF EXISTS ${r}`); + await admin.end(); + } + }); +}); From 78fee1e417cec78dd1cef8aa4684f8c5608648f1 Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 07:20:36 +0000 Subject: [PATCH 05/13] docs(spec): API keys MAY act for people; a registry MAY run without user accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry API §10.7 (Chalk-Actor: validation, recording the actor id, 403 for any other caller) and §10.8 (no password or refresh grants, no user endpoints), with actFor, PATCH keys and GET v1/admin/actors in §10.5. Co-Authored-By: Claude Opus 5.5 --- spec/1.2/registry-api.md | 52 ++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/spec/1.2/registry-api.md b/spec/1.2/registry-api.md index c9e2912..8ed394c 100644 --- a/spec/1.2/registry-api.md +++ b/spec/1.2/registry-api.md @@ -253,7 +253,7 @@ All are sent as `Authorization: Bearer `. window, or once the successor was used, the reuse revokes the family as above. 3. **API key** (machines: CI, publishers, an app's server): an opaque string `clk_`, at least 32 random bytes. Made by an administrator, shown once, kept as its SHA-256. It has a name and roles, records when it was - last used, and can be revoked. + last used, and can be revoked. A key MAY be allowed to act for people (§10.7). 4. **Other tokens** the deployment configures (the reference server: `TOKENS`) MAY also be accepted. Passwords are stored with a memory-hard hash (the reference server: scrypt with a random salt per user) and @@ -287,7 +287,8 @@ when a limit is reached. **`POST v1/auth/revoke`** `{ "refresh_token" }` → `200`, always: signing out ends that sign-in (its family). **`GET v1/auth/me`** → `{ "sub", "name", "roles", "kind": "user" | "api-key" | "bootstrap" }` for any valid -credential, `401` without one. +credential, `401` without one. A key that may act for people adds `"actFor": true`, and when the request carries +`Chalk-Actor` (§10.7), `"actingFor": { "id", "name" }`; `name` stays the key's own. ### 10.5 Administration (`admin`) @@ -297,11 +298,16 @@ credential, `401` without one. - **`GET v1/admin/users`** → `{ "items": [user, …] }`. - **`PATCH v1/admin/users/{id}`** `{ "password"?, "roles"?, "disabled"? }` → the user. Disabling a user or setting a new password revokes all of the user's refresh tokens. -- **`POST v1/admin/keys`** `{ "name", "roles" }` → `201 { "id", "key", "name", "roles", … }`. `key` is never - shown again. -- **`GET v1/admin/keys`** → `{ "items": [{ "id", "name", "hint", "roles", "createdBy", "createdAt", "lastUsedAt", - "revokedAt" }] }`; never the key. +- **`POST v1/admin/keys`** `{ "name", "roles", "actFor"? }` → `201 { "id", "key", "name", "roles", "actFor", … }`. + `key` is never shown again. `actFor` (default `false`): the key may act for people (§10.7). +- **`GET v1/admin/keys`** → `{ "items": [{ "id", "name", "hint", "roles", "actFor", "createdBy", "createdAt", + "lastUsedAt", "revokedAt" }] }`; never the key. +- **`PATCH v1/admin/keys/{id}`** `{ "actFor"? }` → the key. It applies from the key's next request. - **`DELETE v1/admin/keys/{id}`** → `200`; the key stops working at once. +- **`GET v1/admin/actors`** → `{ "items": [{ "id", "name", "firstSeenAt", "lastSeenAt" }] }`: everyone keys have + acted for (§10.7), with the latest name sent for each. + +A registry without user accounts (§10.8) answers `404` to the three user endpoints. ### 10.6 A private library @@ -309,6 +315,37 @@ A registry MAY require a token for reading too (the reference server: `PUBLIC_RE `GET v1/stores`, a store with `"auth": "bearer"` (1.2 §5.1) for the bytes, and readers send the same bearer credential to it (§7.1). `GET v1/stores` itself and the discovery documents of §10.3 stay public. +### 10.7 Acting for a person + +An app that signs in its own people (a dashboard with staff accounts) MAY call the registry with one API key and say, +on each request, who it acts for, so the registry's records name the person and not the app. A registry MAY allow +this; one that does MUST do it as this section says. + +- An administrator marks a key as one that may act for people (`actFor`, §10.5). No other credential may. +- The app sends **`Chalk-Actor: `** (RFC 4648 §5, padding optional). `id` is the + person's lasting id in the app, `staff:`, or a job's, `system:`; it MUST match + `^(staff|system):[A-Za-z0-9._:-]{1,80}$`. `name` is a display name, 1 to 120 characters, no control characters. +- From a key that may act for people, the registry records `id` wherever it records who did something (an asset's + `uploadedBy`; in §11, `reviewedBy` and `rectsBy`, in place of the body's `by`, which may then be left out), and + filters that name the caller (`mine=1`, §5) mean that person. It keeps the latest `name` for each `id` + (`GET v1/admin/actors`) so screens can show a name; records keep only the id. A header that does not decode to + such an object is `400 invalid`. +- From any other caller (a key without `actFor`, a user's token, another token, no token) the header MUST be + refused with `403 act_for_not_allowed`, never ignored: a client set up wrong is then noticed instead of writing + under the wrong name. A credential that is not valid is still `401`. +- Without the header, a key that may act for people is recorded under its own name, as any key. + +The registry trusts the app to say who its person is: such a key is as powerful as every person it can name, and +is kept on the app's server, never in a browser. + +### 10.8 Without user accounts + +A registry MAY run without user accounts, with API keys and its other tokens only (the reference server: +`HUMAN_ACCOUNTS=off`), for example when the people sign in to another app that acts for them (§10.7). It then +answers the `password` and `refresh_token` grants with `400 unsupported_grant_type`, leaves them out of +`grant_types_supported`, answers `404` to the user endpoints of §10.5, and refuses access tokens of users it issued +before. Discovery (§10.3) stays published. + ## 11. Book highlights (optional) A registry MAY keep the **highlights** of its books: the lines of a book's text and which question each line leads to. Unlike @@ -375,7 +412,8 @@ A **link** (a question tied to a line): ### 11.3 Writing (`author`) -Every write names the person (or job) in `by`, kept as `reviewedBy`. Each request is one transaction. +Every write names the person (or job) in `by`, kept as `reviewedBy`; from a key acting for a person (§10.7), the +person's id is kept instead and `by` may be left out. Each request is one transaction. | Request | What it does | |---|---| From 78485401ee92d145b32a47ffac82b9182cbc88ac Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 07:20:36 +0000 Subject: [PATCH 06/13] docs(library-server): acting for people, HUMAN_ACCOUNTS, owner and runtime logins The settings, the migrate release step, the grants for a DML-only runtime login and where pg_trgm goes (the setup creates it in the library's schema). Co-Authored-By: Claude Opus 5.5 --- docs/guides/run-a-library-server.md | 59 +++++++++++++++++++++++++--- packages/library-server/.env.example | 5 +++ packages/library-server/README.md | 8 +++- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/guides/run-a-library-server.md b/docs/guides/run-a-library-server.md index 1d415c4..cef52c5 100644 --- a/docs/guides/run-a-library-server.md +++ b/docs/guides/run-a-library-server.md @@ -50,6 +50,7 @@ starting point. Never commit the real `.env`. |---|---|---| | `DATABASE_URL` | `pglite:` (in memory) | `postgres://user:pass@host:5432/db?sslmode=require`, or `pglite:./dir` to keep PGlite's data in a folder. The Docker image needs Postgres (see below). | | `DB_SCHEMA` | `chalk_library` | Postgres only. The library keeps everything in this schema (tables, its migration record, `pg_trgm`), so it can share a database with an app. | +| `MIGRATE_ON_START` | `true` | `false`: the server creates nothing at start (no schema, extension or migration) and refuses to start unless the schema is at its migration step; `chalk-library migrate`, run with the owner login, brings it there. Lets the server run as a login with only row rights (see [Postgres](#postgres)). | | `STORAGE` | `disk` | `s3` or `disk`. | | `S3_ENDPOINT` | | e.g. `https://.r2.cloudflarestorage.com`, `http://minio:9000`, `https://s3.eu-central-1.amazonaws.com`. | | `S3_BUCKET` | | The bucket. | @@ -62,6 +63,7 @@ starting point. Never commit the real `.env`. | `PUBLIC_URL` | `/files/` | Where readers fetch the bytes: the bucket's public domain or CDN. With disk storage the server serves them itself at `/files/`. | | `SERVER_URL` | `http://localhost:` | This server's own public address, e.g. `https://library.example.org`. Disk storage puts it in upload links, and it is the `iss` of every access token, so set it in production and do not change it casually (tokens issued under the old value stop working). With `PUBLIC_READ=false` the store at `GET /v1/stores` names it; not set, the address the request reached is used (with `X-Forwarded-Proto`), and the answer is not shared-cacheable. | | `TOKENS` | none | Bootstrap tokens: `name:token,name:token`. A plain name is an author (reads and writes), a name ending in `!` an administrator (`admin!:…`). Compared in constant time. See [Users, sign-in and API keys](#users-sign-in-and-api-keys). | +| `HUMAN_ACCOUNTS` | `on` | `off`: the library has no user accounts. No sign-in or refresh (`400 unsupported_grant_type`), the user endpoints answer `404`, tokens users already have are refused; API keys (acting for people included) and `TOKENS` work. For a deployment whose people sign in to another app that acts for them ([below](#acting-for-a-person-chalk-actor)). | | `PUBLIC_READ` | `true` | `false`: searching, reading records **and the files' bytes** need a token too (any signed-in user or key; see [A private library](#a-private-library-public_readfalse)). Writing always needs one. | | `ACCESS_TOKEN_TTL` | `900` | Seconds an access token lasts. | | `REFRESH_TOKEN_TTL` | `2592000` (30 days) | Seconds a refresh token lasts (each use gives a new one). | @@ -87,10 +89,35 @@ exist). On first start the server: 1. creates `DB_SCHEMA` if it is missing; 2. points every connection's `search_path` at it (then `public`); -3. applies [`spec/1.2/registry.sql`](../../spec/1.2/registry.sql) once, recording it in - `chalk_library_schema`. +3. applies [`spec/1.2/registry.sql`](../../spec/1.2/registry.sql) once, then each later migration step, recording + them in `chalk_library_schema`. + +Nothing outside that schema is touched. + +**An owner login for migrations, a runtime login for the server.** With `MIGRATE_ON_START=false` the server +changes nothing in the database and checks at start that the schema has every migration step it knows; if not, it +stops with a message to run `chalk-library migrate`. Migrations are then a release step, run with a login that owns +the schema (`DATABASE_URL=postgres://px_library_owner:…@…/db chalk-library migrate`, in the image `node server.mjs +migrate`), before the new version starts. The server's own login needs only this (as a superuser or the database +owner, once): + +```sql +CREATE ROLE px_library_owner LOGIN PASSWORD '…'; +CREATE ROLE px_library_app LOGIN PASSWORD '…'; +CREATE SCHEMA chalk_library AUTHORIZATION px_library_owner; +CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA chalk_library; -- the setup makes it: the owner may not create extensions +GRANT USAGE ON SCHEMA chalk_library TO px_library_app; +ALTER DEFAULT PRIVILEGES FOR ROLE px_library_owner IN SCHEMA chalk_library GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO px_library_app; +ALTER DEFAULT PRIVILEGES FOR ROLE px_library_owner IN SCHEMA chalk_library GRANT USAGE, SELECT ON SEQUENCES TO px_library_app; +-- tables the owner made before these defaults existed: +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA chalk_library TO px_library_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA chalk_library TO px_library_app; +``` -Nothing outside that schema is touched. Search uses trigram similarity on titles, file names and tags, which works +`pg_trgm` can be installed once per database, in one schema: put it in the library's schema (the server finds it +through `search_path`). If another app of the same database already installed it in `public`, that works too. +The `user`, `key` and `signing-key` commands follow `MIGRATE_ON_START` like the server, so they run with either +login. Search uses trigram similarity on titles, file names and tags, which works in any script, including Bengali (no Bengali stemmer exists). ## S3-compatible storage @@ -239,17 +266,19 @@ needs `GetObject` on the bucket, which it already has (it reads uploads back to ```bash chalk-library serve # the API (the default) +chalk-library migrate # create the schema and apply the migrations, then exit (the release step with MIGRATE_ON_START=false) chalk-library scan [prefix] # adopt media already in the storage; again adds only what is new chalk-library check --token T [--admin-token A] # conformance: does a server do what the registry API says? chalk-library check --user U [--admin-token A] # the same, signed in (password: CHALK_LIBRARY_PASSWORD, a prompt or stdin) chalk-library user add --roles reader # users (password: a prompt without echo, or the first line of stdin) chalk-library user list | disable | enable | password -chalk-library key add --roles author # API keys for machines; the key is printed once +chalk-library key add --roles author [--act-for] # API keys for machines; the key is printed once; --act-for: it may send Chalk-Actor +chalk-library key act-for on|off # allow or stop a key acting for people chalk-library key list | revoke chalk-library signing-key rotate # a new token signing key ``` -`user`, `key` and `signing-key` need only `DATABASE_URL` (and `DB_SCHEMA`), not the storage settings. +`migrate`, `user`, `key` and `signing-key` need only `DATABASE_URL` (and `DB_SCHEMA`), not the storage settings. From the repository use `npx tsx packages/library-server/src/cli.ts `. From a build use `node dist/server.mjs `. @@ -331,6 +360,26 @@ seconds (30) of its use, the same token gives the same new refresh token again, used, so a client whose answer was lost stays signed in. `POST /v1/auth/revoke {"refresh_token"}` signs out. `libraryAuth` in `@chalk-format/store` does all of this for a browser or an app (`signIn`, `token()`, `signOut`). +### Acting for a person (Chalk-Actor) + +An app whose people sign in to the app itself (a staff dashboard) can call the library with one API key and say on +each request who it acts for, so the library's records name the person ([registry API +§10.7](../../spec/1.2/registry-api.md#107-acting-for-a-person)): + +```bash +chalk-library key add dashboard --roles admin --act-for # or POST /v1/admin/keys {"name","roles","actFor":true}; PATCH /v1/admin/keys/{id} {"actFor"} later +``` + +The app's server sends `Chalk-Actor: base64url(JSON.stringify({ id: 'staff:', name: 'Rita Akter' }))` with +the key. The library then records the `id` wherever it records who did something (`uploaded_by`; the highlights' +`reviewed_by` and `rects_by`, in place of the body's `by`; the key that made a key), `?mine=1` means that person, +and the table `actors` keeps each id's latest name (`GET /v1/admin/actors`). `GET /v1/auth/me` answers +`actingFor`. The header from any other caller is refused (`403 act_for_not_allowed`), a malformed one is `400`. +Without the header the key is recorded under its own name. + +Such a key can write as anyone it names, so it stays on the app's server. With `HUMAN_ACCOUNTS=off` the library has +no accounts of its own at all: only such keys, machine keys and `TOKENS`. + ### Making a library private (PUBLIC_READ=false) Turning reading off breaks every reader that has no credential, so do it in this order: diff --git a/packages/library-server/.env.example b/packages/library-server/.env.example index d7733af..76ac5b8 100644 --- a/packages/library-server/.env.example +++ b/packages/library-server/.env.example @@ -1,6 +1,9 @@ # A Chalk library server. Copy to .env (never commit .env). See src/config.ts for every setting. DATABASE_URL=postgres://user:password@host:5432/database?sslmode=require DB_SCHEMA=chalk_library +# false: the server changes nothing in the database at start and refuses a schema that is not current; run +# `chalk-library migrate` with the schema's owner login as a release step (the server's own login then needs row rights only) +MIGRATE_ON_START=true STORAGE=s3 # Cloudflare R2 (any S3-compatible service works: MinIO, AWS S3, Backblaze B2, …) S3_ENDPOINT=https://.r2.cloudflarestorage.com @@ -15,6 +18,8 @@ SERVER_URL=https://library.example.org # bootstrap tokens: name:token, comma separated; a plain name is an author, a name ending in ! an administrator. # Users and API keys: chalk-library user add … / key add … (see docs/guides/run-a-library-server.md) TOKENS=teacher:change-me,admin!:change-me-too +# off: no user accounts (no sign-in, refresh or user admin); API keys (also ones acting for people: key add --act-for) and TOKENS only +HUMAN_ACCOUNTS=on # encrypts the token signing keys in the database; required once there are users or PUBLIC_READ=false. # openssl rand -base64 32 (the CLI needs the same value) AUTH_KEY_SECRET= diff --git a/packages/library-server/README.md b/packages/library-server/README.md index 75eef8d..0455c8d 100644 --- a/packages/library-server/README.md +++ b/packages/library-server/README.md @@ -19,14 +19,20 @@ Users and API keys (the library issues its own tokens; spec 1.2 registry API §1 ```bash DATABASE_URL=pglite:./.data/db npx tsx src/cli.ts user add alice --roles admin # asks for a password DATABASE_URL=pglite:./.data/db npx tsx src/cli.ts key add ci --roles author # prints the key once +DATABASE_URL=pglite:./.data/db npx tsx src/cli.ts key add dashboard --roles admin --act-for # may send Chalk-Actor (acts for its people) +DATABASE_URL=postgres://owner… npx tsx src/cli.ts migrate # the release step when MIGRATE_ON_START=false ``` +An app's server can act for its signed-in people with a key made `--act-for`: it sends `Chalk-Actor` (base64url +JSON `{"id": "staff:", "name"}`), and the library records the person's id (registry API §10.7). `HUMAN_ACCOUNTS=off` +runs the library with keys and `TOKENS` only (§10.8). + (PGlite allows one process at a time: stop `serve` first, or use the admin API.) Docker, from the repository root: `docker build -f packages/library-server/Dockerfile -t chalk-library-server .` It also keeps the **highlights** of books (registry API §11): the lines of a book's text and the questions linked to them, as records an editor and a matcher change one by one (`v1/books//lines`, `…/links`), so reading apps get them from the library. They are migration step 5 of its database (two tables, `book_lines` and `book_links`), applied -when the server starts. +when the server starts (or by `chalk-library migrate` with `MIGRATE_ON_START=false`). Every setting, Postgres and S3 setup, bucket CORS, deploying and security: [docs/guides/run-a-library-server.md](../../docs/guides/run-a-library-server.md). Using libraries in the From e381ca77c5e112eb6023daf3fd0db953d811f96c Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 08:04:55 +0000 Subject: [PATCH 07/13] fix(library-server): public /v1/files redirect kept a day, never to an own /files/, never for SVG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The redirect to PUBLIC_URL is kept public, max-age=86400 (was a week), so a withdrawal reaches browsers within a day; adopted files stay 300 s. - Whether PUBLIC_URL is a public domain is decided from the address: a /files/ path (the default when PUBLIC_URL is unset, even without SERVER_URL, which redirected to http://localhost:/files/) pre-signs. - An SVG is pre-signed as an attachment (needsAttachment in storage.ts), never sent to a public address that would open it inline. - Tests: libraryFromEnv with PUBLIC_URL and SERVER_URL unset and with an own https://host/files/, the SVG case, an adopted path that needs encoding, and the public library's redirect while an adopted file moves. - Spec §7/§8: how long a withdrawal takes to reach readers, and the CDN purge. Co-Authored-By: Claude Opus 5.5 --- packages/library-server/src/adopt.test.ts | 69 +++++++++++++++++++---- packages/library-server/src/delivery.ts | 32 ++++++----- packages/library-server/src/storage.ts | 9 ++- spec/1.2/registry-api.md | 11 +++- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/packages/library-server/src/adopt.test.ts b/packages/library-server/src/adopt.test.ts index 7743ed6..12dde80 100644 --- a/packages/library-server/src/adopt.test.ts +++ b/packages/library-server/src/adopt.test.ts @@ -537,7 +537,14 @@ describe('adopting on S3: a public library behind a CDN, and a private one', () vi.setSystemTime(Date.now() + 2000); objects.set(B, bytes); types.set(B, 'image/png'); await createRegistryClient(LIB, { token: 't-1' }).adopt({ ...s, type: 'image/png', size: bytes.byteLength, path: B }); - const at = async () => new URL((await fetch(`${PRIV}/v1/files/${s.key}`, { headers: bearer(), redirect: 'manual' })).headers.get('location')!).pathname; + // the public library sends readers to the copy at the CDN (a redirect kept 5 minutes, as long as a check may take); the private one pre-signs it + const at = async () => { + const pub = (await fetch(`${LIB}/v1/files/${s.key}`, { redirect: 'manual' })).headers, priv = await fetch(`${PRIV}/v1/files/${s.key}`, { headers: bearer(), redirect: 'manual' }); + expect(pub.get('cache-control')).toBe('public, max-age=300'); + const path = pub.get('location')!.slice(`${CDN}/`.length); + expect(new URL(priv.headers.get('location')!).pathname).toBe(`/books/${path}`); + return `/books/${path}`; + }; const entry = async () => ((await (await fetch(`${LIB}/index/${s.key.slice(3, 5)}.json`)).json()) as { entries: Record }).entries[s.key.slice(3)]!.p; // B is pruned first this time; within the check interval nothing is HEADed and B is still named objects.delete(B); @@ -565,13 +572,13 @@ describe('adopting on S3: a public library behind a CDN, and a private one', () expect(await book.urlFor(want.assets[3]!.id)).toBe(`${CDN}/chalk-books/s3-book/assets/${want.assets[3]!.id.replace(':', '-')}`); }, 60_000); - it('an uploaded key: a public library redirects /v1/files to the CDN\'s standard path, kept a week; a private one pre-signs the bucket', async () => { + it('an uploaded key: a public library redirects /v1/files to the CDN\'s standard path, kept a day; a private one pre-signs the bucket', async () => { const bytes = new TextEncoder().encode('uploaded, then read through the CDN'); const rec = await createRegistryClient(LIB, { token: 't-1' }).add(bytes, { type: 'image/png', title: 'cdn' }); const r = await fetch(`${LIB}/v1/files/${rec.key.slice(3)}`, { redirect: 'manual' }); expect(r.status).toBe(302); expect(r.headers.get('location')).toBe(`${CDN}/${standardPath(rec.key)}`); - expect(r.headers.get('cache-control')).toBe('public, max-age=604800'); + expect(r.headers.get('cache-control')).toBe('public, max-age=86400'); expect(r.headers.get('vary') ?? '').not.toMatch(/authorization/i); log.length = 0; expect(text(await (await fetch(`${LIB}/v1/files/${rec.key}`)).arrayBuffer())).toBe(text(bytes)); @@ -587,15 +594,53 @@ describe('adopting on S3: a public library behind a CDN, and a private one', () expect([gone.status, gone.headers.get('location')]).toEqual([410, null]); }); - it('a public S3 library without a public domain (PUBLIC_URL left at its own /files/) still pre-signs', async () => { - const own = createLibraryApp({ db, storage: s3Storage({ endpoint: S3, bucket: 'books', ...CREDS }), publicUrl: 'https://lib.example.org/files/', serverUrl: 'https://lib.example.org', callers: tokenCallers('teacher:t-1') } as Parameters[0]); - const bytes = new TextEncoder().encode('no public domain'), k2 = await assetKeyAndSha256(bytes), path = standardPath(k2.key); - objects.set(path, bytes); - await db.query("INSERT INTO assets (key, sha256, type, size, status) VALUES ($1, $2, 'image/png', $3, 'available')", [k2.key, k2.sha256, bytes.byteLength]); - const r = await own.fetch(new Request(`https://lib.example.org/v1/files/${k2.key}`)); - expect(r.status).toBe(302); - expect(new URL(r.headers.get('location')!).origin).toBe(S3); - expect(r.headers.get('cache-control')).toBe('private, max-age=240'); + it('a public S3 library without a public domain still pre-signs: PUBLIC_URL and SERVER_URL unset, or PUBLIC_URL its own https://host/files/', async () => { + const bytes = new TextEncoder().encode('no public domain'), k2 = await assetKeyAndSha256(bytes); + objects.set(standardPath(k2.key), bytes); + const s3env: Record = { STORAGE: 's3', S3_ENDPOINT: S3, S3_BUCKET: 'books', S3_ACCESS_KEY_ID: CREDS.accessKeyId, S3_SECRET_ACCESS_KEY: CREDS.secretAccessKey, PORT: '8099' }; + for (const env of [s3env, { ...s3env, PUBLIC_URL: 'https://lib.example.org/files/', SERVER_URL: 'https://lib.example.org' }, { ...s3env, PUBLIC_URL: 'https://lib.example.org/files' }]) { + const o = await libraryFromEnv(env); + try { + expect(o.publicRead).toBe(true); + await o.db.query("INSERT INTO assets (key, sha256, type, size, status) VALUES ($1, $2, 'image/png', $3, 'available')", [k2.key, k2.sha256, bytes.byteLength]); + const r = await createLibraryApp(o).fetch(new Request(`https://lib.example.org/v1/files/${k2.key}`)); + expect(r.status, env.PUBLIC_URL ?? 'unset').toBe(302); + expect(new URL(r.headers.get('location')!).origin).toBe(S3); + expect(r.headers.get('location')).not.toContain('localhost'); + expect(r.headers.get('cache-control')).toBe('private, max-age=240'); + } finally { await o.db.close(); } + } + // a public domain set explicitly is used + const o = await libraryFromEnv({ ...s3env, PUBLIC_URL: 'https://assets.example.org/' }); + try { + await o.db.query("INSERT INTO assets (key, sha256, type, size, status) VALUES ($1, $2, 'image/png', $3, 'available')", [k2.key, k2.sha256, bytes.byteLength]); + expect((await createLibraryApp(o).fetch(new Request(`https://lib.example.org/v1/files/${k2.key}`))).headers.get('location')).toBe(`https://assets.example.org/${standardPath(k2.key)}`); + } finally { await o.db.close(); } + }, 60_000); + + it('an SVG is never sent to the public address (it would open there inline, script and all): pre-signed as an attachment', async () => { + const svg = new TextEncoder().encode(''); + const rec = await createRegistryClient(LIB, { token: 't-1' }).add(svg, { type: 'image/svg+xml', title: 'svg' }); + for (const type of ['image/svg+xml', ' IMAGE/SVG+XML; charset=utf-8']) { + await db.query('UPDATE assets SET type = $2 WHERE key = $1', [rec.key, type]); + const r = await fetch(`${LIB}/v1/files/${rec.key}`, { redirect: 'manual' }); + expect(r.status).toBe(302); + const u = new URL(r.headers.get('location')!); + expect(u.origin).toBe(S3); + expect(u.searchParams.get('response-content-disposition')).toMatch(/^attachment/); + expect(r.headers.get('cache-control')).toBe('private, max-age=240'); + } + }); + + it('an adopted path that needs encoding: the redirect names it encoded, and the CDN serves the bytes', async () => { + const bytes = new TextEncoder().encode('a path with a space, a hash and Bangla'), a = await assetKeyAndSha256(bytes); + const path = 'chalk-books/chem-10/assets/পরমাণু model #2 (v1)?.png'; + objects.set(path, bytes); types.set(path, 'image/png'); + expect((await createRegistryClient(LIB, { token: 't-1' }).adopt({ ...a, type: 'image/png', size: bytes.byteLength, path })).status).toBe('available'); + const r = await fetch(`${LIB}/v1/files/${a.key}`, { redirect: 'manual' }); + expect(r.headers.get('location')).toBe(`${CDN}/${path.split('/').map(encodeURIComponent).join('/')}`); + expect(r.headers.get('location')).toContain('%20%232%20(v1)%3F.png'); + expect(text(await (await fetch(`${LIB}/v1/files/${a.key}`)).arrayBuffer())).toBe(text(bytes)); }); it('withdrawing deletes only the standard path, never the adopted object', async () => { diff --git a/packages/library-server/src/delivery.ts b/packages/library-server/src/delivery.ts index da77cab..c3374c4 100644 --- a/packages/library-server/src/delivery.ts +++ b/packages/library-server/src/delivery.ts @@ -6,7 +6,7 @@ import { standardPath } from '@chalk-format/store'; import { assetAt, locate } from './adopt.js'; import type { LibraryOptions } from './app.js'; import { requireRole, type AuthVariables } from './auth/guard.js'; -import { contentDisposition, servedType, type DiskStorage } from './storage.js'; +import { contentDisposition, needsAttachment, servedType, type DiskStorage } from './storage.js'; /** * File bytes for signed-in readers (spec/1.2/registry-api.md §7.1). With PUBLIC_READ=false the bucket is private @@ -21,7 +21,9 @@ import { contentDisposition, servedType, type DiskStorage } from './storage.js'; * routes work too, open to anyone like the rest of a public library's reading. On S3, `/v1/files/{key}` then * redirects to the file under PUBLIC_URL (the bucket's public domain, behind a CDN), where the store already * names it, instead of pre-signing the bucket's own endpoint: the bytes come from the CDN's cache, and the - * redirect is kept (a week; 5 minutes for a file adopted in place). + * redirect is kept (a day; 5 minutes for a file adopted in place). Not for an SVG (a public address sends it + * inline, where its script would run on that domain: it is pre-signed as an attachment), and not when + * PUBLIC_URL is a server's own `/files/` (the default when it is not set; only a disk library serves that). * * Code is never served as a page: HTML and JavaScript go out as `application/octet-stream` (S3: fixed in the * signature with `response-content-type`), and the server's own answers add `Content-Security-Policy: sandbox` @@ -36,11 +38,11 @@ export type DeliveryOptions = { export const FILE_SECONDS = 5 * 60; export const FILE_REDIRECT_CACHE = 'private, max-age=240'; /** - * A public library's redirect to PUBLIC_URL. A key's bytes never change, so the redirect to the standard path is kept a week by anyone (a - * withdrawn key's URL then 404s once its object is deleted and the CDN purged). A file adopted in place may move to another copy within - * minutes (adopt.ts), so that redirect is kept 5 minutes. + * A public library's redirect to PUBLIC_URL. A key's bytes never change, so the redirect to the standard path is kept a day by anyone. That + * is also how long a browser may still follow it after the key is withdrawn (the file itself then 404s once its object is deleted and the + * CDN purged). A file adopted in place may move to another copy within minutes (adopt.ts), so that redirect is kept 5 minutes. */ -export const PUBLIC_REDIRECT_CACHE = 'public, max-age=604800'; +export const PUBLIC_REDIRECT_CACHE = 'public, max-age=86400'; export const ADOPTED_REDIRECT_CACHE = 'public, max-age=300'; /** A link for places that cannot send a header (the contract allows 5 to 15 minutes; a video plays from it). */ export const LINK_SECONDS = 15 * 60; @@ -68,12 +70,8 @@ export function registerDelivery(app: Hono<{ Variables: AuthVariables }>, o: Lib return { key, path: await locate(o.db, o.storage, key), type: row.type }; // where the bytes are: the standard path, or where they were adopted }; const base = (c: Context) => baseOf(c, o.serverUrl); - /** - * Where a public S3 library sends `/v1/files` readers: PUBLIC_URL. Not when PUBLIC_URL is this server's own `/files/` (the default when - * it is not set), which only a disk library serves; that library answers with the bytes itself. - */ - const ownFiles = o.serverUrl && o.publicUrl.startsWith(`${o.serverUrl.replace(/\/+$/, '')}/files/`); - const cdn = publicRead && !disk && !ownFiles ? o.publicUrl.replace(/\/?$/, '/') : null; + /** Where a public S3 library sends `/v1/files` readers: PUBLIC_URL, unless it is a library's own `/files/` (see above). */ + const cdn = publicRead && !disk && !ownFiles(o.publicUrl) ? o.publicUrl.replace(/\/?$/, '/') : null; // ---- the store readers use: a private library's names this server, with a token ---- app.get('/v1/stores', async (c, next) => { @@ -93,7 +91,7 @@ export function registerDelivery(app: Hono<{ Variables: AuthVariables }>, o: Lib } // a public library: its bytes are already public at PUBLIC_URL (the store /v1/stores names), so send the reader there, where a CDN // keeps them, and let the redirect be kept too. A private library never does (its bucket is not public; PUBLIC_URL may still answer) - if (cdn) { + if (cdn && !needsAttachment(a.type)) { const std = a.path === standardPath(a.key); return c.body(null, 302, { Location: `${cdn}${a.path.split('/').map(encodeURIComponent).join('/')}`, 'Cache-Control': std ? PUBLIC_REDIRECT_CACHE : ADOPTED_REDIRECT_CACHE }); } @@ -137,6 +135,14 @@ export function registerDelivery(app: Hono<{ Variables: AuthVariables }>, o: Lib }); } +/** + * True when PUBLIC_URL is a library server's own `/files/` (what PUBLIC_URL defaults to when it is not set: `/files/`, or + * `http://localhost:/files/` without SERVER_URL), decided from the address alone. Only a disk library serves that path. + */ +export function ownFiles(publicUrl: string) { + try { return new URL(publicUrl).pathname.replace(/\/?$/, '/') === '/files/'; } catch { return true; } +} + /** This server's public address: SERVER_URL, else the one the request reached (only X-Forwarded-Proto is taken from the request, a proxy's TLS). */ export function baseOf(c: Context, serverUrl: string | undefined) { if (serverUrl) return serverUrl.replace(/\/+$/, ''); diff --git a/packages/library-server/src/storage.ts b/packages/library-server/src/storage.ts index 4259ab9..291db0c 100644 --- a/packages/library-server/src/storage.ts +++ b/packages/library-server/src/storage.ts @@ -46,6 +46,11 @@ export function servedType(type: string): string { // (a pre-signed URL makes it a download, see presignGet; disk answers send CSP: sandbox) return t.endsWith('+xml') && t !== 'image/svg+xml' ? 'application/octet-stream' : t; } +/** + * True for a type a browser could open as a page with script although it is served as itself: SVG. Such a file is shown by an `` but + * must reach a reader only with `Content-Disposition: attachment` (a pre-signed URL fixes that), never from a public address that sends none. + */ +export const needsAttachment = (type: string) => servedType(type) === 'image/svg+xml'; /** `inline` with the file name, RFC 6266 / 5987 (any script). */ export const contentDisposition = (filename: string) => `inline; filename*=UTF-8''${encodeURIComponent(filename.replace(/[\r\n"\\/]/g, '_'))}`; /** S3 accepts 1 s to 7 days for a pre-signed URL. */ @@ -95,8 +100,8 @@ export function s3Storage(o: S3Options): Storage { u.searchParams.set('response-cache-control', 'private'); u.searchParams.set('response-expires', expiresAt.toUTCString()); // an SVG may hold script: shown by an , never opened as a page on the bucket's origin (a bucket sends no CSP) - const disposition = g.filename ? contentDisposition(g.filename) : type === 'image/svg+xml' ? 'inline' : null; - if (disposition) u.searchParams.set('response-content-disposition', type === 'image/svg+xml' ? disposition.replace(/^inline/, 'attachment') : disposition); + const attach = needsAttachment(type), disposition = g.filename ? contentDisposition(g.filename) : attach ? 'inline' : null; + if (disposition) u.searchParams.set('response-content-disposition', attach ? disposition.replace(/^inline/, 'attachment') : disposition); const signed = await aws.sign(new Request(u, { method: 'GET' }), { aws: { signQuery: true } }); return { url: signed.url, expiresAt: expiresAt.toISOString() }; }, diff --git a/spec/1.2/registry-api.md b/spec/1.2/registry-api.md index d9968c7..0ec80fc 100644 --- a/spec/1.2/registry-api.md +++ b/spec/1.2/registry-api.md @@ -172,8 +172,11 @@ stores. It is public and cacheable. A registry whose bytes are public MAY also answer **`GET v1/files/{key}`** without a token (for apps that link one file by key, such as an ``), with a `302` to the file in the store it names here, which the redirect may let -any cache keep (the reference: `public, max-age=604800`, as a key's bytes never change). It MUST NOT redirect a -private registry's readers (§7.1) to a public address. +any cache keep (the reference: `public, max-age=86400`, as a key's bytes never change). It MUST NOT redirect a +private registry's readers (§7.1) to a public address, and SHOULD NOT redirect a file a browser could open there as +a page with script (SVG; the reference pre-signs it as an attachment instead). A withdrawal (§8) reaches a reader +only once the redirect it kept and the file its browser kept have expired, and a CDN in front of the store keeps +the bytes until it is purged. ### 7.1 A private library's files @@ -205,7 +208,9 @@ How tokens are issued is in §10; a token "may read" when it has the `reader` ro ## 8. Withdrawing an asset **`POST v1/assets/{key}/withdraw`** `{ "reason" }` (privileged) sets `status: "withdrawn"`. From then on the -registry's API store answers `410 Gone` for the key, and stores it controls SHOULD delete the bytes. The +registry's API store answers `410 Gone` for the key, and stores it controls SHOULD delete the bytes (a cache in +front of a public store keeps them until purged; caches it allowed to keep a redirect or a file, §7, keep them +for as long as it allowed). The record is kept, so the same key cannot be uploaded again by mistake (`gone`). An adopted file (§3.1) belongs to whoever wrote it: withdrawing removes it from every store and index the registry From 77191a2094db41e623cf883283eeb67f4fef4f52 Mon Sep 17 00:00:00 2001 From: sawradip Date: Wed, 11 Nov 2026 08:04:55 +0000 Subject: [PATCH 08/13] docs(library-server): a day of browser cache, withdrawal lifetimes, optional sandbox headers Co-Authored-By: Claude Opus 5.5 --- packages/library-server/README.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/library-server/README.md b/packages/library-server/README.md index 19e2403..3f5d9c4 100644 --- a/packages/library-server/README.md +++ b/packages/library-server/README.md @@ -33,11 +33,12 @@ when the server starts. A public library (`PUBLIC_READ` unset or `true`) names its bucket's public address in `GET /v1/stores` (`PUBLIC_URL` + `c1/{h0}/{h1}/{h}`), and readers fetch files there. On S3 storage, `GET /v1/files/{key}` (the address apps use for a single file: an `` poster, a lab's code) answers `302` to the same file under `PUBLIC_URL`, -kept `public, max-age=604800` (a key's bytes never change; a file adopted in place, which may move to another -copy, `public, max-age=300`); `410` for a withdrawn key, `404` for an unknown one. A private library -(`PUBLIC_READ=false`) never sends anyone to `PUBLIC_URL`: it pre-signs the bucket (5 minutes, -`private, max-age=240`). Nor does a public one whose `PUBLIC_URL` is left at its own `/files/` (only a disk -library serves that). Those settings for the reference deployment: `STORAGE=s3`, +kept `public, max-age=86400` (a key's bytes never change; a file adopted in place, which may move to another +copy, `public, max-age=300`); `410` for a withdrawn key, `404` for an unknown one. It pre-signs the bucket instead +(5 minutes, `private, max-age=240`, as a private library always does) for an SVG, which a public address would +send inline so that its script runs on that domain (pre-signed, it is an attachment), and when `PUBLIC_URL` is not +set or is a server's own `/files/` (only a disk library serves that path). A private library +(`PUBLIC_READ=false`) never sends anyone to `PUBLIC_URL`. Those settings for the reference deployment: `STORAGE=s3`, `PUBLIC_URL=https://assets.pathshalax.com/` (trailing slash), `SERVER_URL=https://library.pathshalax.com`. For the bytes to come from Cloudflare's edge rather than from the bucket each time, on Cloudflare (R2 objects carry @@ -52,7 +53,8 @@ no `Cache-Control`, so without a rule a custom domain answers `cf-cache-status: ``` *Eligible for cache*; Edge TTL *Ignore cache-control header and use this TTL* → 1 year, with a status code TTL `404` → *No cache* (a key asked for before its upload finished, or adopted elsewhere, must not stay missing; - without status code TTLs on the plan, use 1 day); Browser TTL *Override origin* → 1 year; Cache key + without status code TTLs on the plan, use 1 day); Browser TTL *Override origin* → 1 day (a browser cannot be + purged: this is how long it may keep a file that was withdrawn); Cache key *Ignore query string*. Only `c1/` is matched: files adopted in place (`chalk-books/…`) can be replaced by the tool that wrote them, so they are left to Cloudflare's defaults. 3. **CORS** (the bucket → Settings → CORS): readers fetch from the browser, sometimes with `Range`. @@ -63,13 +65,20 @@ no `Cache-Control`, so without a rule a custom domain answers `cf-cache-status: Keep `*` rather than a list of origins: with a list the bucket answers per origin (`Vary: Origin`), which Cloudflare's cache ignores, so one site's answer would be served to another. Authors' uploads go to the bucket's S3 endpoint (pre-signed `PUT`), not through this domain; their CORS rule is the one in the guide below. -4. **Withdrawing** a key deletes its object, but the edge keeps a copy: purge it too (Caching → Configuration → - *Custom purge* → URL `https://assets.example.org/c1//

/`). +4. **Withdrawing** a key deletes its object, but the edge keeps a copy for its 1-year TTL: purge it too (Caching → + Configuration → *Custom purge* → URL `https://assets.example.org/c1//

/`). Browsers are not reached + by a purge: one that fetched the file may show it for up to a day more (Browser TTL), and one that followed + `/v1/files` may follow the kept redirect for up to a day (5 minutes for a file adopted in place), after which it + gets the `410`. +5. **Optional, defence in depth** (Rules → Transform Rules → *Modify Response Header*, when + `http.host eq "assets.example.org"`): set `Content-Security-Policy: sandbox` and + `X-Content-Type-Options: nosniff`, so that nothing the bucket holds can ever run as a page on that domain + (``, `