Skip to content

Commit a8ea502

Browse files
authored
Merge pull request #112 from haverstack/claude/issue-110-plan-ot4zqw
POST /records honors unlistedAt, un-skip four stale conformance fixtures
2 parents 5dac869 + 969fbef commit a8ea502

5 files changed

Lines changed: 212 additions & 11 deletions

File tree

‎docs/api.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ Answers `200` with the updated record — it bumps `version` like any other muta
162162

163163
**`includeUnlisted` is owner-only.** `GET /records`, `POST /records/query`, and `GET /changes` all accept it (excluded by default, like `includeDeleted`), and all three refuse it with `403` for any requester but the owner acting alone — enumeration standing rests on nothing but ownership, so no grant or delegation carries it. On `GET /changes` the refusal happens before the SSE stream opens.
164164

165+
A `POST /records` body may carry `unlistedAt` to create the record already unlisted, so there is no window where it exists and is enumerable before a later `PUT .../unlisted` catches up — presence of the field is what matters; its value is not honored (`unlistedAt` on the response is stamped to the real create time, like `version`). This is **not** owner-only, unlike `createdAt`/`updatedAt`: it's gated the same way the `permissions` field is at create time — refused with `403` for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type.
166+
165167
### The feed transitions
166168

167169
`unlist` and `list` are new change-feed ops (`unlist` maps to `kind: "deleted"`, `list` to `kind: "changed"` — see [Change feed](#change-feed)). Only the `unlist` transition needs special-casing; every other row falls out of checking the record's current `unlistedAt` against the subscriber's `includeUnlisted`:

‎src/routes/records.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
9797
// both on any create (a record body is a whole record), and forwarding
9898
// them unfiltered would turn an ordinary grantee create into a 403, since
9999
// ScopedStack.create() refuses backdating rather than ignoring it.
100+
//
101+
// unlistedAt is different: it's opt-in (only present when a client
102+
// actually wants an unlisted create), so it's forwarded unconditionally
103+
// as the `unlisted` boolean ScopedStack.create() expects — same as
104+
// permissions/associations below. ScopedStack.create() gates it itself
105+
// (mayGrantAccess(), the same rule as the permissions field), refusing
106+
// with 403 rather than silently dropping it.
100107
app.post('/', requireAuth(), async (c) => {
101108
const auth = c.get('auth')!;
102109
const body = await readJson<Record<string, unknown>>(c);
@@ -121,6 +128,7 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
121128
associations: Array.isArray(body.associations)
122129
? (body.associations as Association[])
123130
: undefined,
131+
unlisted: typeof body.unlistedAt === 'string' ? true : undefined,
124132
createdAt,
125133
updatedAt,
126134
});

‎tests/conformance.test.ts‎

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,6 @@ describe('createRecord fixtures', () => {
225225
// same fileId via an attachment association or file-ref field — exercised
226226
// by hand in tests/routes/attachments.test.ts instead of reconstructed here.
227227
'create-attachment-record-non-owner-carve-out-succeeds',
228-
// POST /records does not forward unlistedAt to create(), so an
229-
// unlisted create silently produces a listed record.
230-
'create-record-unlisted',
231228
]);
232229
const handled = new Set<string>();
233230

@@ -305,6 +302,22 @@ describe('createRecord fixtures', () => {
305302
expect((data as Record<string, unknown>).content).toEqual(body.content);
306303
});
307304

305+
test('create-record-unlisted — unlistedAt in the create body suppresses enumeration from create time', async () => {
306+
const fixture = createRecordFixtures.find((f) => f.name === 'create-record-unlisted')!;
307+
handled.add(fixture.name);
308+
const body = withFreshId(fixture.requestBody as WireRecord);
309+
const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
310+
expect(status).toBe(fixture.responseStatus);
311+
const d = data as Record<string, unknown>;
312+
expect(d.id).toBe(body.id);
313+
expect(d.content).toEqual(body.content);
314+
expect(d.version).toBe(1);
315+
// Core stamps unlistedAt to the real create time rather than honoring a
316+
// backdated value from the body it's echoed alongside — presence is
317+
// what this fixture pins, not the literal value.
318+
expect(typeof d.unlistedAt).toBe('string');
319+
});
320+
308321
test('coverage', () => {
309322
assertCoverage(
310323
createRecordFixtures.map((f) => f.name),
@@ -865,9 +878,6 @@ describe('error response fixtures', () => {
865878
// Not implemented by this server: no query-time bound exists to trip.
866879
// A real gap, tracked separately from this harness.
867880
'error-timeout-search-exceeds-server-bound',
868-
// Behaves correctly already — 403 on both query routes — but is not
869-
// dispatched here yet.
870-
'error-permission-denied-includeUnlisted-non-owner',
871881
]);
872882
const handled = new Set<string>();
873883

@@ -958,6 +968,17 @@ describe('error response fixtures', () => {
958968
expectError(status, data, fixture);
959969
});
960970

971+
test('error-permission-denied-includeUnlisted-non-owner', async () => {
972+
const fixture = find('error-permission-denied-includeUnlisted-non-owner');
973+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
974+
const { status, data } = await dispatch(fixture, token);
975+
expectError(status, data, fixture);
976+
// Same refusal on GET /records — includeUnlisted is owner-only on every
977+
// route that accepts it, not just the fixture's own POST /records/query.
978+
const get = await req(t.app, 'GET', '/records?includeUnlisted=true', { token });
979+
expect(get.status).toBe(403);
980+
});
981+
961982
test('error-not-found — write against a nonexistent id', async () => {
962983
const fixture = find('error-not-found');
963984
const { status, data } = await dispatch(fixture, TEST_TOKEN);
@@ -1120,11 +1141,6 @@ describe('changeFeed fixtures', () => {
11201141
// unrecognized frame — there's nothing server-side for this fixture to
11211142
// exercise until a later minor adds one.
11221143
'change-feed-unknown-frame-names-are-ignored',
1123-
// The unlist/list transitions and the suppression of edits made while
1124-
// unlisted all behave correctly already, but are not dispatched here yet.
1125-
'change-feed-unlist-frame-is-a-deleted-kind',
1126-
'change-feed-list-frame-is-a-changed-kind',
1127-
'change-feed-unlisted-record-produces-no-frame-by-default',
11281144
]);
11291145

11301146
test('change-feed-ready-leads-every-connection', async () => {
@@ -1372,6 +1388,82 @@ describe('changeFeed fixtures', () => {
13721388
}
13731389
});
13741390

1391+
test('change-feed-unlist-frame-is-a-deleted-kind', async () => {
1392+
const fixture = changeFeedFixtures.find(
1393+
(f) => f.name === 'change-feed-unlist-frame-is-a-deleted-kind',
1394+
)!;
1395+
handled.add(fixture.name);
1396+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
1397+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1398+
try {
1399+
await conn.waitForFrames(1); // ready
1400+
await req(t.app, 'PUT', `/records/${record.id}/unlisted`, {
1401+
token: TEST_TOKEN,
1402+
body: { unlisted: true },
1403+
});
1404+
const [, frame] = await conn.waitForFrames(2);
1405+
const data = frameData(frame);
1406+
expect(data.kind).toBe('deleted');
1407+
expect(data.op).toBe('unlist');
1408+
expect(data.recordId).toBe(record.id);
1409+
} finally {
1410+
await conn.close();
1411+
}
1412+
});
1413+
1414+
test('change-feed-list-frame-is-a-changed-kind', async () => {
1415+
const fixture = changeFeedFixtures.find(
1416+
(f) => f.name === 'change-feed-list-frame-is-a-changed-kind',
1417+
)!;
1418+
handled.add(fixture.name);
1419+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
1420+
await t.ctx.stack.setUnlisted(record.id, true);
1421+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1422+
try {
1423+
await conn.waitForFrames(1); // ready
1424+
await req(t.app, 'PUT', `/records/${record.id}/unlisted`, {
1425+
token: TEST_TOKEN,
1426+
body: { unlisted: false },
1427+
});
1428+
const [, frame] = await conn.waitForFrames(2);
1429+
const data = frameData(frame);
1430+
expect(data.kind).toBe('changed');
1431+
expect(data.op).toBe('list');
1432+
expect(data.recordId).toBe(record.id);
1433+
} finally {
1434+
await conn.close();
1435+
}
1436+
});
1437+
1438+
test('change-feed-unlisted-record-produces-no-frame-by-default', async () => {
1439+
const fixture = changeFeedFixtures.find(
1440+
(f) => f.name === 'change-feed-unlisted-record-produces-no-frame-by-default',
1441+
)!;
1442+
handled.add(fixture.name);
1443+
const unlistedRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Unlisted' });
1444+
await t.ctx.stack.setUnlisted(unlistedRecord.id, true);
1445+
const visibleRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Visible' });
1446+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
1447+
try {
1448+
await conn.waitForFrames(1); // ready
1449+
await req(t.app, 'PATCH', `/records/${unlistedRecord.id}`, {
1450+
token: TEST_TOKEN,
1451+
body: { title: 'Edited while unlisted' },
1452+
});
1453+
await req(t.app, 'PATCH', `/records/${visibleRecord.id}`, {
1454+
token: TEST_TOKEN,
1455+
body: { title: 'Edited, visible' },
1456+
});
1457+
// Exactly one more frame — the visible record's — proves the
1458+
// unlisted edit produced none rather than merely arriving later.
1459+
const [, frame] = await conn.waitForFrames(2);
1460+
const data = frameData(frame);
1461+
expect(data.recordId).toBe(visibleRecord.id);
1462+
} finally {
1463+
await conn.close();
1464+
}
1465+
});
1466+
13751467
test('coverage', () => {
13761468
assertCoverage(
13771469
changeFeedFixtures.map((f) => f.name),

‎tests/routes/changes.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,30 @@ describe('GET /changes', () => {
480480
}
481481
});
482482

483+
it('POST /records with unlistedAt in the body creates unlisted and emits nothing', async () => {
484+
// Same transition as "created unlisted emits nothing" above, but
485+
// through the actual HTTP route with unlistedAt in the body rather
486+
// than stack.create({ unlisted: true }) directly — pins that the
487+
// route really forwards it end to end.
488+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
489+
try {
490+
await conn.waitForFrames(1); // ready
491+
await req(t.app, 'POST', '/records', {
492+
token: TEST_TOKEN,
493+
body: {
494+
typeId: NOTE_TYPE,
495+
content: { title: 'secret' },
496+
unlistedAt: new Date().toISOString(),
497+
},
498+
});
499+
const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' });
500+
const [, frame] = await conn.waitForFrames(2);
501+
expect((frame.data as { recordId: string }).recordId).toBe(proof.id);
502+
} finally {
503+
await conn.close();
504+
}
505+
});
506+
483507
it('listed -> unlisted emits a deleted/unlist frame', async () => {
484508
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
485509
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });

‎tests/routes/records.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,81 @@ describe('Records', () => {
12181218
});
12191219
});
12201220

1221+
describe('POST /records — unlistedAt', () => {
1222+
it('unlistedAt in the body creates the record already unlisted', async () => {
1223+
const { status, data } = await req(t.app, 'POST', '/records', {
1224+
token: TEST_TOKEN,
1225+
body: {
1226+
typeId: NOTE_TYPE_ID,
1227+
content: { body: 'link-shared draft' },
1228+
unlistedAt: new Date().toISOString(),
1229+
},
1230+
});
1231+
expect(status).toBe(200);
1232+
expect((data as { unlistedAt?: string }).unlistedAt).toBeDefined();
1233+
});
1234+
1235+
it('an unlisted create is absent from an unfiltered GET /records', async () => {
1236+
const { data: created } = await req(t.app, 'POST', '/records', {
1237+
token: TEST_TOKEN,
1238+
body: {
1239+
typeId: NOTE_TYPE_ID,
1240+
content: { body: 'link-shared draft' },
1241+
unlistedAt: new Date().toISOString(),
1242+
},
1243+
});
1244+
const id = (created as { id: string }).id;
1245+
1246+
const { data: listed } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
1247+
const ids = (listed as { records: { id: string }[] }).records.map((r) => r.id);
1248+
expect(ids).not.toContain(id);
1249+
1250+
// Still reachable by id — unlisted withholds enumeration, not access.
1251+
const { status: getStatus } = await req(t.app, 'GET', `/records/${id}`, {
1252+
token: TEST_TOKEN,
1253+
});
1254+
expect(getStatus).toBe(200);
1255+
});
1256+
1257+
it('a non-owner grantee with a plain create grant may create unlisted — not owner-only', async () => {
1258+
await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
1259+
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
1260+
const { status, data } = await req(t.app, 'POST', '/records', {
1261+
token,
1262+
body: {
1263+
typeId: NOTE_TYPE_ID,
1264+
content: { body: 'not the owner' },
1265+
unlistedAt: new Date().toISOString(),
1266+
},
1267+
});
1268+
expect(status).toBe(200);
1269+
expect((data as { unlistedAt?: string }).unlistedAt).toBeDefined();
1270+
});
1271+
1272+
it('refuses unlistedAt from a delegated app acting for a non-owner subject', async () => {
1273+
// Neither identity is the stack owner, so mayGrantAccess() — the same
1274+
// gate the permissions field uses at create time — refuses: a
1275+
// delegated app carries none of the owner's extra trust, and this
1276+
// record is the subject's own to begin with.
1277+
const appId = 'com.example.thirdparty';
1278+
const subjectId = 'entity-third-party-subject';
1279+
await t.ctx.stack.grant(subjectId, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
1280+
await t.ctx.stack.grant(appId, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
1281+
const { token } = await t.ctx.adapter.createToken(appId, { onBehalfOf: subjectId });
1282+
1283+
const { status, data } = await req(t.app, 'POST', '/records', {
1284+
token,
1285+
body: {
1286+
typeId: NOTE_TYPE_ID,
1287+
content: { body: 'delegated' },
1288+
unlistedAt: new Date().toISOString(),
1289+
},
1290+
});
1291+
expect(status).toBe(403);
1292+
expect((data as { error: { code: string } }).error.code).toBe('permission');
1293+
});
1294+
});
1295+
12211296
describe('PATCH /records/:id — If-Match / optimistic concurrency', () => {
12221297
it('succeeds when If-Match names the current version', async () => {
12231298
const record = await seedRecord(t.ctx);

0 commit comments

Comments
 (0)