@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.
- package/package.json +2 -2
- package/vendor/spec/actions.d.ts +5 -5
- package/vendor/spec/actions.js +11 -6
- package/vendor/spec/admin.d.ts +7 -5
- package/vendor/spec/admin.js +7 -21
- package/vendor/spec/diagnostics.js +77 -26
- package/vendor/spec/import-policy.d.ts +38 -11
- package/vendor/spec/import-policy.js +30 -8
- package/vendor/spec/index.d.ts +1 -0
- package/vendor/spec/index.js +1 -0
- package/vendor/spec/local-identity.d.ts +83 -8
- package/vendor/spec/local-identity.js +135 -11
- package/vendor/spec/page-cursor.d.ts +26 -0
- package/vendor/spec/page-cursor.js +45 -0
- package/vendor/spec/review.js +2 -0
- package/vendor/spec/route.d.ts +13 -0
- package/vendor/spec/route.js +33 -0
- package/vendor/spec/schema-lifecycle.d.ts +10 -4
- package/vendor/spec/schema-lifecycle.js +32 -4
- package/vendor/spec/schema-plan.d.ts +10 -1
- package/vendor/spec/schema-plan.js +22 -3
- package/vendor/spec/schema.d.ts +9 -0
- package/vendor/spec/schema.js +20 -0
- package/vendor/spec/state-export.d.ts +22 -0
- package/vendor/spec/state-export.js +42 -2
- package/vendor/spec/tunnel.d.ts +3 -0
- package/vendor/spec/tunnel.js +18 -0
- package/vendor/spec/type-check-profile.d.ts +67 -0
- package/vendor/spec/type-check-profile.js +78 -0
- package/vendor/spec/types.d.ts +75 -5
- package/vendor/spec/types.js +10 -0
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* are exercisable before
|
|
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
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* are exercisable before
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
+
|
|
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
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
package/vendor/spec/review.js
CHANGED
|
@@ -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({
|
package/vendor/spec/route.d.ts
CHANGED
|
@@ -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.
|
package/vendor/spec/route.js
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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;
|