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
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@haverstack/adapter-local": "^0.10.0",
"@haverstack/commons": "^0.3.0",
"@haverstack/core": "^0.11.1",
"@haverstack/wire-types": "^0.9.0",
"@haverstack/adapter-local": "^0.12.0",
"@haverstack/commons": "^0.5.0",
"@haverstack/core": "^0.13.1",
"@haverstack/wire-types": "^0.12.0",
"@hono/node-server": "^2.1.1",
"hono": "^4.13.3",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@haverstack/conformance-fixtures": "^0.3.0",
"@haverstack/conformance-fixtures": "^0.6.0",
"@types/node": "^26.2.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
78 changes: 39 additions & 39 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/middleware/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export function errorMiddleware(logger: Logger): ErrorHandler<AppEnv> {
'Denied a verified requester',
);
}
// An anonymous requester gets the same 404 for a private record as for
// a missing one (docs/spec/wire-format.md § Server implementation
// checklist): a bare 403 or 401 here would confirm the record exists
// to a caller who presented no credential at all. `WWW-Authenticate`
// keeps the login prompt reachable without reopening that
// distinction — RFC 7235's standard scheme for a bearer-token API is
// `Bearer` (RFC 6750 §3), not the higher-level `did-challenge`
// exchange discovery advertises for obtaining that token.
if (wire.status === 404 && !auth) c.header('WWW-Authenticate', 'Bearer');
return c.json(wire.body, wire.status as ContentfulStatusCode);
}
logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error');
Expand Down
14 changes: 13 additions & 1 deletion src/routes/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ export function entityRoutes(ctx: StackContext): Hono<AppEnv> {
const id = await resolveOwnerRecordId();
const record = id ? await stack.forSession(auth).get(id) : null;
if (!record) {
cachedOwnerRecordId = null;
// get() now returns null both for "doesn't exist" and "exists but
// this caller can't read it" (the anti-oracle rule — see #79), so a
// non-owner's null says nothing about whether the card is actually
// gone. Only the owner's own null is a reliable deletion signal;
// evicting the cache on anyone else's denial would force a
// re-resolve query on every subsequent forbidden GET for a card that
// never moved.
const ownerActingAlone =
auth.principalId === ownerEntityId && auth.subjectId === ownerEntityId;
if (ownerActingAlone) cachedOwnerRecordId = null;
throw new StackNotFoundError('Entity record not found');
}
return c.json(serializeRecord(record));
Expand All @@ -64,6 +73,9 @@ export function entityRoutes(ctx: StackContext): Hono<AppEnv> {
.forSession(auth)
.update(id, (body.content ?? {}) as Record<string, unknown>);
} catch (err) {
// requireOwner() above already restricts this handler to the owner
// acting alone, so unlike GET's, a StackNotFoundError here can only
// mean the card is genuinely gone — never a permission denial.
if (err instanceof StackNotFoundError) cachedOwnerRecordId = null;
throw err;
}
Expand Down
36 changes: 19 additions & 17 deletions src/routes/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,19 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
return c.json(serializeRecord(updated));
});

// DELETE /records/:id (?hard=true for permanent)
// DELETE /records/:id (?hard=true for permanent). A soft delete bumps
// the version like any other mutation, so it answers with the record it
// produced (carrying deletedAt); a hard delete leaves nothing to answer
// with, so that one stays 204.
app.delete('/:id', requireAuth(), async (c) => {
const id = c.req.param('id');
const auth = c.get('auth')!;
const hard = new URL(c.req.url).searchParams.get('hard') === 'true';
const session = stack.forSession(auth);

await stack
.forSession(auth)
.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.body(null, 204);
await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) });
if (hard) return c.body(null, 204);
return c.json(serializeRecord((await session.get(id))!));
});

// POST /records/:id/undelete — reverses a soft delete; idempotent
Expand Down Expand Up @@ -183,10 +186,11 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
const auth = c.get('auth')!;
const body = await readJson<{ permissions: Permission[] }>(c);
if (!Array.isArray(body.permissions)) throw new StackQueryError('permissions must be an array');
await stack
.forSession(auth)
.setPermissions(id, body.permissions, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.body(null, 204);
const session = stack.forSession(auth);
await session.setPermissions(id, body.permissions, {
ifVersion: parseIfMatch(c.req.header('If-Match')),
});
return c.json(serializeRecord((await session.get(id))!));
});

// ------------------------------------------------------------------
Expand All @@ -211,10 +215,9 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
const auth = c.get('auth')!;
const body = await readJson<Association>(c);
if (!body.kind || !body.label) throw new StackQueryError('kind and label are required');
await stack
.forSession(auth)
.associate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.body(null, 204);
const session = stack.forSession(auth);
await session.associate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.json(serializeRecord((await session.get(id))!));
});

// POST, not DELETE — a DELETE request body has no defined semantics
Expand All @@ -224,10 +227,9 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<Ap
const id = c.req.param('id');
const auth = c.get('auth')!;
const body = await readJson<Association>(c);
await stack
.forSession(auth)
.dissociate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.body(null, 204);
const session = stack.forSession(auth);
await session.dissociate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
return c.json(serializeRecord((await session.get(id))!));
});

// ------------------------------------------------------------------
Expand Down
71 changes: 67 additions & 4 deletions tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,15 @@ describe('discovery fixtures', () => {
assertCoverage(
discoveryFixtures.map((f) => f.name),
handled,
new Set(),
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',
]),
);
});
});
Expand Down Expand Up @@ -371,6 +379,31 @@ describe('patchContent fixtures', () => {
expect(d.version).toBe(fixture.responseBody!.version);
});

test('patch-record-restamps-the-actor', async () => {
const fixture = patchContentFixtures.find((f) => f.name === 'patch-record-restamps-the-actor')!;
handled.add(fixture.name);
const record = await t.ctx.stack.create(
NOTE_TYPE,
{ title: 'original' },
{
entityId: TEST_ENTITY_ID,
permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }],
},
);
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
token,
body: fixture.requestBody,
});
expect(status).toBe(fixture.responseStatus);
const d = data as Record<string, unknown>;
expect(d.content).toEqual(fixture.responseBody!.content);
// Authorship (entityId) is untouched by a non-author write; updatedBy
// moves to the requester who made this edit.
expect(d.entityId).toBe(TEST_ENTITY_ID);
expect(d.updatedBy).toBe(CONTRIBUTOR_ID);
});

test('coverage', () => {
assertCoverage(
patchContentFixtures.map((f) => f.name),
Expand Down Expand Up @@ -549,8 +582,11 @@ describe('setPermissions fixtures', () => {
body: fixture.requestBody,
});
expect(status).toBe(fixture.responseStatus);
const anon = await req(t.app, 'GET', `/records/${record.id}`);
expect(anon.status).toBe(403);
// Anonymous can't tell "made private" from "never existed" (#79's
// anti-oracle rule) — 404 + WWW-Authenticate, not 403.
const anon = await t.app.request(`/records/${record.id}`);
expect(anon.status).toBe(404);
expect(anon.headers.get('WWW-Authenticate')).toBe('Bearer');
});

test('coverage', () => {
Expand Down Expand Up @@ -763,14 +799,41 @@ describe('error response fixtures', () => {
}
}

test('error-permission-denied — write without a grant', async () => {
test('error-permission-denied — can read, no write grant', async () => {
const fixture = find('error-permission-denied');
// Readability is what earns the 403 (see error-not-found-record-the-
// requester-cannot-read below) — a write-only-denied requester still
// needs an explicit read grant, or this would hit the anti-oracle 404
// instead of the permission check this fixture pins.
const record = await t.ctx.stack.create(
NOTE_TYPE,
{ title: 'x' },
{ permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
);
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
const { status, data } = await dispatch(fixture, token, `/records/${record.id}`);
expectError(status, data, fixture);
});

test('error-not-found-record-the-requester-cannot-read — the anti-oracle rule', async () => {
const fixture = find('error-not-found-record-the-requester-cannot-read');
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
const { status, data } = await dispatch(fixture, token, `/records/${record.id}`);
expectError(status, data, fixture);
});

test('error-validation-permission-write-without-read', async () => {
const fixture = find('error-validation-permission-write-without-read');
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
const { status, data } = await dispatch(
fixture,
TEST_TOKEN,
`/records/${record.id}/permissions`,
);
expectError(status, data, fixture);
});

test('error-permission-denied-versions-read-only — can read, cannot write', async () => {
const fixture = find('error-permission-denied-versions-read-only');
const record = await t.ctx.stack.create(
Expand Down
Loading
Loading