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
37 changes: 28 additions & 9 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -663,15 +680,15 @@ Authorization: Bearer <token>

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.

Expand Down Expand Up @@ -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

```
Expand Down
46 changes: 42 additions & 4 deletions packages/core/src/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
// -------------------------------------------------------
Expand All @@ -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));
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
export type {
StackClient,
CreateRecordOptions,
StackOptions,
GetRecordOptions,
DeleteRecordOptions,
} from './stack.js';
Expand Down Expand Up @@ -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';
100 changes: 94 additions & 6 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,13 +44,31 @@ 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;
permissions?: Permission[];
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;
Expand Down Expand Up @@ -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
// -------------------------------------------------------
Expand Down Expand Up @@ -145,19 +210,25 @@ export interface StackClient {
export class Stack implements StackClient {
private readonly migrations = new Map<TypeId, Migration>();

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<Stack> {
static async create(adapter: StackAdapter, opts: StackOptions = {}): Promise<Stack> {
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;
}
Expand Down Expand Up @@ -187,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);
}

// -------------------------------------------------------
Expand Down Expand Up @@ -386,9 +457,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,
Expand Down Expand Up @@ -875,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 {
Expand Down Expand Up @@ -982,6 +1061,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<T extends Record<string, unknown> = Record<string, unknown>>(
typeId: TypeId,
Expand All @@ -993,6 +1077,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.idTimestampSkewMs);
}
return this.stack.create(typeId, content, { ...opts, entityId: requester });
}

Expand Down
Loading
Loading