@impetik/xeer-mcp 0.2.16 → 0.2.18

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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The per-zone import policy: the one hand-maintained statement of what a bare specifier may mean
3
+ * in each zone (#212).
4
+ *
5
+ * Before this module existed the same facts lived in five independently maintained forms — the
6
+ * analyzer's client-package allowlist, the platform type-module resolver, the build-time runtime
7
+ * aliases and their entrypoint-matching regex, the dev server's alias/dedupe/optimizeDeps block,
8
+ * and the diagnostics prose — and issue #210 was the drift that duplication invites: validation
9
+ * accepted an import that bundling then resolved to a second Preact instance. Every one of those
10
+ * consumers now derives from the two policy instances declared here.
11
+ *
12
+ * Deliberately free of I/O and of `require.resolve`: this module states *policy*, and the compiler
13
+ * turns it into resolved file paths against the platform's own installed renderer (its "anchor").
14
+ * Keeping it in the spec package lets the diagnostics catalogue render its guidance from the same
15
+ * source, and vendoring folds it into the published tarball with everything else.
16
+ */
17
+ /**
18
+ * The normalized client runtime: the only implemented provider/source combination. `client.runtime`
19
+ * in the manifest is optional and omission normalizes to exactly this value, so declaring it is a
20
+ * statement of the default rather than a choice.
21
+ */
22
+ export const NORMALIZED_CLIENT_RUNTIME = Object.freeze({
23
+ provider: 'preact',
24
+ source: 'platform',
25
+ });
26
+ /** The normalized client runtime as compiler facts and artifact provenance spell it. */
27
+ export const CLIENT_RUNTIME_ID = `${NORMALIZED_CLIENT_RUNTIME.provider}/${NORMALIZED_CLIENT_RUNTIME.source}`;
28
+ /**
29
+ * Default and advisory budgets for the open client bundle, minified pre-gzip bytes of the single
30
+ * JavaScript chunk. The error tier is overridable through manifest `budgets.clientBundleBytes`
31
+ * under the existing normalization pattern, bounded by the 10 MiB module ceiling; the advisory tier
32
+ * is fixed. Values are a product decision recorded in #212.
33
+ */
34
+ export const CLIENT_BUNDLE_ERROR_BUDGET_BYTES = 1024 * 1024;
35
+ export const CLIENT_BUNDLE_ADVISORY_BUDGET_BYTES = 400 * 1024;
36
+ /**
37
+ * Every public entrypoint of the platform Preact package, classified. The compiler's conformance
38
+ * test compares this map against the installed package's `exports` map in both directions, so a
39
+ * Preact upgrade that adds, removes, or renames an entrypoint fails until it is deliberately
40
+ * classified here.
41
+ */
42
+ const PREACT_ENTRYPOINTS = new Map([
43
+ ['preact', 'runtime'],
44
+ ['preact/hooks', 'runtime'],
45
+ ['preact/jsx-runtime', 'runtime'],
46
+ ['preact/jsx-dev-runtime', 'runtime'],
47
+ ['preact/compat', 'runtime'],
48
+ ['preact/compat/client', 'runtime'],
49
+ // Browser-safe server rendering: both resolve to the browser build under the client conditions.
50
+ ['preact/compat/server', 'runtime'],
51
+ ['preact/compat/server.browser', 'runtime'],
52
+ ['preact/compat/jsx-runtime', 'runtime'],
53
+ ['preact/compat/jsx-dev-runtime', 'runtime'],
54
+ ['preact/compat/scheduler', 'runtime'],
55
+ // Side-effectful development surfaces: they mutate the shared renderer options, which is exactly
56
+ // why they must be pinned to the same singleton as everything else.
57
+ ['preact/debug', 'development'],
58
+ ['preact/devtools', 'development'],
59
+ ['preact/test-utils', 'test'],
60
+ ['preact/compat/test-utils', 'test'],
61
+ ['preact/package.json', 'metadata'],
62
+ ['preact/compat/package.json', 'metadata'],
63
+ ['preact/debug/package.json', 'metadata'],
64
+ ['preact/devtools/package.json', 'metadata'],
65
+ ['preact/hooks/package.json', 'metadata'],
66
+ ['preact/test-utils/package.json', 'metadata'],
67
+ ['preact/jsx-runtime/package.json', 'metadata'],
68
+ ]);
69
+ /**
70
+ * The complete per-subpath React-family alias table. Every key is pinned to its target for every
71
+ * importer — application code, SDK, JSX runtime, generated bootstrap, and bundled dependencies'
72
+ * own imports alike — so a React-ecosystem library and the platform renderer can never diverge
73
+ * into two instances. The installed Preact publishes a matching export for every target.
74
+ */
75
+ const REACT_COMPAT_ALIASES = new Map([
76
+ ['react', 'preact/compat'],
77
+ ['react/jsx-runtime', 'preact/compat/jsx-runtime'],
78
+ ['react/jsx-dev-runtime', 'preact/compat/jsx-dev-runtime'],
79
+ ['react-dom', 'preact/compat'],
80
+ ['react-dom/client', 'preact/compat/client'],
81
+ ['react-dom/server', 'preact/compat/server'],
82
+ ['react-dom/server.browser', 'preact/compat/server.browser'],
83
+ ['react-dom/test-utils', 'preact/compat/test-utils'],
84
+ ['scheduler', 'preact/compat/scheduler'],
85
+ ]);
86
+ /** The client renderer family under the Preact provider. */
87
+ export const PREACT_RENDERER_FAMILY = Object.freeze({
88
+ anchorPackage: 'preact',
89
+ packages: Object.freeze(['preact', 'react', 'react-dom', 'scheduler']),
90
+ entrypoints: PREACT_ENTRYPOINTS,
91
+ compatAliases: REACT_COMPAT_ALIASES,
92
+ jsx: Object.freeze({
93
+ importSource: '@impetik/xeer',
94
+ runtime: 'preact/jsx-runtime',
95
+ devRuntime: 'preact/jsx-dev-runtime',
96
+ }),
97
+ });
98
+ const CLIENT_PLATFORM_MODULES = Object.freeze([
99
+ '@impetik/xeer/client',
100
+ '@impetik/xeer/client/core',
101
+ '@impetik/xeer/shared',
102
+ '@impetik/xeer/jsx-runtime',
103
+ '@impetik/xeer/jsx-dev-runtime',
104
+ ]);
105
+ const SERVER_PLATFORM_MODULES = Object.freeze([
106
+ '@impetik/xeer/server',
107
+ '@impetik/xeer/shared',
108
+ ]);
109
+ export const CLIENT_IMPORT_POLICY = Object.freeze({
110
+ zone: 'client',
111
+ platformModules: CLIENT_PLATFORM_MODULES,
112
+ managedFamilies: Object.freeze([PREACT_RENDERER_FAMILY]),
113
+ foreignFamilies: Object.freeze([]),
114
+ applicationDependencies: Object.freeze({
115
+ mode: 'open',
116
+ conditions: Object.freeze(['browser', 'import']),
117
+ defines: Object.freeze({ 'process.env.NODE_ENV': '"production"' }),
118
+ bundleBudget: Object.freeze({
119
+ errorBytes: CLIENT_BUNDLE_ERROR_BUDGET_BYTES,
120
+ advisoryBytes: CLIENT_BUNDLE_ADVISORY_BUDGET_BYTES,
121
+ }),
122
+ }),
123
+ });
124
+ /**
125
+ * The server zone, open to application dependencies since ADR 0005 was accepted (#244).
126
+ *
127
+ * The two zones now state the same rule — declare it, install it, type it, use it — and differ only
128
+ * where the *runtime* differs. `workerd` conditions are declared because that is what the server
129
+ * executes in, so a package that publishes a worker build gets it. What stays refused on the server
130
+ * is refused for one of two reasons, and the distinction is load-bearing for both the diagnostic's
131
+ * wording and cross-zone attribution:
132
+ *
133
+ * - **Impossible**: Node builtins and native addons. workerd provides neither, and no policy change
134
+ * can conjure them. Refused in *every* zone, so it says nothing about which zone a module is in.
135
+ * - **Zone-exclusive**: the renderer family, and `@impetik/xeer/client*`. Perfectly loadable here;
136
+ * simply the other zone's surface. This is what attribution reads as evidence.
137
+ *
138
+ * No byte budget: unlike the client bundle, the server module is not shipped to a browser on every
139
+ * cold navigation, so the 10 MiB v0 module ceiling (XE1401) is the only bound the platform has a
140
+ * defensible number for. A per-zone budget is a product decision, not a gap this change may fill.
141
+ */
142
+ export const SERVER_IMPORT_POLICY = Object.freeze({
143
+ zone: 'server',
144
+ platformModules: SERVER_PLATFORM_MODULES,
145
+ managedFamilies: Object.freeze([]),
146
+ foreignFamilies: Object.freeze([PREACT_RENDERER_FAMILY]),
147
+ applicationDependencies: Object.freeze({
148
+ mode: 'open',
149
+ conditions: Object.freeze(['worker', 'import']),
150
+ defines: Object.freeze({}),
151
+ bundleBudget: null,
152
+ }),
153
+ });
154
+ export function zoneImportPolicy(zone) {
155
+ return zone === 'client' ? CLIENT_IMPORT_POLICY : SERVER_IMPORT_POLICY;
156
+ }
157
+ /** The bare package name of a specifier: the first segment, or the first two for a scope. */
158
+ export function packageNameOfSpecifier(specifier) {
159
+ const segments = specifier.split('/');
160
+ return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
161
+ }
162
+ /**
163
+ * Classifies one bare specifier under a zone's policy. Node builtins and native addons are the
164
+ * caller's concern (the analyzer already owns those, and they are refused in every zone); everything
165
+ * else — platform subpaths, managed and foreign families, and the open set — is answered here so no
166
+ * consumer keeps a private copy of the rules.
167
+ */
168
+ export function admitBareImport(policy, specifier) {
169
+ if (policy.platformModules.includes(specifier))
170
+ return { kind: 'platform' };
171
+ if (specifier === '@impetik/xeer' || specifier.startsWith('@impetik/xeer/')) {
172
+ return { kind: 'rejected', reason: 'platform-subpath' };
173
+ }
174
+ const packageName = packageNameOfSpecifier(specifier);
175
+ for (const family of policy.managedFamilies) {
176
+ if (!family.packages.includes(packageName))
177
+ continue;
178
+ const target = family.compatAliases.get(specifier) ?? specifier;
179
+ const classification = family.entrypoints.get(target);
180
+ if (!classification)
181
+ return { kind: 'managed-rejected', reason: 'unknown-entrypoint' };
182
+ if (classification === 'test')
183
+ return { kind: 'managed-rejected', reason: 'test-only' };
184
+ if (classification === 'metadata')
185
+ return { kind: 'managed-rejected', reason: 'metadata' };
186
+ return { kind: 'managed', classification, target };
187
+ }
188
+ // Before the open set, or an installed renderer would be admitted by the declaration invariant it
189
+ // satisfies in every application that has a client.
190
+ for (const family of policy.foreignFamilies) {
191
+ if (family.packages.includes(packageName))
192
+ return { kind: 'rejected', reason: 'foreign-family' };
193
+ }
194
+ if (policy.applicationDependencies.mode === 'open')
195
+ return { kind: 'open' };
196
+ return { kind: 'rejected', reason: 'closed' };
197
+ }
198
+ /**
199
+ * The admitted managed-family surface of a zone, alias keys included — what bundling and the dev
200
+ * server must pin to the anchor, and what documentation lists as the renderer surface.
201
+ */
202
+ export function managedImportSpecifiers(policy) {
203
+ const result = new Map();
204
+ for (const family of policy.managedFamilies) {
205
+ for (const [specifier, classification] of family.entrypoints) {
206
+ if (classification === 'runtime' || classification === 'development')
207
+ result.set(specifier, specifier);
208
+ }
209
+ for (const [alias, target] of family.compatAliases) {
210
+ const classification = family.entrypoints.get(target);
211
+ if (classification === 'runtime' || classification === 'development')
212
+ result.set(alias, target);
213
+ }
214
+ }
215
+ return result;
216
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './diagnostics.js';
3
+ export * from './import-policy.js';
3
4
  export * from './schema.js';
4
5
  export * from './storage.js';
5
6
  export * from './canonical.js';
@@ -20,3 +21,4 @@ export * from './network-policy.js';
20
21
  export * from './review.js';
21
22
  export * from './template.js';
22
23
  export * from './tunnel.js';
24
+ export * from './type-check-profile.js';
@@ -1,5 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './diagnostics.js';
3
+ export * from './import-policy.js';
3
4
  export * from './schema.js';
4
5
  export * from './storage.js';
5
6
  export * from './canonical.js';
@@ -20,3 +21,4 @@ export * from './network-policy.js';
20
21
  export * from './review.js';
21
22
  export * from './template.js';
22
23
  export * from './tunnel.js';
24
+ export * from './type-check-profile.js';
@@ -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;