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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
42 changes: 40 additions & 2 deletions src/routes/wellknown.ts
Original file line number Diff line number Diff line change
@@ -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<AppEnv> {
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<AppEnv> {
const app = new Hono<AppEnv>();

app.get('/stack', (c) => {
Expand All @@ -27,6 +48,23 @@ export function wellknownRoutes(ctx: StackContext, config: Config): Hono<AppEnv>
// 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);
});
Expand Down
59 changes: 49 additions & 10 deletions tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> }).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<AppEnv>();
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(),
);
});
});
Expand Down
34 changes: 33 additions & 1 deletion tests/routes/wellknown.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<AppEnv>();
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,
});
});
});
Loading