@impetik/xeer-mcp 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,10 @@
2
2
  * The local persona model shared by `xeer dev`, `xeer preview`, and `xeer test`.
3
3
  *
4
4
  * A local persona is a deterministic stand-in for an identity the platform would otherwise mint in
5
- * a signed assertion. It carries the same two facts production membership carries — who the caller
6
- * is, and which workspaces they belong to — so `workspaceTable()` policies and `memberOf()` guards
7
- * are exercisable before anything is deployed.
5
+ * a signed assertion. It carries the same facts a production assertion carries — who the caller is,
6
+ * which workspaces they belong to, and which roles and permissions they hold — so `workspaceTable()`
7
+ * policies and `memberOf()`, `hasRole()`, and `hasPermission()` guards are all exercisable before
8
+ * anything is deployed.
8
9
  *
9
10
  * Everything here is a pure value contract, deliberately free of platform and Node dependencies:
10
11
  * the runtime parses these cookies, the CLI writes them, and the test host serializes them, so the
@@ -18,14 +19,30 @@
18
19
  */
19
20
  export declare const LOCAL_PERSONA_COOKIE = "xeer_local_persona";
20
21
  export declare const LOCAL_WORKSPACE_COOKIE = "xeer_local_workspaces";
22
+ /**
23
+ * The two claim cookies are siblings of {@link LOCAL_WORKSPACE_COOKIE}, carrying the other two facts
24
+ * a production assertion carries. Three cookies rather than one encoded blob, for the reason the
25
+ * membership cookie is already one: each is a comma-separated list a human can read in devtools and
26
+ * set by hand, and each fails closed on its own without taking the others down with it.
27
+ */
28
+ export declare const LOCAL_ROLE_COOKIE = "xeer_local_roles";
29
+ export declare const LOCAL_PERMISSION_COOKIE = "xeer_local_permissions";
21
30
  /** Deterministic local identities. Each persona is a distinct application user. */
22
- export type LocalPersonaName = 'alice' | 'bob' | 'guest';
31
+ export type LocalPersonaName = 'alice' | 'bob' | 'carol' | 'guest';
23
32
  export declare const LOCAL_PERSONA_NAMES: readonly LocalPersonaName[];
24
33
  /**
25
34
  * Personas the local sign-in route may select. `guest` is deliberately absent: it is the unclaimed
26
- * visitor a test uses to prove that neither alice's nor bob's rows leak, not a session to log into.
35
+ * visitor a test uses to prove that none of alice's, bob's, or carol's rows leak, not a session to
36
+ * log into.
27
37
  */
28
38
  export declare const LOCAL_SIGN_IN_PERSONA_NAMES: readonly LocalPersonaName[];
39
+ /** The provisioned personas: every local identity except the unclaimed visitor. */
40
+ export declare const LOCAL_MEMBER_PERSONA_NAMES: readonly LocalPersonaName[];
41
+ /**
42
+ * `alice, bob, carol, or guest` — the one place the persona list is spelled for a human, so adding
43
+ * a persona cannot leave a diagnostic naming a set that no longer exists.
44
+ */
45
+ export declare function localPersonaNameList(names?: readonly LocalPersonaName[]): string;
29
46
  /** The identity-assertion charset for a workspace id, so local ids stay production-shaped. */
30
47
  export declare const LOCAL_WORKSPACE_ID_PATTERN: RegExp;
31
48
  /**
@@ -33,27 +50,74 @@ export declare const LOCAL_WORKSPACE_ID_PATTERN: RegExp;
33
50
  * every browser's per-cookie budget while staying far above what a local isolation test needs.
34
51
  */
35
52
  export declare const MAXIMUM_LOCAL_WORKSPACES = 16;
53
+ /**
54
+ * The identity-assertion charset for a role or a permission. Deliberately wider than a workspace id
55
+ * and identical to what `validateTrustedIdentityAssertion` accepts, because applications already
56
+ * spell permissions `notes:write` and roles `org/admin`. `,` is excluded by the charset, which is
57
+ * what keeps a comma-separated cookie unambiguous.
58
+ */
59
+ export declare const LOCAL_CLAIM_PATTERN: RegExp;
60
+ /** Claims are a development fixture too, and the same cap keeps each cookie small. */
61
+ export declare const MAXIMUM_LOCAL_CLAIMS = 16;
36
62
  /** An unusable local membership specification. Always a builder input error, never a denial. */
37
63
  export declare class LocalWorkspaceMembershipError extends Error {
38
64
  readonly code: "invalid_local_workspace";
39
65
  constructor(message: string);
40
66
  }
67
+ /** An unusable local role or permission. Always a builder input error, never a denial. */
68
+ export declare class LocalPersonaClaimError extends Error {
69
+ readonly code: "invalid_local_claim";
70
+ constructor(message: string);
71
+ }
72
+ /** Which claim list is being spoken about, so one implementation can name itself in errors. */
73
+ export type LocalClaimKind = 'role' | 'permission';
41
74
  export declare function isLocalPersonaName(value: unknown): value is LocalPersonaName;
42
75
  /**
43
76
  * Validates and de-duplicates a declared membership set, preserving declaration order so the
44
77
  * single-workspace insert default (`preparePolicyInsert`) is predictable.
45
78
  */
46
79
  export declare function normalizeLocalWorkspaceIds(values: readonly unknown[]): readonly string[];
80
+ /**
81
+ * Validates and de-duplicates a declared role or permission set, preserving declaration order for
82
+ * the same reason membership does: what a builder wrote is what a diagnostic reads back.
83
+ */
84
+ export declare function normalizeLocalClaims(kind: LocalClaimKind, values: readonly unknown[]): readonly string[];
85
+ /** Everything a local persona can be granted beyond its name. */
86
+ export interface LocalPersonaClaims {
87
+ readonly roles?: readonly unknown[];
88
+ readonly permissions?: readonly unknown[];
89
+ }
90
+ /** The resolved grant: three lists, each already validated and in declaration order. */
91
+ export interface ResolvedLocalPersonaClaims {
92
+ readonly roles: readonly string[];
93
+ readonly permissions: readonly string[];
94
+ }
47
95
  /**
48
96
  * The one place the persona/membership pairing rule lives: only a provisioned identity can hold
49
97
  * membership, and `guest` is by definition unprovisioned. A local persona that holds membership is
50
98
  * therefore minted as `kind: "user"` — see `localPersonaAssertion` in the runtime.
51
99
  */
52
100
  export declare function normalizeLocalMembership(persona: LocalPersonaName, workspaceIds: readonly unknown[]): readonly string[];
53
- /** True when a persona holding this membership is a provisioned user rather than a guest. */
54
- export declare function localPersonaKind(workspaceIds: readonly string[]): 'guest' | 'user';
101
+ /**
102
+ * The same pairing rule, applied to roles and permissions. Production issues a role to a
103
+ * provisioned identity and to nothing else, so a `guest` holding one would be a local-only fiction
104
+ * that no deployment could reproduce — and the guard it made pass would still refuse in production.
105
+ */
106
+ export declare function normalizeLocalPersonaClaims(persona: LocalPersonaName, claims?: LocalPersonaClaims): ResolvedLocalPersonaClaims;
107
+ /**
108
+ * True when a persona holding this grant is a provisioned user rather than a guest.
109
+ *
110
+ * Any claim promotes, not membership alone: production assigns a workspace, a role, and a
111
+ * permission by the same act of provisioning an identity, so a persona that holds a role but no
112
+ * workspace is still someone the platform knows. Without that, `[authenticated(), hasRole('admin')]`
113
+ * would be untestable locally without inventing a workspace the application never declared.
114
+ * A persona granted nothing is unchanged: `as('alice')` is still a guest.
115
+ */
116
+ export declare function localPersonaKind(workspaceIds: readonly string[], roles?: readonly string[], permissions?: readonly string[]): 'guest' | 'user';
55
117
  /** Parses one comma-separated membership list, as written in a cookie or a query parameter. */
56
118
  export declare function parseLocalWorkspaceList(value: string | null | undefined): readonly string[];
119
+ /** Parses one comma-separated claim list, as written in a cookie or a query parameter. */
120
+ export declare function parseLocalClaimList(kind: LocalClaimKind, value: string | null | undefined): readonly string[];
57
121
  /** The persona a session chose, or null when it made no choice and the baked default applies. */
58
122
  export declare function readLocalPersonaCookie(cookie: string | null | undefined): LocalPersonaName | null;
59
123
  /**
@@ -61,9 +125,20 @@ export declare function readLocalPersonaCookie(cookie: string | null | undefined
61
125
  * identity fails closed, exactly as an assertion with an empty `workspaceIds` does.
62
126
  */
63
127
  export declare function readLocalWorkspaceCookie(cookie: string | null | undefined): readonly string[];
128
+ /**
129
+ * The roles a session was granted. An absent, empty, or malformed cookie is no roles: a claim
130
+ * cookie fails closed exactly as the membership cookie does, so tampering can only ever narrow.
131
+ */
132
+ export declare function readLocalRoleCookie(cookie: string | null | undefined): readonly string[];
133
+ /** The permissions a session was granted, read with the same fail-closed rule as roles. */
134
+ export declare function readLocalPermissionCookie(cookie: string | null | undefined): readonly string[];
64
135
  export declare function localWorkspaceCookieValue(workspaceIds: readonly string[]): string;
136
+ export declare function localClaimCookieValue(kind: LocalClaimKind, values: readonly string[]): string;
65
137
  /**
66
138
  * The complete local identity selection as a single `cookie` request header. `xeer test` sends this
67
139
  * for every call, so a test persona reaches the runtime through the same seam a browser uses.
140
+ *
141
+ * `claims` is a third argument rather than three positional lists so that adding a fourth fact to
142
+ * the local identity later does not move an existing caller's arguments.
68
143
  */
69
- export declare function localIdentityCookieHeader(persona: LocalPersonaName, workspaceIds?: readonly string[]): string;
144
+ export declare function localIdentityCookieHeader(persona: LocalPersonaName, workspaceIds?: readonly string[], claims?: LocalPersonaClaims): string;
@@ -2,9 +2,10 @@
2
2
  * The local persona model shared by `xeer dev`, `xeer preview`, and `xeer test`.
3
3
  *
4
4
  * A local persona is a deterministic stand-in for an identity the platform would otherwise mint in
5
- * a signed assertion. It carries the same two facts production membership carries — who the caller
6
- * is, and which workspaces they belong to — so `workspaceTable()` policies and `memberOf()` guards
7
- * are exercisable before anything is deployed.
5
+ * a signed assertion. It carries the same facts a production assertion carries — who the caller is,
6
+ * which workspaces they belong to, and which roles and permissions they hold — so `workspaceTable()`
7
+ * policies and `memberOf()`, `hasRole()`, and `hasPermission()` guards are all exercisable before
8
+ * anything is deployed.
8
9
  *
9
10
  * Everything here is a pure value contract, deliberately free of platform and Node dependencies:
10
11
  * the runtime parses these cookies, the CLI writes them, and the test host serializes them, so the
@@ -18,12 +19,34 @@
18
19
  */
19
20
  export const LOCAL_PERSONA_COOKIE = 'xeer_local_persona';
20
21
  export const LOCAL_WORKSPACE_COOKIE = 'xeer_local_workspaces';
21
- export const LOCAL_PERSONA_NAMES = Object.freeze(['alice', 'bob', 'guest']);
22
+ /**
23
+ * The two claim cookies are siblings of {@link LOCAL_WORKSPACE_COOKIE}, carrying the other two facts
24
+ * a production assertion carries. Three cookies rather than one encoded blob, for the reason the
25
+ * membership cookie is already one: each is a comma-separated list a human can read in devtools and
26
+ * set by hand, and each fails closed on its own without taking the others down with it.
27
+ */
28
+ export const LOCAL_ROLE_COOKIE = 'xeer_local_roles';
29
+ export const LOCAL_PERMISSION_COOKIE = 'xeer_local_permissions';
30
+ export const LOCAL_PERSONA_NAMES = Object.freeze(['alice', 'bob', 'carol', 'guest']);
22
31
  /**
23
32
  * Personas the local sign-in route may select. `guest` is deliberately absent: it is the unclaimed
24
- * visitor a test uses to prove that neither alice's nor bob's rows leak, not a session to log into.
33
+ * visitor a test uses to prove that none of alice's, bob's, or carol's rows leak, not a session to
34
+ * log into.
35
+ */
36
+ export const LOCAL_SIGN_IN_PERSONA_NAMES = Object.freeze(['alice', 'bob', 'carol']);
37
+ /** The provisioned personas: every local identity except the unclaimed visitor. */
38
+ export const LOCAL_MEMBER_PERSONA_NAMES = Object.freeze(['alice', 'bob', 'carol']);
39
+ /**
40
+ * `alice, bob, carol, or guest` — the one place the persona list is spelled for a human, so adding
41
+ * a persona cannot leave a diagnostic naming a set that no longer exists.
25
42
  */
26
- export const LOCAL_SIGN_IN_PERSONA_NAMES = Object.freeze(['alice', 'bob']);
43
+ export function localPersonaNameList(names = LOCAL_PERSONA_NAMES) {
44
+ if (names.length < 2)
45
+ return names.join('');
46
+ const last = names[names.length - 1];
47
+ const rest = names.slice(0, -1);
48
+ return names.length === 2 ? `${rest[0]} or ${last}` : `${rest.join(', ')}, or ${last}`;
49
+ }
27
50
  /** The identity-assertion charset for a workspace id, so local ids stay production-shaped. */
28
51
  export const LOCAL_WORKSPACE_ID_PATTERN = /^[A-Za-z0-9:_-]{1,96}$/u;
29
52
  /**
@@ -31,6 +54,15 @@ export const LOCAL_WORKSPACE_ID_PATTERN = /^[A-Za-z0-9:_-]{1,96}$/u;
31
54
  * every browser's per-cookie budget while staying far above what a local isolation test needs.
32
55
  */
33
56
  export const MAXIMUM_LOCAL_WORKSPACES = 16;
57
+ /**
58
+ * The identity-assertion charset for a role or a permission. Deliberately wider than a workspace id
59
+ * and identical to what `validateTrustedIdentityAssertion` accepts, because applications already
60
+ * spell permissions `notes:write` and roles `org/admin`. `,` is excluded by the charset, which is
61
+ * what keeps a comma-separated cookie unambiguous.
62
+ */
63
+ export const LOCAL_CLAIM_PATTERN = /^[A-Za-z0-9._:@/-]{1,128}$/u;
64
+ /** Claims are a development fixture too, and the same cap keeps each cookie small. */
65
+ export const MAXIMUM_LOCAL_CLAIMS = 16;
34
66
  /** An unusable local membership specification. Always a builder input error, never a denial. */
35
67
  export class LocalWorkspaceMembershipError extends Error {
36
68
  code = 'invalid_local_workspace';
@@ -39,6 +71,17 @@ export class LocalWorkspaceMembershipError extends Error {
39
71
  this.name = 'LocalWorkspaceMembershipError';
40
72
  }
41
73
  }
74
+ /** An unusable local role or permission. Always a builder input error, never a denial. */
75
+ export class LocalPersonaClaimError extends Error {
76
+ code = 'invalid_local_claim';
77
+ constructor(message) {
78
+ super(message);
79
+ this.name = 'LocalPersonaClaimError';
80
+ }
81
+ }
82
+ function claimPlural(kind) {
83
+ return kind === 'role' ? 'roles' : 'permissions';
84
+ }
42
85
  export function isLocalPersonaName(value) {
43
86
  return typeof value === 'string' && LOCAL_PERSONA_NAMES.includes(value);
44
87
  }
@@ -60,6 +103,24 @@ export function normalizeLocalWorkspaceIds(values) {
60
103
  }
61
104
  return Object.freeze(normalized);
62
105
  }
106
+ /**
107
+ * Validates and de-duplicates a declared role or permission set, preserving declaration order for
108
+ * the same reason membership does: what a builder wrote is what a diagnostic reads back.
109
+ */
110
+ export function normalizeLocalClaims(kind, values) {
111
+ const normalized = [];
112
+ for (const value of values) {
113
+ if (typeof value !== 'string' || !LOCAL_CLAIM_PATTERN.test(value)) {
114
+ throw new LocalPersonaClaimError(`Invalid ${kind} ${JSON.stringify(String(value))}: use 1-128 characters from A-Z, a-z, 0-9, '.', '_', ':', '@', '/', and '-'.`);
115
+ }
116
+ if (!normalized.includes(value))
117
+ normalized.push(value);
118
+ }
119
+ if (normalized.length > MAXIMUM_LOCAL_CLAIMS) {
120
+ throw new LocalPersonaClaimError(`A local persona may hold at most ${MAXIMUM_LOCAL_CLAIMS} ${claimPlural(kind)}; ${normalized.length} were declared.`);
121
+ }
122
+ return Object.freeze(normalized);
123
+ }
63
124
  /**
64
125
  * The one place the persona/membership pairing rule lives: only a provisioned identity can hold
65
126
  * membership, and `guest` is by definition unprovisioned. A local persona that holds membership is
@@ -69,13 +130,35 @@ export function normalizeLocalMembership(persona, workspaceIds) {
69
130
  const normalized = normalizeLocalWorkspaceIds(workspaceIds);
70
131
  if (persona === 'guest' && normalized.length > 0) {
71
132
  throw new LocalWorkspaceMembershipError('The guest persona cannot carry workspace membership: it is the unclaimed visitor, and '
72
- + 'membership is only ever held by a provisioned identity. Use alice or bob.');
133
+ + `membership is only ever held by a provisioned identity. Use ${localPersonaNameList(LOCAL_MEMBER_PERSONA_NAMES)}.`);
73
134
  }
74
135
  return normalized;
75
136
  }
76
- /** True when a persona holding this membership is a provisioned user rather than a guest. */
77
- export function localPersonaKind(workspaceIds) {
78
- return workspaceIds.length > 0 ? 'user' : 'guest';
137
+ /**
138
+ * The same pairing rule, applied to roles and permissions. Production issues a role to a
139
+ * provisioned identity and to nothing else, so a `guest` holding one would be a local-only fiction
140
+ * that no deployment could reproduce — and the guard it made pass would still refuse in production.
141
+ */
142
+ export function normalizeLocalPersonaClaims(persona, claims = {}) {
143
+ const roles = normalizeLocalClaims('role', claims.roles ?? []);
144
+ const permissions = normalizeLocalClaims('permission', claims.permissions ?? []);
145
+ if (persona === 'guest' && (roles.length > 0 || permissions.length > 0)) {
146
+ throw new LocalPersonaClaimError('The guest persona cannot carry roles or permissions: it is the unclaimed visitor, and a '
147
+ + `claim is only ever held by a provisioned identity. Use ${localPersonaNameList(LOCAL_MEMBER_PERSONA_NAMES)}.`);
148
+ }
149
+ return Object.freeze({ roles, permissions });
150
+ }
151
+ /**
152
+ * True when a persona holding this grant is a provisioned user rather than a guest.
153
+ *
154
+ * Any claim promotes, not membership alone: production assigns a workspace, a role, and a
155
+ * permission by the same act of provisioning an identity, so a persona that holds a role but no
156
+ * workspace is still someone the platform knows. Without that, `[authenticated(), hasRole('admin')]`
157
+ * would be untestable locally without inventing a workspace the application never declared.
158
+ * A persona granted nothing is unchanged: `as('alice')` is still a guest.
159
+ */
160
+ export function localPersonaKind(workspaceIds, roles = [], permissions = []) {
161
+ return workspaceIds.length > 0 || roles.length > 0 || permissions.length > 0 ? 'user' : 'guest';
79
162
  }
80
163
  /** Parses one comma-separated membership list, as written in a cookie or a query parameter. */
81
164
  export function parseLocalWorkspaceList(value) {
@@ -86,6 +169,15 @@ export function parseLocalWorkspaceList(value) {
86
169
  return Object.freeze([]);
87
170
  return normalizeLocalWorkspaceIds(trimmed.split(',').map((entry) => entry.trim()));
88
171
  }
172
+ /** Parses one comma-separated claim list, as written in a cookie or a query parameter. */
173
+ export function parseLocalClaimList(kind, value) {
174
+ if (value === null || value === undefined)
175
+ return Object.freeze([]);
176
+ const trimmed = value.trim();
177
+ if (trimmed === '')
178
+ return Object.freeze([]);
179
+ return normalizeLocalClaims(kind, trimmed.split(',').map((entry) => entry.trim()));
180
+ }
89
181
  function cookieValue(cookie, name) {
90
182
  if (!cookie)
91
183
  return null;
@@ -116,17 +208,49 @@ export function readLocalWorkspaceCookie(cookie) {
116
208
  return Object.freeze([]);
117
209
  }
118
210
  }
211
+ /**
212
+ * The roles a session was granted. An absent, empty, or malformed cookie is no roles: a claim
213
+ * cookie fails closed exactly as the membership cookie does, so tampering can only ever narrow.
214
+ */
215
+ export function readLocalRoleCookie(cookie) {
216
+ try {
217
+ return parseLocalClaimList('role', cookieValue(cookie, LOCAL_ROLE_COOKIE));
218
+ }
219
+ catch {
220
+ return Object.freeze([]);
221
+ }
222
+ }
223
+ /** The permissions a session was granted, read with the same fail-closed rule as roles. */
224
+ export function readLocalPermissionCookie(cookie) {
225
+ try {
226
+ return parseLocalClaimList('permission', cookieValue(cookie, LOCAL_PERMISSION_COOKIE));
227
+ }
228
+ catch {
229
+ return Object.freeze([]);
230
+ }
231
+ }
119
232
  export function localWorkspaceCookieValue(workspaceIds) {
120
233
  return normalizeLocalWorkspaceIds(workspaceIds).join(',');
121
234
  }
235
+ export function localClaimCookieValue(kind, values) {
236
+ return normalizeLocalClaims(kind, values).join(',');
237
+ }
122
238
  /**
123
239
  * The complete local identity selection as a single `cookie` request header. `xeer test` sends this
124
240
  * for every call, so a test persona reaches the runtime through the same seam a browser uses.
241
+ *
242
+ * `claims` is a third argument rather than three positional lists so that adding a fourth fact to
243
+ * the local identity later does not move an existing caller's arguments.
125
244
  */
126
- export function localIdentityCookieHeader(persona, workspaceIds = []) {
245
+ export function localIdentityCookieHeader(persona, workspaceIds = [], claims = {}) {
127
246
  const membership = normalizeLocalMembership(persona, workspaceIds);
247
+ const { roles, permissions } = normalizeLocalPersonaClaims(persona, claims);
128
248
  const pairs = [`${LOCAL_PERSONA_COOKIE}=${persona}`];
129
249
  if (membership.length > 0)
130
250
  pairs.push(`${LOCAL_WORKSPACE_COOKIE}=${membership.join(',')}`);
251
+ if (roles.length > 0)
252
+ pairs.push(`${LOCAL_ROLE_COOKIE}=${roles.join(',')}`);
253
+ if (permissions.length > 0)
254
+ pairs.push(`${LOCAL_PERMISSION_COOKIE}=${permissions.join(',')}`);
131
255
  return pairs.join('; ');
132
256
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The keyset page cursor shared by every paged read the platform serves.
3
+ *
4
+ * Every declared table is ordered by `(created_at, id)` and `id` is unique, so the pair is a total
5
+ * order and a cursor names an *exact position* in it rather than a count of rows to skip. That is
6
+ * the whole reason this is a cursor and not an `offset`: a row inserted or deleted between two
7
+ * pages shifts an offset and silently duplicates or skips a row, while a keyset position stays
8
+ * where it was pointing.
9
+ *
10
+ * Opaque by contract, and deliberately **not** signed. It encodes a position in data the caller
11
+ * already had to authenticate to reach and that the next page would have shown anyway, so a forged
12
+ * one discloses nothing — it merely starts the scan somewhere else in the caller's own rows. What
13
+ * it must do is *fail closed on garbage*, which is why decoding returns `null` rather than throwing
14
+ * or silently resetting to the first page: a caller that hands back a truncated or hand-written
15
+ * token gets a refusal it can act on, not page 1 wearing page 4's clothes.
16
+ */
17
+ export interface PageCursorPosition {
18
+ /** The last returned row's `created_at`, as the ISO-8601 text the column stores. */
19
+ readonly createdAt: string;
20
+ /** That row's id, which breaks ties within a single `created_at`. */
21
+ readonly id: string;
22
+ }
23
+ /** Mints the token that resumes a scan immediately after `(createdAt, id)`. */
24
+ export declare function encodePageCursor(createdAt: string, id: string): string;
25
+ /** The inverse, or `null` when the text is not a cursor this version wrote. */
26
+ export declare function decodePageCursor(cursor: string): PageCursorPosition | null;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The keyset page cursor shared by every paged read the platform serves.
3
+ *
4
+ * Every declared table is ordered by `(created_at, id)` and `id` is unique, so the pair is a total
5
+ * order and a cursor names an *exact position* in it rather than a count of rows to skip. That is
6
+ * the whole reason this is a cursor and not an `offset`: a row inserted or deleted between two
7
+ * pages shifts an offset and silently duplicates or skips a row, while a keyset position stays
8
+ * where it was pointing.
9
+ *
10
+ * Opaque by contract, and deliberately **not** signed. It encodes a position in data the caller
11
+ * already had to authenticate to reach and that the next page would have shown anyway, so a forged
12
+ * one discloses nothing — it merely starts the scan somewhere else in the caller's own rows. What
13
+ * it must do is *fail closed on garbage*, which is why decoding returns `null` rather than throwing
14
+ * or silently resetting to the first page: a caller that hands back a truncated or hand-written
15
+ * token gets a refusal it can act on, not page 1 wearing page 4's clothes.
16
+ */
17
+ /** Mints the token that resumes a scan immediately after `(createdAt, id)`. */
18
+ export function encodePageCursor(createdAt, id) {
19
+ const text = JSON.stringify([createdAt, id]);
20
+ return btoa(String.fromCharCode(...new TextEncoder().encode(text)))
21
+ .replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
22
+ }
23
+ /** The inverse, or `null` when the text is not a cursor this version wrote. */
24
+ export function decodePageCursor(cursor) {
25
+ try {
26
+ const padded = cursor.replaceAll('-', '+').replaceAll('_', '/');
27
+ const bytes = Uint8Array.from(atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)), (c) => c.charCodeAt(0));
28
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
29
+ if (!Array.isArray(parsed) || parsed.length !== 2)
30
+ return null;
31
+ const [createdAt, id] = parsed;
32
+ if (typeof createdAt !== 'string' || typeof id !== 'string')
33
+ return null;
34
+ // A position is only usable if its `created_at` compares the way the column does. Lexicographic
35
+ // order over ISO-8601 text *is* chronological order, but only for well-formed text: a token
36
+ // carrying `"whenever"` would otherwise seek to a position the index can never reach and return
37
+ // an empty page forever, which reads as "no more rows" rather than as the bad token it is.
38
+ if (Number.isNaN(Date.parse(createdAt)) || id.length === 0)
39
+ return null;
40
+ return { createdAt, id };
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
@@ -60,6 +60,8 @@ const schemaPlanStep = z.discriminatedUnion('kind', [
60
60
  fields: z.array(z.string().min(1)) }),
61
61
  z.strictObject({ kind: z.literal('index.change'), table: z.string().min(1), index: z.string().min(1),
62
62
  from: z.array(z.string().min(1)), to: z.array(z.string().min(1)) }),
63
+ z.strictObject({ kind: z.literal('table.idSource'), table: z.string().min(1),
64
+ from: z.enum(['runtime', 'application']), to: z.enum(['runtime', 'application']) }),
63
65
  z.strictObject({ kind: z.literal('unknown.change'), path: z.string().min(1) }),
64
66
  ]);
65
67
  const schemaPlan = z.strictObject({
@@ -10,6 +10,19 @@ export interface EndpointRoute {
10
10
  export declare function parseEndpointRoute(key: string): EndpointRoute | null;
11
11
  export declare function endpointRouteIdentity(route: Pick<EndpointRoute, 'method' | 'shape'>): string;
12
12
  export declare function endpointRouteMatches(route: Pick<EndpointRoute, 'method' | 'path'>, method: string, pathname: string): boolean;
13
+ /**
14
+ * The path parameters a matched request carries, keyed by the names the route declared.
15
+ *
16
+ * This is the value half of what `Params<Key>` in the SDK describes at the type level, and it is
17
+ * derived from the same route the dispatcher matched with — not from a second pattern written by
18
+ * hand. A route with no dynamic segments yields an empty object rather than `null`, because an
19
+ * endpoint always has parameters; a static one simply has none.
20
+ *
21
+ * Segments are percent-decoded, and a segment that is not valid percent-encoding is handed over
22
+ * verbatim instead of throwing: a malformed URL is the caller's problem to answer with a status,
23
+ * not a reason for the runtime to turn the request into a 500.
24
+ */
25
+ export declare function endpointRouteParams(route: Pick<EndpointRoute, 'path'>, pathname: string): Readonly<Record<string, string>>;
13
26
  export declare function normalizeEndpointRoutes(keys: Iterable<string>): EndpointRoute[];
14
27
  /**
15
28
  * The reserved runtime route that serves the live invalidation stream.
@@ -30,6 +30,39 @@ export function endpointRouteMatches(route, method, pathname) {
30
30
  const actual = pathname.split('/');
31
31
  return expected.length === actual.length && expected.every((part, index) => part.startsWith(':') ? (actual[index]?.length ?? 0) > 0 : part === actual[index]);
32
32
  }
33
+ /**
34
+ * The path parameters a matched request carries, keyed by the names the route declared.
35
+ *
36
+ * This is the value half of what `Params<Key>` in the SDK describes at the type level, and it is
37
+ * derived from the same route the dispatcher matched with — not from a second pattern written by
38
+ * hand. A route with no dynamic segments yields an empty object rather than `null`, because an
39
+ * endpoint always has parameters; a static one simply has none.
40
+ *
41
+ * Segments are percent-decoded, and a segment that is not valid percent-encoding is handed over
42
+ * verbatim instead of throwing: a malformed URL is the caller's problem to answer with a status,
43
+ * not a reason for the runtime to turn the request into a 500.
44
+ */
45
+ export function endpointRouteParams(route, pathname) {
46
+ const expected = route.path.split('/');
47
+ const actual = pathname.split('/');
48
+ // No prototype: under the open-record fallback a caller-supplied name must never resolve to
49
+ // `Object.prototype.toString` where the types promise `string | undefined`.
50
+ const params = Object.create(null);
51
+ for (const [index, part] of expected.entries()) {
52
+ if (!part.startsWith(':'))
53
+ continue;
54
+ params[part.slice(1)] = decodeSegment(actual[index] ?? '');
55
+ }
56
+ return Object.freeze(params);
57
+ }
58
+ function decodeSegment(value) {
59
+ try {
60
+ return decodeURIComponent(value);
61
+ }
62
+ catch {
63
+ return value;
64
+ }
65
+ }
33
66
  export function normalizeEndpointRoutes(keys) {
34
67
  const routes = [];
35
68
  const identities = new Map();
@@ -18,10 +18,16 @@ export declare function schemaIdentityReference(): NormalizedDatabaseSchemaV0;
18
18
  * one to upgrade.
19
19
  *
20
20
  * Derived rather than declared, so it cannot be forgotten: adding, removing or renaming a key in
21
- * `normalizedSchemaPayload` moves this hash in the same commit that moves it, because every key is
22
- * emitted unconditionally and the reference declares one of each. What it cannot see is a change to how
23
- * a value is *derived* for a shape the reference does not declare — index normalization has its own
24
- * spellings, and only the ones written here are covered.
21
+ * `normalizedSchemaPayload` moves this hash in the same commit that moves it, because the reference
22
+ * declares a value for every key — and, for a key the projection omits when it is defaulted, one table
23
+ * holding the default beside one that does not. What it cannot see is a change to how a value is
24
+ * *derived* for a shape the reference does not declare — index normalization has its own spellings, and
25
+ * only the ones written here are covered.
26
+ *
27
+ * This value moving between releases is expected and costs nothing on its own: it is quoted in a
28
+ * refusal, never compared for equality. What a release must not do is move an *application's*
29
+ * `schemaHash` without that application changing, which is a different question and the reason
30
+ * `idSource` is omitted when defaulted.
25
31
  */
26
32
  export declare function schemaIdentityRevision(): `sha256:${string}`;
27
33
  export declare function planSchemaChange(application: string, fromSchema: NormalizedDatabaseSchemaV0, toSchema: NormalizedDatabaseSchemaV0): SchemaPlanV0;
@@ -44,6 +44,19 @@ function normalizedSchemaPayload(schema) {
44
44
  unique: (table.unique ?? []).map((tuple) => [...tuple]),
45
45
  checks: Object.fromEntries(Object.keys(table.checks ?? {}).sort()
46
46
  .map((checkName) => [checkName, table.checks[checkName]])),
47
+ // The one key here that is *omitted* when it holds its default, rather than materialized as
48
+ // every key above is. Both conventions are right for what they cover, and which one applies
49
+ // is decided by whether the key existed when the schemas being compared were written.
50
+ //
51
+ // A materialized default is what lets two spellings of the same declaration hash alike:
52
+ // `optional: false` and an absent `optional` are one column, and writing `false` for both
53
+ // says so. That works because every schema, of every age, has an answer for `optional`.
54
+ // `idSource` does not: a schema written before decision 0006 has no opinion about it, and
55
+ // materializing `"runtime"` for those would move the hash of every application that never
56
+ // asked for the capability — which is the `storage?` rule in `artifact-v0.md` read from the
57
+ // other side. Adding a capability must not change the identity of applications that do not
58
+ // use it, so a table that does not use this one contributes exactly the bytes it did before.
59
+ ...(table.idSource === undefined || table.idSource === 'runtime' ? {} : { idSource: table.idSource }),
47
60
  }];
48
61
  })),
49
62
  };
@@ -70,6 +83,13 @@ export function applicationSchemaIdentity(schema) {
70
83
  * would each let a projection that read only the first of them hash this schema exactly as a correct
71
84
  * one does.
72
85
  *
86
+ * **Both values of a key the projection omits when it is defaulted.** `idSource` is the one key that is
87
+ * absent from the payload when it holds its default, so a single spelling of it would leave half the
88
+ * ways the projection can change invisible. `reference` declares `"application"`, which a build that
89
+ * stopped reading the key at all would drop; `beta` declares `"runtime"`, which contributes nothing
90
+ * today and would start contributing the moment a build stopped omitting the default. One of each is
91
+ * what makes both directions move this hash.
92
+ *
73
93
  * **Every order-preserving array declared in an order sorting would change.** Object keys are sorted by
74
94
  * canonical JSON and cannot carry order, but four arrays can and all four are semantic: an enum's
75
95
  * values, the outer and inner arrays of a composite UNIQUE, and an index's columns — `UNIQUE (a, b)`
@@ -107,12 +127,14 @@ const SCHEMA_IDENTITY_REFERENCE = {
107
127
  },
108
128
  unique: [['plain', 'number'], ['optional', 'maxLength']],
109
129
  checks: { positive: 'number > 0', bounded: 'maxLength IS NOT NULL' },
130
+ idSource: 'application',
110
131
  },
111
132
  beta: {
112
133
  fields: { second: { type: 'string' }, first: { type: 'number' } },
113
134
  indexes: { by_second: ['second'], by_first: ['first'] },
114
135
  unique: [['second', 'first']],
115
136
  checks: { nonzero: 'first <> 0', named: 'second <> \'\'' },
137
+ idSource: 'runtime',
116
138
  },
117
139
  },
118
140
  };
@@ -133,10 +155,16 @@ export function schemaIdentityReference() {
133
155
  * one to upgrade.
134
156
  *
135
157
  * Derived rather than declared, so it cannot be forgotten: adding, removing or renaming a key in
136
- * `normalizedSchemaPayload` moves this hash in the same commit that moves it, because every key is
137
- * emitted unconditionally and the reference declares one of each. What it cannot see is a change to how
138
- * a value is *derived* for a shape the reference does not declare — index normalization has its own
139
- * spellings, and only the ones written here are covered.
158
+ * `normalizedSchemaPayload` moves this hash in the same commit that moves it, because the reference
159
+ * declares a value for every key — and, for a key the projection omits when it is defaulted, one table
160
+ * holding the default beside one that does not. What it cannot see is a change to how a value is
161
+ * *derived* for a shape the reference does not declare — index normalization has its own spellings, and
162
+ * only the ones written here are covered.
163
+ *
164
+ * This value moving between releases is expected and costs nothing on its own: it is quoted in a
165
+ * refusal, never compared for equality. What a release must not do is move an *application's*
166
+ * `schemaHash` without that application changing, which is a different question and the reason
167
+ * `idSource` is omitted when defaulted.
140
168
  */
141
169
  export function schemaIdentityRevision() {
142
170
  return applicationSchemaHash(SCHEMA_IDENTITY_REFERENCE);
@@ -1,4 +1,4 @@
1
- import type { ApplicationSchemaIdentityV0, NormalizedApplicationManifestV0, ScalarType } from './types.js';
1
+ import type { ApplicationSchemaIdentityV0, NormalizedApplicationManifestV0, ScalarType, TableDefinition } from './types.js';
2
2
  export declare const SCHEMA_PROTOCOL: "xeer.schema.v0";
3
3
  export type NormalizedDatabaseSchemaV0 = NormalizedApplicationManifestV0['database'];
4
4
  /**
@@ -8,6 +8,10 @@ export type NormalizedDatabaseSchemaV0 = NormalizedApplicationManifestV0['databa
8
8
  */
9
9
  export declare const FIELD_ATTRIBUTES: readonly ["collate", "default", "enum", "generated", "onDelete", "onUpdate", "table", "unique"];
10
10
  export type FieldAttribute = (typeof FIELD_ATTRIBUTES)[number];
11
+ /** Table keys with a step of their own, beside `fields`, `indexes`, `unique` and `checks`. */
12
+ export type IdSource = NonNullable<TableDefinition['idSource']>;
13
+ /** The default a table that says nothing about its ids gets, in the one place the plan reads it. */
14
+ export declare function tableIdSource(table: TableDefinition): IdSource;
11
15
  export type SchemaPlanStepV0 = {
12
16
  kind: 'table.add';
13
17
  table: string;
@@ -20,6 +24,11 @@ export type SchemaPlanStepV0 = {
20
24
  constraint: 'unique' | 'checks';
21
25
  from: unknown;
22
26
  to: unknown;
27
+ } | {
28
+ kind: 'table.idSource';
29
+ table: string;
30
+ from: IdSource;
31
+ to: IdSource;
23
32
  } | {
24
33
  kind: 'field.add';
25
34
  table: string;