@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.
- 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 +99 -10
- package/vendor/spec/import-policy.d.ts +172 -0
- package/vendor/spec/import-policy.js +216 -0
- package/vendor/spec/index.d.ts +2 -0
- package/vendor/spec/index.js +2 -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/public-assets.d.ts +9 -6
- package/vendor/spec/public-assets.js +4 -3
- 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 +16 -0
- package/vendor/spec/schema.js +44 -0
- package/vendor/spec/state-export.d.ts +22 -0
- package/vendor/spec/state-export.js +42 -2
- 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 +134 -1
- package/vendor/spec/types.js +10 -0
|
@@ -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
|
+
}
|
package/vendor/spec/index.d.ts
CHANGED
|
@@ -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';
|
package/vendor/spec/index.js
CHANGED
|
@@ -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
|
|
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;
|