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
92 changes: 47 additions & 45 deletions docs/api.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,18 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@haverstack/adapter-local": "^0.36.0",
"@haverstack/commons": "^0.31.0",
"@haverstack/core": "^0.37.0",
"@haverstack/wire-types": "^0.36.0",
"@haverstack/adapter-local": "^0.37.0",
"@haverstack/commons": "^0.32.0",
"@haverstack/core": "^0.38.0",
"@haverstack/wire-types": "^0.37.0",
"@hono/node-server": "^2.1.1",
"hono": "^4.13.7",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@haverstack/conformance-fixtures": "^0.31.0",
"@haverstack/conformance-fixtures": "^0.32.0",
"@types/node": "^26.2.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
76 changes: 38 additions & 38 deletions pnpm-lock.yaml

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

27 changes: 23 additions & 4 deletions src/lib/json.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Context } from 'hono';
import { StackQueryError } from '@haverstack/core';
import { StackBadRequestError } from '@haverstack/core';
import type { AppEnv } from '../types.js';

/**
Expand All @@ -8,20 +8,39 @@ import type { AppEnv } from '../types.js';
* catch-all as an unlabeled 500 instead of the 400 `bad_request` every other
* structurally-invalid request gets (docs/spec/wire-format.md § Error responses).
*/
export async function readJson<T = unknown>(c: Context<AppEnv>): Promise<T> {
export async function readJson<T = unknown>(
c: Context<AppEnv>,
keys?: readonly string[],
): Promise<T> {
let parsed: unknown;
try {
parsed = await c.req.json<T>();
} catch (err) {
if (err instanceof SyntaxError) throw new StackQueryError('Invalid JSON in request body');
if (err instanceof SyntaxError) throw new StackBadRequestError('Invalid JSON in request body');
throw err;
}
// `null`, a bare string or a number parses fine, but every call site
// indexes fields off the result, so a non-object reaches the handler and
// throws a bare TypeError — another unlabeled 500, unauthenticated on the
// /auth routes. Structurally invalid like malformed JSON, so a 400 too.
if (parsed === null || typeof parsed !== 'object') {
throw new StackQueryError('Request body must be a JSON object');
throw new StackBadRequestError('Request body must be a JSON object');
}
if (keys) rejectUnknownKeys(parsed as Record<string, unknown>, keys);
return parsed as T;
}

/**
* A key the endpoint doesn't define is refused rather than ignored: an
* ignored field turns a mistaken request into a different one that
* succeeds, and the caller never learns it asked for something else.
*/
export function rejectUnknownKeys(body: Record<string, unknown>, keys: readonly string[]): void {
const unknown = Object.keys(body).filter((key) => !keys.includes(key));
if (unknown.length > 0) {
throw new StackBadRequestError(
`Unknown body key${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. ` +
`This endpoint takes: ${keys.join(', ') || 'no keys'}.`,
);
}
}
3 changes: 2 additions & 1 deletion src/lib/queryWorker/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
* by-id reads stay on the main thread.
*/
import { Worker } from 'node:worker_threads';
import type { StackQuery, QueryResult, TokenSession } from '@haverstack/core';
import type { StackQuery, QueryResult } from '@haverstack/core';
import type { TokenSession } from '@haverstack/core/wire';
import { StackTimeoutError } from '@haverstack/core';
import { deserializeError } from '@haverstack/wire-types';
import type { Logger } from 'pino';
Expand Down
3 changes: 2 additions & 1 deletion src/lib/queryWorker/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* (serializeError/deserializeError from @haverstack/wire-types) since a
* custom Error subclass doesn't survive structured clone as itself.
*/
import type { StackQuery, QueryResult, TokenSession } from '@haverstack/core';
import type { StackQuery, QueryResult } from '@haverstack/core';
import type { TokenSession } from '@haverstack/core/wire';
import type { WireError } from '@haverstack/wire-types';

/**
Expand Down
4 changes: 2 additions & 2 deletions src/lib/queryWorker/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ const init = workerData as QueryWorkerInit;
// pool.ts). open() (not openOrInitialize()) reflects that: there is no
// first-run path to handle here.
const adapter = await LocalAdapter.open({ path: init.dbPath });
const stack = await Stack.create(adapter);
const stack = await Stack.open(adapter);

parentPort.on('message', async (req: QueryRequest) => {
const port = parentPort!;
try {
const scoped = req.session ? stack.forSession(req.session) : stack.asEntity(null);
const scoped = req.session ? stack.asActor(req.session) : stack.asEntity(null);
const result = await scoped.query(req.query);
port.postMessage({ id: req.id, ok: true, result } satisfies QueryResponse);
} catch (err) {
Expand Down
34 changes: 24 additions & 10 deletions src/lib/resumeBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type ResumeEntry = {
n: number;
recordId: string;
isPurge: boolean;
/** Wire-ready, seq already attached. */
/** Wire-ready, cursor already attached. */
frame: WireRecordChange;
};

Expand Down Expand Up @@ -61,8 +61,8 @@ export class ResumeBuffer {
*/
append(change: RecordChange): ResumeEntry {
this.currentN += 1;
const seq = encodeCursor(this.id, this.currentN);
const frame = serializeChange({ ...change, seq });
const cursor = encodeCursor(this.id, this.currentN);
const frame = serializeChange({ ...change, cursor });
const entry: ResumeEntry = {
n: this.currentN,
recordId: change.recordId,
Expand Down Expand Up @@ -110,8 +110,9 @@ export type ResumeBufferKeyParts = {
/**
* Reproducible across a reconnect that re-sends the same query params —
* order-independent on the parts of `filter` that don't carry order of
* their own (`typeId`/`kinds` are matched as sets, not sequences, so
* re-sending them in a different order must key the same buffer).
* their own (`typeId`, `baseId`, `createdBy`'s halves and `kinds` are
* matched as sets, not sequences, so re-sending them in a different order
* must key the same buffer).
*
* Two filters that mean different things must never key the same buffer.
* A buffer opens exactly one `ScopedStack.subscribe()`, carrying the
Expand All @@ -130,22 +131,35 @@ export type ResumeBufferKeyParts = {
*/
export function resumeBufferKey(parts: ResumeBufferKeyParts): string {
const { filter } = parts;
const typeId = filter.typeId
? [...(Array.isArray(filter.typeId) ? filter.typeId : [filter.typeId])].sort()
: undefined;
const kinds = filter.kinds ? [...filter.kinds].sort() : undefined;
const typeId = asSortedSet(filter.typeId);
const baseId = asSortedSet(filter.baseId);
const createdBySubject = asSortedSet(filter.createdBy?.subjectId);
const createdByPrincipal = asSortedSet(filter.createdBy?.principalId);
// `createdBy: {}` constrains nothing, so it keys the same as no createdBy.
const createdBy =
createdBySubject || createdByPrincipal
? { subjectId: createdBySubject, principalId: createdByPrincipal }
: undefined;
const kinds = asSortedSet(filter.kinds);
return JSON.stringify({
principalId: parts.principalId,
subjectId: parts.subjectId,
includeRecords: parts.includeRecords,
includeUnlisted: parts.includeUnlisted,
...(typeId !== undefined && { typeId }),
...(baseId !== undefined && { baseId }),
...(filter.parentId !== undefined && { parentId: filter.parentId }),
...(filter.entityId !== undefined && { entityId: filter.entityId }),
...(createdBy !== undefined && { createdBy }),
...(kinds !== undefined && { kinds }),
});
}

/** A one-or-many filter value, matched as a set: normalized to a sorted array. */
function asSortedSet<T extends string>(value: T | T[] | undefined): T[] | undefined {
if (value === undefined) return undefined;
return [...(Array.isArray(value) ? value : [value])].sort();
}

export type ResumeBufferRegistryOptions = {
/** Max entries retained per buffer before the oldest is dropped. */
depth: number;
Expand Down
6 changes: 3 additions & 3 deletions src/lib/resumeCursor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Resume cursor codec. A cursor is opaque and base64url by wire contract
* (`isValidSeq()` in @haverstack/wire-types), but this server's own are
* (`isValidCursor()` in @haverstack/wire-types), but this server's own are
* self-describing: `base64url(bufferId + ":" + n)`. That makes a presented
* cursor's origin checkable — a reconnect naming a buffer this server
* doesn't hold (a different filter, a restart, a buffer past its retention
Expand Down Expand Up @@ -30,10 +30,10 @@ export function encodeCursor(bufferId: string, n: number): string {
* (A charset-*invalid* cursor is refused before this is ever called — see
* `src/routes/changes.ts`.)
*/
export function decodeCursor(seq: string): DecodedCursor | null {
export function decodeCursor(cursor: string): DecodedCursor | null {
let text: string;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(seq));
text = new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(cursor));
} catch {
return null;
}
Expand Down
4 changes: 2 additions & 2 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { timingSafeEqual } from 'node:crypto';
import type { MiddlewareHandler } from 'hono';
import { StackPermissionError } from '@haverstack/core';
import type { TokenSession } from '@haverstack/core';
import type { TokenSession } from '@haverstack/core/wire';
import type { AppEnv } from '../types.js';
import type { StackContext } from '../stack.js';
import { wireError } from '../wireError.js';
Expand Down Expand Up @@ -57,7 +57,7 @@ export function requireAuth(): MiddlewareHandler<AppEnv> {
* authenticated as the owner rather than merely delegated for it. Being the
* owner is never on its own sufficient under delegation — a delegated
* session with the owner as principal still fails this, matching
* `ScopedStack`'s own owner-only gates (e.g. hard delete). See
* `ScopedStack`'s own owner-only gates (e.g. purge). See
* docs/spec/access-control.md § Delegation.
*/
export function isOwnerActingAlone(auth: TokenSession | null, ownerEntityId: string): boolean {
Expand Down
32 changes: 32 additions & 0 deletions src/middleware/params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MiddlewareHandler } from 'hono';
import { StackBadRequestError } from '@haverstack/core';
import type { AppEnv } from '../types.js';

/**
* Refuse any query param the route doesn't define, for the same reason
* `rejectUnknownKeys()` refuses body keys. Routes whose params core's wire
* parsers read (`GET /records`, `GET /changes`, the journal) leave the
* check to those parsers.
*/
export function knownParams(...names: string[]): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const unknown = [...new Set(new URL(c.req.url).searchParams.keys())].filter(
(name) => !names.includes(name),
);
if (unknown.length > 0) {
throw new StackBadRequestError(
`Unknown query param${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. ` +
`This endpoint takes: ${names.join(', ') || 'none'}.`,
);
}
await next();
};
}

/** A boolean query param: absent is false, and anything but `true`/`false` is refused. */
export function booleanParam(url: URL, name: string): boolean {
const value = url.searchParams.get(name);
if (value === null || value === 'false') return false;
if (value === 'true') return true;
throw new StackBadRequestError(`Invalid ${name}: expected true or false, got "${value}"`);
}
Loading
Loading