diff --git a/docs/api.md b/docs/api.md index e59c4aa..32d226e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,12 +22,16 @@ All routes are prefixed by the base URL. Requests are authenticated with a `Bear "sortableFields": ["createdAt", "updatedAt", "version"], "maxAttachmentBytes": 52428800, "maxContentBytes": 1048576 - } + }, + "auth": { "methods": ["did-challenge"] }, + "changes": { "transports": ["sse"], "resume": false, "records": true } } ``` `version` is the wire protocol's own `MAJOR.MINOR` version (from `@haverstack/wire-types`' `WIRE_PROTOCOL_VERSION`), not this server's software version — a client refuses to `open()` a server whose major differs from its own; a minor difference is never a refusal in either direction. `timezone` is present only when the stack was configured with one — there is no default. `capabilities.maxAttachmentBytes` and `maxContentBytes` are this server's own enforced ceilings (413 past either), letting a client pre-check and get a typed error instead of burning a round trip. `auth: { methods: ["did-challenge"] }` is always present — this server always implements the DID challenge-response handshake described below. +`changes` is a top-level field, not part of `capabilities` — a client checks it and fails locally at `open()` rather than discovering a missing feed as a 404 partway through a connection. `transports` lists what it speaks (`sse` is the only one this version defines); `resume` is `false` until cursors exist; `records` is `true` because `GET /changes` already honors `?include=record` unconditionally. Both `resume` and `records` false is also a fully conformant response — see [Change feed](#change-feed) below. + ## Authentication | Method | Path | Auth | Description | diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts index 480a1d9..cd9740c 100644 --- a/src/routes/wellknown.ts +++ b/src/routes/wellknown.ts @@ -1,11 +1,32 @@ import { Hono } from 'hono'; -import { WIRE_PROTOCOL_VERSION, AUTH_METHOD_DID_CHALLENGE } from '@haverstack/wire-types'; +import { + WIRE_PROTOCOL_VERSION, + AUTH_METHOD_DID_CHALLENGE, + CHANGE_TRANSPORT_SSE, +} from '@haverstack/wire-types'; import type { DiscoveryResponse } from '@haverstack/wire-types'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import type { Config } from '../config.js'; -export function wellknownRoutes(ctx: StackContext, config: Config): Hono { +export type WellknownRouteOptions = { + /** + * Whether `?include=record` is honored on `GET /changes`. Always `true` + * in production — `src/routes/changes.ts` honors it unconditionally, so + * there's no real deployer lever here. This exists only so a test can + * exercise the `records: false` branch of the wire contract (both flags + * false is fully conformant — see docs/spec/wire-format.md § Change + * feed) without `src/routes/changes.ts` growing a matching toggle it + * doesn't otherwise need. + */ + changeFeedRecords?: boolean; +}; + +export function wellknownRoutes( + ctx: StackContext, + config: Config, + opts: WellknownRouteOptions = {}, +): Hono { const app = new Hono(); app.get('/stack', (c) => { @@ -27,6 +48,23 @@ export function wellknownRoutes(ctx: StackContext, config: Config): Hono // credential learns at open() that there's a handshake to perform, // rather than discovering it as a 404 partway through one. auth: { methods: [AUTH_METHOD_DID_CHALLENGE] }, + // A top-level field, not part of `capabilities` above — it does not + // come along with the `...ctx.stack.features` spread the way the + // adapter's own capabilities do, so it's added explicitly. An object + // rather than a boolean for the same reason `auth` is: the surface + // grows entries (another transport, batched frames) rather than + // gaining a second and third boolean alongside it. Advertise what's + // true, not what's aspirational — a client that calls + // subscribeChanges() against a server advertising no feed fails + // locally at open(), which is strictly better than discovering a 404 + // partway through a connection. `resume: false` until #84 mints + // cursors; `records: true` because `GET /changes` already honors + // `?include=record` unconditionally (#82). + changes: { + transports: [CHANGE_TRANSPORT_SSE], + resume: false, + records: opts.changeFeedRecords ?? true, + }, }; return c.json(body); }); diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index f717831..214ed98 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -20,6 +20,7 @@ * fixture this file hasn't been told about — that's what makes this an * acceptance gate rather than a snapshot of today's fixture list. */ +import { Hono } from 'hono'; import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import { discoveryFixtures, @@ -48,8 +49,17 @@ import { import type { WireRecord } from '@haverstack/wire-types'; import { generateId, hashSchema } from '@haverstack/core'; import type { Association } from '@haverstack/core'; -import { buildTestApp, req, TEST_TOKEN, TEST_ENTITY_ID, type TestApp } from './setup.js'; +import { + buildTestApp, + req, + testConfig, + TEST_TOKEN, + TEST_ENTITY_ID, + type TestApp, +} from './setup.js'; import { openChangeFeed, type DecodedFrame } from './changeFeedClient.js'; +import { wellknownRoutes } from '../src/routes/wellknown.js'; +import type { AppEnv } from '../src/types.js'; /** * Fixture ids embed a fixed, long-past timestamp (they were authored once @@ -159,19 +169,48 @@ describe('discovery fixtures', () => { expect((data as { auth?: { methods: string[] } }).auth).toEqual(fixture.responseBody!.auth); }); + test('discovery-advertises-a-change-feed', async () => { + const fixture = discoveryFixtures.find((f) => f.name === 'discovery-advertises-a-change-feed')!; + handled.add(fixture.name); + const { status, data } = await req(t.app, fixture.method, fixture.path); + expect(status).toBe(fixture.responseStatus); + const changes = (data as { changes?: Record }).changes; + expect(changes).toBeDefined(); + expect(changes!.transports).toEqual(['sse']); + // resume/records mirror this server's own actual support, not the + // fixture's illustrative `resume: true` — #84 flips resume once cursors + // exist. This server already honors `?include=record` (#82), so records + // is true. + expect(changes!.resume).toBe(false); + expect(changes!.records).toBe(true); + }); + + test('discovery-advertises-a-feed-that-neither-resumes-nor-includes-records', async () => { + const fixture = discoveryFixtures.find( + (f) => f.name === 'discovery-advertises-a-feed-that-neither-resumes-nor-includes-records', + )!; + handled.add(fixture.name); + // Both flags false is fully conformant, but this server always honors + // `?include=record` — it has no real toggle for it (see + // WellknownRouteOptions in src/routes/wellknown.ts). Build a standalone + // app that passes the test-only override directly, the same way + // tests/routes/changes.test.ts bypasses createApp() to pass + // ChangeRouteOptions createApp() has no way to thread through. + const app = new Hono(); + app.route( + '/.well-known', + wellknownRoutes(t.ctx, testConfig(t.dbPath), { changeFeedRecords: false }), + ); + const { status, data } = await req(app, fixture.method, fixture.path); + expect(status).toBe(fixture.responseStatus); + expect((data as { changes?: unknown }).changes).toEqual(fixture.responseBody!.changes); + }); + test('coverage', () => { assertCoverage( discoveryFixtures.map((f) => f.name), handled, - new Set([ - // This server has no change feed yet — no src/routes/changes.ts, no - // `changes` key in discovery. Advertising one before GET /changes - // exists would be worse than not advertising it at all (clients call - // supportsChangeFeed() and fail locally when absent). Land with #82 - // (GET /changes) and #83 (discovery advertisement) — see #78. - 'discovery-advertises-a-change-feed', - 'discovery-advertises-a-feed-that-neither-resumes-nor-includes-records', - ]), + new Set(), ); }); }); diff --git a/tests/routes/wellknown.test.ts b/tests/routes/wellknown.test.ts index 0c48c5b..fc6062d 100644 --- a/tests/routes/wellknown.test.ts +++ b/tests/routes/wellknown.test.ts @@ -1,6 +1,9 @@ +import { Hono } from 'hono'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { WIRE_PROTOCOL_VERSION } from '@haverstack/wire-types'; -import { buildTestApp, req, TEST_ENTITY_ID, type TestApp } from '../setup.js'; +import { buildTestApp, req, testConfig, TEST_ENTITY_ID, type TestApp } from '../setup.js'; +import { wellknownRoutes } from '../../src/routes/wellknown.js'; +import type { AppEnv } from '../../src/types.js'; describe('GET /.well-known/stack', () => { let t: TestApp; @@ -49,4 +52,33 @@ describe('GET /.well-known/stack', () => { methods: ['did-challenge'], }); }); + + it('advertises the change feed', async () => { + const { data } = await req(t.app, 'GET', '/.well-known/stack'); + expect((data as { changes?: unknown }).changes).toEqual({ + transports: ['sse'], + // Not yet: #84 mints cursors. + resume: false, + // Already: GET /changes honors ?include=record unconditionally (#82). + records: true, + }); + }); + + it('can advertise a feed that neither resumes nor includes records', async () => { + // This server has no real deployer-facing toggle for `records` — GET + // /changes always honors ?include=record. changeFeedRecords is a + // test-only override on wellknownRoutes() itself for exercising the + // (fully conformant) records: false branch of the wire contract. + const app = new Hono(); + app.route( + '/.well-known', + wellknownRoutes(t.ctx, testConfig(t.dbPath), { changeFeedRecords: false }), + ); + const { data } = await req(app, 'GET', '/.well-known/stack'); + expect((data as { changes?: unknown }).changes).toEqual({ + transports: ['sse'], + resume: false, + records: false, + }); + }); });