From a476a5267c13a601c9b3012d26d9247c66809185 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 01:27:25 +0000 Subject: [PATCH 1/2] feat(core): validate client-minted record IDs, fix generator bugs (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack.create() and ScopedStack.create() now accept an optional id, validated against the format core already generates (12-char lowercase Crockford base-32, no reserved "_" prefix) with duplicates surfacing as StackConflictError. ScopedStack additionally enforces a configurable clock-skew tolerance on the timestamp prefix, since a grantee is the untrusted actor who could otherwise forge sort position. Also fixes two independent bugs in id.ts: the random-suffix modulus was off by one (making "zzz" unreachable and biasing the distribution), and a backward clock step could silently break monotonic ID ordering — now clamped to the highest timestamp seen. Documents the validation rules and duplicate/skew semantics in docs/spec.md so a server implementation has a normative contract to enforce them against. --- docs/spec.md | 37 ++++++-- packages/core/src/id.ts | 46 +++++++++- packages/core/src/index.ts | 9 +- packages/core/src/stack.ts | 102 +++++++++++++++++++++-- packages/core/tests/id.test.ts | 78 ++++++++++++++++- packages/core/tests/scoped-stack.test.ts | 69 ++++++++++++++- packages/core/tests/stack.test.ts | 56 +++++++++++++ 7 files changed, 376 insertions(+), 21 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index d93853d..8baf764 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -290,6 +290,23 @@ type StackRecord = { **Design principle:** Native fields are things the library needs to operate (routing, querying, syncing, hierarchy). Everything semantic and domain-specific goes in `content`. +### Record IDs + +IDs are Crockford base-32, lowercase, exactly 12 characters — a 9-character timestamp prefix (so IDs are time-sortable: lexicographic order matches creation order) plus a 3-character random suffix, monotonically incremented for IDs generated in the same millisecond. + +**Client-minted IDs are the default and stay supported.** `Stack.create()` accepts an optional `id`, so an app that needs the ID before the write round-trips (e.g. to reference it immediately) can supply its own; omit it and the library generates one. Nothing about this changes with a server in front of the stack — the client still mints the ID — but the server (and, locally, `Stack` itself) now validates what it's given rather than trusting it blindly: + +- **Charset and length** — exactly 12 characters, lowercase Crockford base-32 (`0-9`, `a-z` excluding `i`, `l`, `o`, `u`). Violations → **400**. +- **Reserved prefix** — an `id` beginning with `_` is rejected; that namespace is reserved for system records (`_config`, `_entity`, etc. — see System types under [Types](#types)). Violations → **400**. +- **Duplicate ID** — an `id` that already exists in the stack → **409** (`StackConflictError`), never a silent overwrite. + +A server MAY additionally reject an `id` whose timestamp prefix is implausibly far from server time (a clock-skew tolerance measured in hours, not years). This is optional: legitimate offline-created records carry an honest but stale prefix, so the trade-off is a per-deployment policy choice, not a spec mandate. + +**In `@haverstack/core`**, the same rules are enforced locally so a client-minted ID behaves identically whether it travels over the wire or stays in-process: + +- `Stack.create(typeId, content, { id })` validates charset, length, and the reserved prefix, and throws `StackConflictError` on a duplicate. This is a full-trust context (an embedded single-app stack, or the server's own code) — no clock-skew check. +- `ScopedStack.create()` — a grantee minting an ID — applies the same validation **plus** the timestamp-skew check, since a grantee is exactly the untrusted actor who could otherwise forge a sort position. The tolerance is configurable per Stack via `Stack.create(adapter, { idTimestampSkewMs })` (default 24 hours; pass `null` to disable). + --- ## Associations @@ -663,15 +680,15 @@ Authorization: Bearer Standard HTTP status codes are used throughout: -| Status | Meaning | When | -| ------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| **400** | Bad request | Structurally malformed request — missing a required field, unparseable query parameter, invalid JSON | -| **401** | Unauthorized | Missing or invalid bearer token | -| **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | -| **404** | Not found | `StackNotFoundError` — record or version does not exist | -| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record) | -| **413** | Request entity too large | Attachment upload exceeds the server's size limit | -| **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | +| Status | Meaning | When | +| ------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **400** | Bad request | Structurally malformed request — missing a required field, unparseable query parameter, invalid JSON, a malformed or reserved-prefix record `id` | +| **401** | Unauthorized | Missing or invalid bearer token | +| **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | +| **404** | Not found | `StackNotFoundError` — record or version does not exist | +| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record, a client-supplied `id` that already exists) | +| **413** | Request entity too large | Attachment upload exceeds the server's size limit | +| **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema. @@ -715,6 +732,8 @@ POST /records/:id/undelete — undelete (reverse a soft delete; idempotent) `PATCH /records/:id` accepts a partial content object. Omitted fields retain their current values. A field set to `null` is removed (RFC 7396 / JSON Merge Patch). Associations and permissions are managed via their own endpoints. +`POST /records` accepts a full record body, including an optional client-supplied `id` — see [Record IDs](#record-ids) for the validation and duplicate-conflict rules the server applies. + ### Permissions ``` diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index b6ae8e7..6f82b03 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -21,6 +21,10 @@ export const RAND_SUFFIX_LENGTH = 3; // Module-level state for monotonicity within the same millisecond let lastNowId = ''; let lastRandChars = ''; +// Tracks the highest timestamp an ID has been minted for, so a backward +// clock step (NTP correction, suspend/resume) can't produce an ID that +// sorts before ones already generated in this process. +let lastTimestamp = 0; // ------------------------------------------------------- // Errors @@ -91,15 +95,15 @@ const pad = (chars: string, length: number): string => chars.padStart(length, CH * Works in Node (>=19), browsers, Deno, and Bun. */ const generateRandChars = (): string => { - const max = Math.pow(BASE, RAND_SUFFIX_LENGTH) - 1; + const modulus = Math.pow(BASE, RAND_SUFFIX_LENGTH); const arr = new Uint32Array(1); // Rejection sampling to avoid modulo bias let value: number; do { crypto.getRandomValues(arr); value = arr[0]; - } while (value > Math.floor(0xffffffff / max) * max); - return pad(crockford32Encode(value % max), RAND_SUFFIX_LENGTH); + } while (value > Math.floor(0xffffffff / modulus) * modulus); + return pad(crockford32Encode(value % modulus), RAND_SUFFIX_LENGTH); }; const incrementRandChars = (randChars: string): string => { @@ -128,6 +132,17 @@ export const _setLastRandChars = (chars: string): void => { lastRandChars = chars; }; +export const _setLastTimestamp = (timestamp: number): void => { + lastTimestamp = timestamp; +}; + +/** Resets all module-level generator state. For test isolation only. */ +export const _resetIdState = (): void => { + lastNowId = ''; + lastRandChars = ''; + lastTimestamp = 0; +}; + // ------------------------------------------------------- // Public API // ------------------------------------------------------- @@ -143,11 +158,34 @@ export const _setLastRandChars = (chars: string): void => { * @param timestamp - Override the timestamp (ms since epoch). Defaults to Date.now(). */ export const generateId = (timestamp: number = Date.now()): string => { - const nowId = pad(crockford32Encode(timestamp), MIN_TIMESTAMP_LENGTH); + const effectiveTimestamp = Math.max(timestamp, lastTimestamp); + const nowId = pad(crockford32Encode(effectiveTimestamp), MIN_TIMESTAMP_LENGTH); const randChars = nowId !== lastNowId ? generateRandChars() : incrementRandChars(lastRandChars); + lastTimestamp = effectiveTimestamp; lastNowId = nowId; lastRandChars = randChars; return nowId + randChars; }; + +// ------------------------------------------------------- +// Format validation +// ------------------------------------------------------- + +const ID_LENGTH = MIN_TIMESTAMP_LENGTH + RAND_SUFFIX_LENGTH; +const ID_FORMAT = new RegExp(`^[${CHARACTERS}]{${ID_LENGTH}}$`); + +/** + * Check whether a string has the shape of a Stack record ID: exactly + * 12 lowercase Crockford base-32 characters. Does not check whether the + * ID actually exists — see Stack.create()'s duplicate check for that. + */ +export const isValidIdFormat = (id: string): boolean => ID_FORMAT.test(id); + +/** + * Extract the timestamp (ms since epoch) encoded in an ID's prefix. + * Only meaningful for IDs that pass isValidIdFormat(). + */ +export const idTimestamp = (id: string): number => + crockford32Decode(id.slice(0, MIN_TIMESTAMP_LENGTH)); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bae54d8..bd31906 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export { export type { StackClient, CreateRecordOptions, + StackOptions, GetRecordOptions, DeleteRecordOptions, } from './stack.js'; @@ -75,7 +76,13 @@ export { SYSTEM_TYPES } from './types.js'; export { combineAdapters } from './combine.js'; // Utilities -export { generateId, crockford32Encode, crockford32Decode } from './id.js'; +export { + generateId, + crockford32Encode, + crockford32Decode, + isValidIdFormat, + idTimestamp, +} from './id.js'; export { hashSchema, isCompatible, parseTypeId, buildTypeId } from './schema.js'; export { validateContent, isValid } from './validate.js'; export type { ValidationError } from './validate.js'; diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index fdbd137..a243b64 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -14,7 +14,7 @@ * Apps should never talk to a StackAdapter directly. */ -import { generateId } from './id.js'; +import { generateId, isValidIdFormat, idTimestamp } from './id.js'; import { hashSchema, isCompatible, parseTypeId } from './schema.js'; import { validateContent } from './validate.js'; import { checkAccess } from './access.js'; @@ -44,6 +44,13 @@ import type { // ------------------------------------------------------- export type CreateRecordOptions = { + /** + * Client-minted record ID. Must be 12 lowercase Crockford base-32 + * characters and may not use the reserved `_` prefix. Omit to let the + * library generate one. See Stack.create() and ScopedStack.create() + * for the validation each applies. + */ + id?: string; parentId?: string; entityId?: string; appId?: string; @@ -51,6 +58,17 @@ export type CreateRecordOptions = { associations?: Association[]; }; +export type StackOptions = { + /** + * Clock-skew tolerance (ms) for the timestamp-prefix plausibility check + * ScopedStack.create() runs on a grantee-supplied `id` — a grantee is an + * untrusted actor who could otherwise mint an ID that forges its sort + * position. Default: 24 hours. Pass null to disable the check entirely. + * Unscoped Stack.create() never runs this check (full-trust context). + */ + idTimestampSkewMs?: number | null; +}; + export type GetRecordOptions = { /** If false, return the raw stored record without auto-migrating. Default: true */ migrate?: boolean; @@ -105,6 +123,53 @@ export class StackConflictError extends Error { } } +// ------------------------------------------------------- +// Record ID validation +// ------------------------------------------------------- + +const RESERVED_ID_PREFIX = '_'; +const DEFAULT_ID_TIMESTAMP_SKEW_MS = 24 * 60 * 60 * 1000; + +/** + * Format and reserved-prefix checks — full-trust context (Stack.create()). + * Checked before the format check: the Crockford charset already excludes + * "_", so a reserved-looking id (e.g. "_config") would otherwise just fail + * as a generic format error instead of a specific, actionable one. + */ +function validateRecordId(id: string): void { + if (id.startsWith(RESERVED_ID_PREFIX)) { + throw new StackValidationError([ + { path: 'id', message: `ID "${id}" uses the reserved "${RESERVED_ID_PREFIX}" prefix.` }, + ]); + } + if (!isValidIdFormat(id)) { + throw new StackValidationError([ + { + path: 'id', + message: `Invalid ID "${id}": expected 12 lowercase Crockford base-32 characters.`, + }, + ]); + } +} + +/** + * Timestamp-prefix plausibility check for grantee-minted IDs + * (ScopedStack.create()) — a grantee is untrusted and could otherwise mint + * an ID that forges its sort position. Pass null to disable. + */ +function validateIdTimestampSkew(id: string, toleranceMs: number | null): void { + if (toleranceMs === null) return; + const skew = Math.abs(Date.now() - idTimestamp(id)); + if (skew > toleranceMs) { + throw new StackValidationError([ + { + path: 'id', + message: `ID "${id}" timestamp is outside the allowed clock-skew tolerance (${toleranceMs}ms).`, + }, + ]); + } +} + // ------------------------------------------------------- // StackClient interface // ------------------------------------------------------- @@ -145,19 +210,25 @@ export interface StackClient { export class Stack implements StackClient { private readonly migrations = new Map(); - private constructor(private readonly adapter: StackAdapter) {} + private constructor( + private readonly adapter: StackAdapter, + private readonly idTimestampSkewMsValue: number | null, + ) {} /** * Create a Stack instance. Reads ownerEntityId and timezone from the adapter. */ - static async create(adapter: StackAdapter): Promise { + static async create(adapter: StackAdapter, opts: StackOptions = {}): Promise { if (!adapter.ownerEntityId) { throw new Error( 'Stack misconfiguration: adapter has no ownerEntityId. ' + 'Initialise the adapter with an entityId before calling Stack.create().', ); } - const stack = new Stack(adapter); + const stack = new Stack( + adapter, + opts.idTimestampSkewMs === undefined ? DEFAULT_ID_TIMESTAMP_SKEW_MS : opts.idTimestampSkewMs, + ); await stack.seedSystemTypes(); return stack; } @@ -174,6 +245,11 @@ export class Stack implements StackClient { return this.adapter.capabilities; } + /** Clock-skew tolerance applied to grantee-minted IDs — see StackOptions.idTimestampSkewMs. */ + get idTimestampSkewMs(): number | null { + return this.idTimestampSkewMsValue; + } + /** * Get a permission-scoped view of this Stack, as if the request came from * the given entity. Pass null for an anonymous/unauthenticated requester. @@ -386,9 +462,16 @@ export class Stack implements StackClient { throw new StackValidationError(errors); } + if (opts.id !== undefined) { + validateRecordId(opts.id); + if (await this.adapter.getRecord(opts.id)) { + throw new StackConflictError(`Record already exists: "${opts.id}"`); + } + } + const now = new Date(); const record: StackRecord = { - id: generateId(), + id: opts.id ?? generateId(), typeId, createdAt: now, updatedAt: now, @@ -982,6 +1065,11 @@ export class ScopedStack implements StackClient { * Requires either an entity-specific _grant or a default _grant for * the target type. Anonymous requesters (null entityId) are always denied. * The created record's entityId is always set to the requester. + * + * A client-supplied `opts.id` gets the same format validation as + * Stack.create() plus a timestamp-skew check — the requester here is an + * untrusted actor who could otherwise mint an ID that forges its sort + * position. See StackOptions.idTimestampSkewMs. */ async create = Record>( typeId: TypeId, @@ -993,6 +1081,10 @@ export class ScopedStack implements StackClient { if (!(await this.checkCreateGrant(typeId))) { throw new StackPermissionError(`No create grant for type "${typeId}"`); } + if (opts.id !== undefined) { + validateRecordId(opts.id); + validateIdTimestampSkew(opts.id, this.stack.idTimestampSkewMs); + } return this.stack.create(typeId, content, { ...opts, entityId: requester }); } diff --git a/packages/core/tests/id.test.ts b/packages/core/tests/id.test.ts index 02dc4ed..ad22bc6 100644 --- a/packages/core/tests/id.test.ts +++ b/packages/core/tests/id.test.ts @@ -1,13 +1,16 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, beforeEach, vi } from 'vitest'; import { generateId, crockford32Encode, crockford32Decode, + isValidIdFormat, + idTimestamp, RAND_SUFFIX_LENGTH, BASE, IdGenerationOverflowError, _setLastNowId, _setLastRandChars, + _resetIdState, } from '../src/id.js'; // ------------------------------------------------------- @@ -102,6 +105,10 @@ describe('encode/decode roundtrip', () => { // ------------------------------------------------------- describe('generateId', () => { + beforeEach(() => { + _resetIdState(); + }); + test('returns a string of at least 12 characters', () => { const id = generateId(); expect(typeof id).toBe('string'); @@ -182,4 +189,73 @@ describe('generateId', () => { // A buggy implementation would drop the leading zero: '1hk1p9749as' (11 chars) expect(id).toBe('1hk1p97490as'); }); + + test('a fresh random draw can produce the maximum suffix "zzz" (regression #55: modulus off-by-one)', () => { + const spy = vi.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation((arr) => { + (arr as Uint32Array)[0] = Math.pow(BASE, RAND_SUFFIX_LENGTH) - 1; // 32767 + return arr; + }); + const id = generateId(Date.now()); + expect(id.slice(-RAND_SUFFIX_LENGTH)).toBe('zzz'); + spy.mockRestore(); + }); + + test('clamps to the last timestamp when the clock regresses (regression #55)', () => { + const t2 = new Date('2024-06-01T00:00:05.000Z').valueOf(); + const t1 = t2 - 5000; // clock steps backward after t2 + + const id1 = generateId(t2); + const id2 = generateId(t1); + + // Still sorts after id1 despite the clock reading an earlier time. + expect(id2 > id1).toBe(true); + // Clamped to the same effective timestamp, so the prefix is unchanged + // and the suffix simply increments. + expect(id2.slice(0, -RAND_SUFFIX_LENGTH)).toBe(id1.slice(0, -RAND_SUFFIX_LENGTH)); + }); + + test('does not clamp when the clock advances normally', () => { + const t1 = new Date('2024-06-01T00:00:00.000Z').valueOf(); + const t2 = t1 + 5000; + + const id1 = generateId(t1); + const id2 = generateId(t2); + + expect(id2.slice(0, -RAND_SUFFIX_LENGTH)).toBe(crockford32Encode(t2).padStart(9, '0')); + expect(id1 < id2).toBe(true); + }); +}); + +// ------------------------------------------------------- +// ID format validation +// ------------------------------------------------------- + +describe('isValidIdFormat', () => { + test('accepts a freshly generated ID', () => { + expect(isValidIdFormat(generateId())).toBe(true); + }); + + test('rejects the wrong length', () => { + expect(isValidIdFormat('short')).toBe(false); + expect(isValidIdFormat('a'.repeat(13))).toBe(false); + }); + + test('rejects characters outside the Crockford charset', () => { + expect(isValidIdFormat('1111111111i1')).toBe(false); // 'i' excluded + expect(isValidIdFormat('1111111111l1')).toBe(false); // 'l' excluded + expect(isValidIdFormat('1111111111o1')).toBe(false); // 'o' excluded + expect(isValidIdFormat('1111111111u1')).toBe(false); // 'u' excluded + }); + + test('rejects uppercase', () => { + expect(isValidIdFormat('ABCDEFGHJ123')).toBe(false); + }); +}); + +describe('idTimestamp', () => { + test('extracts the encoded timestamp from an ID prefix', () => { + const now = Date.now(); + const id = generateId(now); + expect(idTimestamp(id)).toBe(now); + }); }); diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 7db317a..3f700af 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -4,8 +4,9 @@ import { StackPermissionError, StackNotFoundError, StackValidationError, + StackConflictError, } from '../src/stack.js'; -import { generateId } from '../src/id.js'; +import { generateId, crockford32Encode } from '../src/id.js'; import { MemoryAdapter } from '../src/testing.js'; import type { StackRecord, Association, Permission } from '../src/types.js'; @@ -13,6 +14,11 @@ import type { StackRecord, Association, Permission } from '../src/types.js'; // Test setup // ------------------------------------------------------- +// Builds a well-formed 12-char id with a specific timestamp prefix, bypassing +// generateId()'s own monotonic-clock clamp (which would otherwise pull an +// "ancient" test timestamp forward to the real current time). +const idWithTimestamp = (ms: number): string => `${crockford32Encode(ms).padStart(9, '0')}000`; + const NOTE = 'com.example.test/note@1'; const OWNER = 'owner-123'; const MEMBER = 'member-456'; @@ -433,6 +439,67 @@ describe('ScopedStack.create', () => { }); }); +// ------------------------------------------------------- +// ScopedStack.create — client-supplied id (#55) +// ------------------------------------------------------- + +describe('ScopedStack.create — client-supplied id', () => { + beforeEach(async () => { + await stack.defineType(COMMENT, 'Comment', { text: { kind: 'text', required: true } }); + await stack.grant(MEMBER, [{ actions: ['create'], typeId: COMMENT }]); + }); + + test('accepts a well-formed, recent id from a grantee', async () => { + const id = generateId(); + const record = await stack.asEntity(MEMBER).create(COMMENT, { text: 'hello' }, { id }); + expect(record.id).toBe(id); + }); + + test('rejects a malformed id from a grantee', async () => { + await expect( + stack.asEntity(MEMBER).create(COMMENT, { text: 'hello' }, { id: 'too-short' }), + ).rejects.toThrow(StackValidationError); + }); + + test('rejects a reserved-prefix id from a grantee', async () => { + await expect( + stack + .asEntity(MEMBER) + .create(COMMENT, { text: 'hello' }, { id: '_' + generateId().slice(1) }), + ).rejects.toThrow(StackValidationError); + }); + + test('rejects an id whose timestamp is far outside the clock-skew tolerance', async () => { + const ancientId = idWithTimestamp(new Date('2000-01-01').valueOf()); + await expect( + stack.asEntity(MEMBER).create(COMMENT, { text: 'hello' }, { id: ancientId }), + ).rejects.toThrow(StackValidationError); + }); + + test('idTimestampSkewMs: null on the Stack disables the skew check for grantees', async () => { + const permissiveAdapter = new MemoryAdapter({ ownerEntityId: OWNER, timezone: 'UTC' }); + const permissiveStack = await Stack.create(permissiveAdapter, { idTimestampSkewMs: null }); + await permissiveStack.defineType(COMMENT, 'Comment', { + text: { kind: 'text', required: true }, + }); + await permissiveStack.grant(MEMBER, [{ actions: ['create'], typeId: COMMENT }]); + + const ancientId = idWithTimestamp(new Date('2000-01-01').valueOf()); + const record = await permissiveStack + .asEntity(MEMBER) + .create(COMMENT, { text: 'hello' }, { id: ancientId }); + expect(record.id).toBe(ancientId); + }); + + test('duplicate id from a grantee surfaces as StackConflictError', async () => { + const id = generateId(); + await stack.asEntity(MEMBER).create(COMMENT, { text: 'first' }, { id }); + await expect( + stack.asEntity(MEMBER).create(COMMENT, { text: 'second' }, { id }), + ).rejects.toThrow(StackConflictError); + }); +}); + // ------------------------------------------------------- // ScopedStack — grant-based read // ------------------------------------------------------- diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 72a314d..010c93d 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -4,13 +4,20 @@ import { StackValidationError, StackMigrationError, StackNotFoundError, + StackConflictError, } from '../src/stack.js'; +import { generateId, crockford32Encode } from '../src/id.js'; import { MemoryAdapter } from '../src/testing.js'; // ------------------------------------------------------- // Test setup // ------------------------------------------------------- +// Builds a well-formed 12-char id with a specific timestamp prefix, bypassing +// generateId()'s own monotonic-clock clamp (which would otherwise pull an +// "ancient" test timestamp forward to the real current time). +const idWithTimestamp = (ms: number): string => `${crockford32Encode(ms).padStart(9, '0')}000`; + const NOTE_V1 = 'com.example.test/note@1'; const NOTE_V2 = 'com.example.test/note@2'; const NOTE_V3 = 'com.example.test/note@3'; @@ -135,6 +142,55 @@ describe('create', () => { }); }); +// ------------------------------------------------------- +// create — client-supplied id (#55) +// ------------------------------------------------------- + +describe('create — client-supplied id', () => { + test('accepts a well-formed client-supplied id', async () => { + const id = generateId(); + const record = await stack.create(NOTE_V1, { text: 'hello' }, { id }); + expect(record.id).toBe(id); + }); + + test('generates an id when none is supplied', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + expect(record.id).toBeTruthy(); + }); + + test('rejects an id with the wrong length', async () => { + await expect(stack.create(NOTE_V1, { text: 'hello' }, { id: 'too-short' })).rejects.toThrow( + StackValidationError, + ); + }); + + test('rejects an id with characters outside the Crockford charset', async () => { + await expect(stack.create(NOTE_V1, { text: 'hello' }, { id: 'UPPERCASE123' })).rejects.toThrow( + StackValidationError, + ); + }); + + test('rejects an id using the reserved "_" prefix', async () => { + await expect( + stack.create(NOTE_V1, { text: 'hello' }, { id: '_' + generateId().slice(1) }), + ).rejects.toThrow(StackValidationError); + }); + + test('rejects a duplicate id with StackConflictError', async () => { + const id = generateId(); + await stack.create(NOTE_V1, { text: 'first' }, { id }); + await expect(stack.create(NOTE_V1, { text: 'second' }, { id })).rejects.toThrow( + StackConflictError, + ); + }); + + test('unscoped Stack.create() does not apply a timestamp-skew check', async () => { + const ancientId = idWithTimestamp(new Date('2000-01-01').valueOf()); + const record = await stack.create(NOTE_V1, { text: 'hello' }, { id: ancientId }); + expect(record.id).toBe(ancientId); + }); +}); + // ------------------------------------------------------- // update — merge patch // ------------------------------------------------------- From d16f61e0c96907265a685d259789e065bb9e6f2a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:02:32 +0000 Subject: [PATCH 2/2] refactor(core): stop exposing idTimestampSkewMs as public Stack API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScopedStack needs the configured clock-skew tolerance, but the only caller of ScopedStack's constructor is Stack.asEntity() itself — so thread the resolved value through the constructor there (Stack can read its own private field) instead of adding a public getter that nothing outside the library needs to read. --- packages/core/src/stack.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index a243b64..261ad72 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -245,11 +245,6 @@ export class Stack implements StackClient { return this.adapter.capabilities; } - /** Clock-skew tolerance applied to grantee-minted IDs — see StackOptions.idTimestampSkewMs. */ - get idTimestampSkewMs(): number | null { - return this.idTimestampSkewMsValue; - } - /** * Get a permission-scoped view of this Stack, as if the request came from * the given entity. Pass null for an anonymous/unauthenticated requester. @@ -263,7 +258,7 @@ export class Stack implements StackClient { * multi-tenant API server). */ asEntity(entityId: string | null): ScopedStack { - return new ScopedStack(this, entityId); + return new ScopedStack(this, entityId, this.idTimestampSkewMsValue); } // ------------------------------------------------------- @@ -958,6 +953,7 @@ export class ScopedStack implements StackClient { constructor( private readonly stack: Stack, private readonly requesterEntityId: string | null, + private readonly idTimestampSkewMs: number | null, ) {} get features(): StackFeatures { @@ -1083,7 +1079,7 @@ export class ScopedStack implements StackClient { } if (opts.id !== undefined) { validateRecordId(opts.id); - validateIdTimestampSkew(opts.id, this.stack.idTimestampSkewMs); + validateIdTimestampSkew(opts.id, this.idTimestampSkewMs); } return this.stack.create(typeId, content, { ...opts, entityId: requester }); }