@impetik/xeer-mcp 0.2.5 → 0.2.6
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/README.md +4 -1
- package/dist/dev-session.d.ts +1 -1
- package/dist/network-policy.js +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +4 -4
- package/dist/test-run.d.ts +1 -1
- package/dist/xeer-cli.d.ts +1 -1
- package/package.json +8 -5
- package/vendor/spec/actions.d.ts +1250 -0
- package/vendor/spec/actions.js +805 -0
- package/vendor/spec/admin-sql.d.ts +59 -0
- package/vendor/spec/admin-sql.js +147 -0
- package/vendor/spec/admin.d.ts +110 -0
- package/vendor/spec/admin.js +58 -0
- package/vendor/spec/canonical.d.ts +3 -0
- package/vendor/spec/canonical.js +36 -0
- package/vendor/spec/diagnostics.d.ts +49 -0
- package/vendor/spec/diagnostics.js +500 -0
- package/vendor/spec/docs.d.ts +21 -0
- package/vendor/spec/docs.js +57 -0
- package/vendor/spec/events.d.ts +8 -0
- package/vendor/spec/events.js +21 -0
- package/vendor/spec/identity-keys.d.ts +36 -0
- package/vendor/spec/identity-keys.js +72 -0
- package/vendor/spec/index.d.ts +20 -0
- package/vendor/spec/index.js +20 -0
- package/vendor/spec/local-identity.d.ts +69 -0
- package/vendor/spec/local-identity.js +132 -0
- package/vendor/spec/network-policy.d.ts +16 -0
- package/vendor/spec/network-policy.js +50 -0
- package/vendor/spec/public-assets.d.ts +153 -0
- package/vendor/spec/public-assets.js +166 -0
- package/vendor/spec/review.d.ts +82 -0
- package/vendor/spec/review.js +175 -0
- package/vendor/spec/route.d.ts +43 -0
- package/vendor/spec/route.js +87 -0
- package/vendor/spec/schema-lifecycle.d.ts +6 -0
- package/vendor/spec/schema-lifecycle.js +59 -0
- package/vendor/spec/schema-plan.d.ts +98 -0
- package/vendor/spec/schema-plan.js +194 -0
- package/vendor/spec/schema.d.ts +166 -0
- package/vendor/spec/schema.js +409 -0
- package/vendor/spec/sql-expression.d.ts +91 -0
- package/vendor/spec/sql-expression.js +650 -0
- package/vendor/spec/state-export.d.ts +143 -0
- package/vendor/spec/state-export.js +341 -0
- package/vendor/spec/storage.d.ts +61 -0
- package/vendor/spec/storage.js +120 -0
- package/vendor/spec/table-ddl.d.ts +162 -0
- package/vendor/spec/table-ddl.js +508 -0
- package/vendor/spec/types.d.ts +275 -0
- package/vendor/spec/types.js +11 -0
- package/vendor/spec/value.d.ts +22 -0
- package/vendor/spec/value.js +72 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deliberately free of imports. The control plane and the dispatcher are Workers, and this module is
|
|
3
|
+
* the one piece of the contract they both need; pulling in `canonical.ts` would drag `node:crypto`
|
|
4
|
+
* across that boundary for the sake of a helper neither of them calls.
|
|
5
|
+
*/
|
|
6
|
+
export const PUBLIC_ASSETS_FORMAT = 'xeer.assets.v0';
|
|
7
|
+
/**
|
|
8
|
+
* The one prefix under which a URL *is* a content address, and which no application may occupy.
|
|
9
|
+
*
|
|
10
|
+
* It does **not** bypass the dispatcher — every request to a deployed application is routed through
|
|
11
|
+
* it, so a cold asset request still pays origin resolution and authorization. What a dedicated
|
|
12
|
+
* prefix buys is narrower and worth stating exactly: it stays clear of `/_xeer/*` (dispatcher) and
|
|
13
|
+
* `/__xeer/*` (runtime), both of which are claimed before the assets are consulted; it gives the
|
|
14
|
+
* `_headers` rule an exclusive namespace, so the immutable directive can never reach a mutable path;
|
|
15
|
+
* and it gives the deployment somewhere to route a *miss*, which is what turns a stale reference into
|
|
16
|
+
* a 404 instead of the single-page-application fallback.
|
|
17
|
+
*/
|
|
18
|
+
export const IMMUTABLE_ASSET_PREFIX = '/_xa/';
|
|
19
|
+
/**
|
|
20
|
+
* What a hashed path is served with, and the reason the naming scheme exists at all.
|
|
21
|
+
*
|
|
22
|
+
* `immutable` (RFC 8246) tells a browser not to revalidate on an ordinary reload; a force reload
|
|
23
|
+
* still revalidates, so this does not cost anyone a debugging tool. It is only ever safe because the
|
|
24
|
+
* URL is a digest of the bytes — the same URL can never legitimately answer with anything else — and
|
|
25
|
+
* that is why {@link immutableAssetPath} is the only sanctioned way to mint one.
|
|
26
|
+
*/
|
|
27
|
+
export const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
28
|
+
/**
|
|
29
|
+
* How much of the digest goes into the URL: all of it.
|
|
30
|
+
*
|
|
31
|
+
* A truncated digest was the first design and it was wrong. The argument for it — that collisions are
|
|
32
|
+
* inconceivable across one application's three generated files — answers the wrong question. The
|
|
33
|
+
* threat model includes attacker-influenced bytes, and against a *constructed* pair the bound is the
|
|
34
|
+
* birthday bound over the retained prefix, not the second-preimage bound over the whole digest. At
|
|
35
|
+
* 128 bits that is 2^64 work to mint two files sharing a URL, one of which then inherits a one-year
|
|
36
|
+
* `immutable` cache entry that no later deployment can withdraw. Thirty-two more characters of URL is
|
|
37
|
+
* not a price worth arguing about against that.
|
|
38
|
+
*
|
|
39
|
+
* It also makes a property the rest of the system leans on actually true: equal paths imply equal
|
|
40
|
+
* bytes.
|
|
41
|
+
*/
|
|
42
|
+
export const IMMUTABLE_ASSET_HASH_LENGTH = 64;
|
|
43
|
+
/**
|
|
44
|
+
* Upper bound on {@link PublicAssetsV0.retainedGenerations}.
|
|
45
|
+
*
|
|
46
|
+
* One, because one is what the control plane implements. A wider range was declared first and that
|
|
47
|
+
* was a promise the code did not keep: an artifact could ask for four generations and silently get
|
|
48
|
+
* one. A contract that can express a value nothing honours is worse than a narrow contract.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_RETAINED_ASSET_GENERATIONS = 1;
|
|
51
|
+
const IMMUTABLE_PATH = /^\/_xa\/[0-9a-f]{64}\/[A-Za-z0-9](?:[A-Za-z0-9._-]{0,63})$/u;
|
|
52
|
+
const ASSET_HASH = /^sha256:[0-9a-f]{64}$/u;
|
|
53
|
+
/** The URL a blob of bytes is served at, and the only sanctioned way to mint one. */
|
|
54
|
+
export function immutableAssetPath(hash, basename) {
|
|
55
|
+
return `${IMMUTABLE_ASSET_PREFIX}${hash.slice('sha256:'.length, 'sha256:'.length + IMMUTABLE_ASSET_HASH_LENGTH)}/${basename}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A cheap structural pre-filter, and never the authority. Membership of
|
|
59
|
+
* {@link PublicAssetsV0.immutable} is what makes a path immutable; this only rejects the
|
|
60
|
+
* overwhelming majority of traffic before anything more expensive is consulted.
|
|
61
|
+
*/
|
|
62
|
+
export function isImmutableAssetPath(path) {
|
|
63
|
+
return IMMUTABLE_PATH.test(path);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The `_headers` document Cloudflare's static asset layer applies to a deployment.
|
|
67
|
+
*
|
|
68
|
+
* One rule, scoped to the prefix. Nothing else may appear here: an `immutable` directive that leaked
|
|
69
|
+
* onto the HTML document would pin stale markup in every browser that saw it for a year, and no
|
|
70
|
+
* server-side action reaches a browser that has already stored one.
|
|
71
|
+
*/
|
|
72
|
+
export function immutableAssetHeaders(prefix = IMMUTABLE_ASSET_PREFIX) {
|
|
73
|
+
return `${prefix}*\n Cache-Control: ${IMMUTABLE_ASSET_CACHE_CONTROL}\n`;
|
|
74
|
+
}
|
|
75
|
+
function fail(message) {
|
|
76
|
+
throw new TypeError(`publicAssets: ${message}`);
|
|
77
|
+
}
|
|
78
|
+
function object(value, label) {
|
|
79
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
80
|
+
fail(`${label} must be an object.`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function origin(value, label) {
|
|
84
|
+
// The `:` must be followed by something. An empty `module:` names nothing, and `documentReferences`
|
|
85
|
+
// resolves the document's three subresources by matching on this exact string.
|
|
86
|
+
if (typeof value !== 'string' || value.length > 256
|
|
87
|
+
|| !(value === 'document' || /^(?:module|asset):.+$/u.test(value))) {
|
|
88
|
+
fail(`${label}.origin must be "document", "module:<path>" or "asset:<path>".`);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function contentType(value, label) {
|
|
93
|
+
if (typeof value !== 'string' || !/^[\x20-\x7e]{1,128}$/u.test(value))
|
|
94
|
+
fail(`${label}.contentType is invalid.`);
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
function sorted(paths, label) {
|
|
98
|
+
for (let index = 1; index < paths.length; index += 1) {
|
|
99
|
+
// `<=` rather than `<`: a duplicate is caught by the same comparison that catches disorder,
|
|
100
|
+
// because both mean the list is not the canonical enumeration of a set.
|
|
101
|
+
if (paths[index - 1] >= paths[index])
|
|
102
|
+
fail(`${label} must be sorted by path and free of duplicates.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Validates a declaration rather than trusting one. Every rule below has a failure it exists to
|
|
107
|
+
* prevent, and the two that matter most are the last two:
|
|
108
|
+
*
|
|
109
|
+
* - **path–hash binding.** If a hashed path could ever name bytes other than its own digest, the
|
|
110
|
+
* immutable cache directive becomes unsafe and there is no way to withdraw it.
|
|
111
|
+
* - **prefix exclusivity.** If anything mutable could answer under the prefix, the single `_headers`
|
|
112
|
+
* rule would make it permanently cacheable too.
|
|
113
|
+
*/
|
|
114
|
+
export function parsePublicAssets(value) {
|
|
115
|
+
const input = object(value, 'declaration');
|
|
116
|
+
if (input.format !== PUBLIC_ASSETS_FORMAT)
|
|
117
|
+
fail(`format must be ${PUBLIC_ASSETS_FORMAT}.`);
|
|
118
|
+
if (input.immutablePrefix !== IMMUTABLE_ASSET_PREFIX)
|
|
119
|
+
fail(`immutablePrefix must be ${IMMUTABLE_ASSET_PREFIX}.`);
|
|
120
|
+
const retainedGenerations = input.retainedGenerations;
|
|
121
|
+
if (typeof retainedGenerations !== 'number' || !Number.isInteger(retainedGenerations)
|
|
122
|
+
|| retainedGenerations < 0 || retainedGenerations > MAX_RETAINED_ASSET_GENERATIONS) {
|
|
123
|
+
fail(`retainedGenerations must be an integer between 0 and ${MAX_RETAINED_ASSET_GENERATIONS}.`);
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(input.immutable) || !Array.isArray(input.revalidate))
|
|
126
|
+
fail('immutable and revalidate must be arrays.');
|
|
127
|
+
const immutable = input.immutable.map((item, index) => {
|
|
128
|
+
const entry = object(item, `immutable[${index}]`);
|
|
129
|
+
const hash = entry.hash;
|
|
130
|
+
if (typeof hash !== 'string' || !ASSET_HASH.test(hash))
|
|
131
|
+
fail(`immutable[${index}].hash is invalid.`);
|
|
132
|
+
const path = entry.path;
|
|
133
|
+
if (typeof path !== 'string' || !isImmutableAssetPath(path))
|
|
134
|
+
fail(`immutable[${index}].path is not a well-formed immutable path.`);
|
|
135
|
+
if (path !== immutableAssetPath(hash, path.slice(path.lastIndexOf('/') + 1))) {
|
|
136
|
+
fail(`immutable[${index}].path does not carry the digest of its own bytes.`);
|
|
137
|
+
}
|
|
138
|
+
const size = entry.size;
|
|
139
|
+
if (typeof size !== 'number' || !Number.isInteger(size) || size < 0)
|
|
140
|
+
fail(`immutable[${index}].size is invalid.`);
|
|
141
|
+
return { path, hash: hash, contentType: contentType(entry.contentType, `immutable[${index}]`),
|
|
142
|
+
size, origin: origin(entry.origin, `immutable[${index}]`) };
|
|
143
|
+
});
|
|
144
|
+
const revalidate = input.revalidate.map((item, index) => {
|
|
145
|
+
const entry = object(item, `revalidate[${index}]`);
|
|
146
|
+
const path = entry.path;
|
|
147
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.includes('//') || path.includes('..')) {
|
|
148
|
+
fail(`revalidate[${index}].path is invalid.`);
|
|
149
|
+
}
|
|
150
|
+
if (path.startsWith(IMMUTABLE_ASSET_PREFIX))
|
|
151
|
+
fail(`revalidate[${index}].path uses the reserved immutable prefix.`);
|
|
152
|
+
return { path, contentType: contentType(entry.contentType, `revalidate[${index}]`),
|
|
153
|
+
origin: origin(entry.origin, `revalidate[${index}]`) };
|
|
154
|
+
});
|
|
155
|
+
sorted(immutable.map((entry) => entry.path), 'immutable');
|
|
156
|
+
sorted(revalidate.map((entry) => entry.path), 'revalidate');
|
|
157
|
+
return { format: PUBLIC_ASSETS_FORMAT, immutablePrefix: IMMUTABLE_ASSET_PREFIX, retainedGenerations,
|
|
158
|
+
immutable, revalidate };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Every path that must answer for this deployment, immutable first. The union a control plane uploads
|
|
162
|
+
* is this set for the current artifact plus the immutable half of the retained generations.
|
|
163
|
+
*/
|
|
164
|
+
export function declaredAssetPaths(declaration) {
|
|
165
|
+
return [...declaration.immutable.map((entry) => entry.path), ...declaration.revalidate.map((entry) => entry.path)];
|
|
166
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { NormalizedDatabaseSchemaV0, SchemaPlanV0 } from './schema-plan.js';
|
|
2
|
+
import { type ApplicationArtifactV0, type ApplicationSchemaIdentityV0, type Capability, type SourceReceipt } from './types.js';
|
|
3
|
+
export declare const ARTIFACT_REVIEW_PROTOCOL: "xeer.artifact-review.v0";
|
|
4
|
+
export declare const TEST_EVIDENCE_PROTOCOL: "xeer.test-evidence.v0";
|
|
5
|
+
export declare const REVIEW_PROTOCOL: "xeer.review.v0";
|
|
6
|
+
export declare const REVIEW_RECEIPT_ID_SOURCE: "^review_[A-Za-z0-9_-]{12,96}$";
|
|
7
|
+
export declare const REVIEW_RECEIPT_ID_PATTERN: RegExp;
|
|
8
|
+
export interface ArtifactReviewMetadataV0 {
|
|
9
|
+
readonly protocol: typeof ARTIFACT_REVIEW_PROTOCOL;
|
|
10
|
+
readonly sourceHash: `sha256:${string}`;
|
|
11
|
+
readonly schema: NormalizedDatabaseSchemaV0;
|
|
12
|
+
readonly schemaIdentity: ApplicationSchemaIdentityV0;
|
|
13
|
+
}
|
|
14
|
+
export type TestEvidenceStatus = 'passed' | 'failed';
|
|
15
|
+
/** A local test runner's report. The control plane records it as reported evidence, not an attestation. */
|
|
16
|
+
export interface XeerTestEvidenceV0 {
|
|
17
|
+
readonly protocol: typeof TEST_EVIDENCE_PROTOCOL;
|
|
18
|
+
readonly artifactId: `sha256:${string}`;
|
|
19
|
+
readonly status: TestEvidenceStatus;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly total: number;
|
|
22
|
+
readonly passed: number;
|
|
23
|
+
readonly failed: number;
|
|
24
|
+
readonly files: readonly string[];
|
|
25
|
+
readonly completedAt: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ReviewTestEvidenceV0 {
|
|
28
|
+
readonly provenance: 'reported' | 'absent';
|
|
29
|
+
readonly status: TestEvidenceStatus | 'absent';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
readonly total: number;
|
|
32
|
+
readonly passed: number;
|
|
33
|
+
readonly failed: number;
|
|
34
|
+
readonly completedAt: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface ReviewSchemaChangeV0 {
|
|
37
|
+
readonly status: 'unknown' | 'initial' | 'unchanged' | 'planned' | 'unplannable';
|
|
38
|
+
readonly from: ApplicationSchemaIdentityV0 | null;
|
|
39
|
+
readonly to: ApplicationSchemaIdentityV0 | null;
|
|
40
|
+
readonly plan: SchemaPlanV0 | null;
|
|
41
|
+
readonly planningError: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface ReviewCapabilityChangeV0 {
|
|
44
|
+
readonly before: readonly Capability[];
|
|
45
|
+
readonly after: readonly Capability[];
|
|
46
|
+
readonly added: readonly Capability[];
|
|
47
|
+
readonly removed: readonly Capability[];
|
|
48
|
+
}
|
|
49
|
+
export interface ReviewEnvironmentNamesV0 {
|
|
50
|
+
readonly preview: readonly string[];
|
|
51
|
+
readonly production: readonly string[];
|
|
52
|
+
readonly previewOnly: readonly string[];
|
|
53
|
+
readonly productionOnly: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
export interface XeerReviewReceiptV0 {
|
|
56
|
+
readonly protocol: typeof REVIEW_PROTOCOL;
|
|
57
|
+
readonly receiptId: string;
|
|
58
|
+
readonly appId: string;
|
|
59
|
+
readonly application: string;
|
|
60
|
+
readonly artifactId: `sha256:${string}`;
|
|
61
|
+
readonly sourceHash: `sha256:${string}` | null;
|
|
62
|
+
readonly createdAt: string;
|
|
63
|
+
readonly test: ReviewTestEvidenceV0;
|
|
64
|
+
readonly changes: {
|
|
65
|
+
readonly baselineArtifactId: `sha256:${string}` | null;
|
|
66
|
+
readonly schema: ReviewSchemaChangeV0;
|
|
67
|
+
readonly capabilities: ReviewCapabilityChangeV0;
|
|
68
|
+
readonly environmentNames: ReviewEnvironmentNamesV0;
|
|
69
|
+
};
|
|
70
|
+
readonly preview: {
|
|
71
|
+
readonly deploymentId: string;
|
|
72
|
+
readonly url: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export declare function artifactSourceHash(files: readonly SourceReceipt[]): `sha256:${string}`;
|
|
76
|
+
export declare function artifactReviewMetadata(artifact: ApplicationArtifactV0): ArtifactReviewMetadataV0;
|
|
77
|
+
export declare function parseArtifactReviewMetadata(value: unknown): ArtifactReviewMetadataV0;
|
|
78
|
+
export declare function parseTestEvidence(value: unknown): XeerTestEvidenceV0;
|
|
79
|
+
export declare function parseReviewReceipt(value: unknown): XeerReviewReceiptV0;
|
|
80
|
+
export declare function isReviewReceiptId(value: string): boolean;
|
|
81
|
+
export declare function capabilityChange(before: readonly string[], after: readonly string[]): ReviewCapabilityChangeV0;
|
|
82
|
+
export declare function environmentNameChange(preview: readonly string[], production: readonly string[]): ReviewEnvironmentNamesV0;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { canonicalHash } from './canonical.js';
|
|
3
|
+
import { normalizedDatabaseSchema } from './schema.js';
|
|
4
|
+
import { applicationSchemaIdentity } from './schema-lifecycle.js';
|
|
5
|
+
import { CAPABILITIES, } from './types.js';
|
|
6
|
+
export const ARTIFACT_REVIEW_PROTOCOL = 'xeer.artifact-review.v0';
|
|
7
|
+
export const TEST_EVIDENCE_PROTOCOL = 'xeer.test-evidence.v0';
|
|
8
|
+
export const REVIEW_PROTOCOL = 'xeer.review.v0';
|
|
9
|
+
export const REVIEW_RECEIPT_ID_SOURCE = '^review_[A-Za-z0-9_-]{12,96}$';
|
|
10
|
+
export const REVIEW_RECEIPT_ID_PATTERN = new RegExp(REVIEW_RECEIPT_ID_SOURCE, 'u');
|
|
11
|
+
const HASH = /^sha256:[a-f0-9]{64}$/u;
|
|
12
|
+
const APP_ID = /^app_[A-Za-z0-9_-]{8,96}$/u;
|
|
13
|
+
const schemaIdentity = z.strictObject({
|
|
14
|
+
applicationSchemaVersion: z.number().int().positive(),
|
|
15
|
+
schemaHash: z.string().regex(HASH),
|
|
16
|
+
});
|
|
17
|
+
const artifactReview = z.strictObject({
|
|
18
|
+
protocol: z.literal(ARTIFACT_REVIEW_PROTOCOL),
|
|
19
|
+
sourceHash: z.string().regex(HASH),
|
|
20
|
+
schema: normalizedDatabaseSchema,
|
|
21
|
+
schemaIdentity,
|
|
22
|
+
});
|
|
23
|
+
const testEvidence = z.strictObject({
|
|
24
|
+
protocol: z.literal(TEST_EVIDENCE_PROTOCOL),
|
|
25
|
+
artifactId: z.string().regex(HASH),
|
|
26
|
+
status: z.enum(['passed', 'failed']),
|
|
27
|
+
reason: z.string().min(1).max(64),
|
|
28
|
+
total: z.number().int().nonnegative(),
|
|
29
|
+
passed: z.number().int().nonnegative(),
|
|
30
|
+
failed: z.number().int().nonnegative(),
|
|
31
|
+
files: z.array(z.string().min(1).max(512)).max(512),
|
|
32
|
+
completedAt: z.iso.datetime(),
|
|
33
|
+
}).superRefine((value, context) => {
|
|
34
|
+
if (value.passed + value.failed !== value.total) {
|
|
35
|
+
context.addIssue({ code: 'custom', path: ['total'], message: 'must equal passed + failed' });
|
|
36
|
+
}
|
|
37
|
+
if (value.status === 'passed' && (value.total === 0 || value.failed !== 0)) {
|
|
38
|
+
context.addIssue({ code: 'custom', path: ['status'], message: 'requires at least one test and zero failures' });
|
|
39
|
+
}
|
|
40
|
+
if (value.status === 'failed' && value.failed === 0 && value.reason === 'complete') {
|
|
41
|
+
context.addIssue({ code: 'custom', path: ['status'], message: 'failed evidence needs a failure reason' });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const schemaPlanStep = z.discriminatedUnion('kind', [
|
|
45
|
+
z.strictObject({ kind: z.literal('table.add'), table: z.string().min(1) }),
|
|
46
|
+
z.strictObject({ kind: z.literal('table.remove'), table: z.string().min(1) }),
|
|
47
|
+
z.strictObject({ kind: z.literal('field.add'), table: z.string().min(1), field: z.string().min(1),
|
|
48
|
+
optional: z.boolean() }),
|
|
49
|
+
z.strictObject({ kind: z.literal('field.remove'), table: z.string().min(1), field: z.string().min(1) }),
|
|
50
|
+
z.strictObject({ kind: z.literal('field.type'), table: z.string().min(1), field: z.string().min(1),
|
|
51
|
+
from: z.enum(['string', 'number', 'boolean', 'datetime', 'bytes', 'json']),
|
|
52
|
+
to: z.enum(['string', 'number', 'boolean', 'datetime', 'bytes', 'json']) }),
|
|
53
|
+
z.strictObject({ kind: z.literal('field.optional'), table: z.string().min(1), field: z.string().min(1),
|
|
54
|
+
from: z.boolean(), to: z.boolean() }),
|
|
55
|
+
z.strictObject({ kind: z.literal('field.maxLength'), table: z.string().min(1), field: z.string().min(1),
|
|
56
|
+
from: z.number().int().positive().nullable(), to: z.number().int().positive().nullable() }),
|
|
57
|
+
z.strictObject({ kind: z.literal('index.add'), table: z.string().min(1), index: z.string().min(1),
|
|
58
|
+
fields: z.array(z.string().min(1)) }),
|
|
59
|
+
z.strictObject({ kind: z.literal('index.remove'), table: z.string().min(1), index: z.string().min(1),
|
|
60
|
+
fields: z.array(z.string().min(1)) }),
|
|
61
|
+
z.strictObject({ kind: z.literal('index.change'), table: z.string().min(1), index: z.string().min(1),
|
|
62
|
+
from: z.array(z.string().min(1)), to: z.array(z.string().min(1)) }),
|
|
63
|
+
z.strictObject({ kind: z.literal('unknown.change'), path: z.string().min(1) }),
|
|
64
|
+
]);
|
|
65
|
+
const schemaPlan = z.strictObject({
|
|
66
|
+
protocol: z.literal('xeer.schema.v0'),
|
|
67
|
+
application: z.string().min(1),
|
|
68
|
+
from: schemaIdentity,
|
|
69
|
+
to: schemaIdentity,
|
|
70
|
+
classification: z.enum(['unchanged', 'compatible', 'incompatible']),
|
|
71
|
+
steps: z.array(schemaPlanStep),
|
|
72
|
+
requiresReset: z.boolean(),
|
|
73
|
+
});
|
|
74
|
+
const reviewReceipt = z.strictObject({
|
|
75
|
+
protocol: z.literal(REVIEW_PROTOCOL),
|
|
76
|
+
receiptId: z.string().regex(REVIEW_RECEIPT_ID_PATTERN),
|
|
77
|
+
appId: z.string().regex(APP_ID),
|
|
78
|
+
application: z.string().min(1),
|
|
79
|
+
artifactId: z.string().regex(HASH),
|
|
80
|
+
sourceHash: z.string().regex(HASH).nullable(),
|
|
81
|
+
createdAt: z.iso.datetime(),
|
|
82
|
+
test: z.strictObject({
|
|
83
|
+
provenance: z.enum(['reported', 'absent']),
|
|
84
|
+
status: z.enum(['passed', 'failed', 'absent']),
|
|
85
|
+
reason: z.string().min(1).max(64),
|
|
86
|
+
total: z.number().int().nonnegative(),
|
|
87
|
+
passed: z.number().int().nonnegative(),
|
|
88
|
+
failed: z.number().int().nonnegative(),
|
|
89
|
+
completedAt: z.iso.datetime().nullable(),
|
|
90
|
+
}),
|
|
91
|
+
changes: z.strictObject({
|
|
92
|
+
baselineArtifactId: z.string().regex(HASH).nullable(),
|
|
93
|
+
schema: z.strictObject({
|
|
94
|
+
status: z.enum(['unknown', 'initial', 'unchanged', 'planned', 'unplannable']),
|
|
95
|
+
from: schemaIdentity.nullable(),
|
|
96
|
+
to: schemaIdentity.nullable(),
|
|
97
|
+
plan: schemaPlan.nullable(),
|
|
98
|
+
planningError: z.string().nullable(),
|
|
99
|
+
}),
|
|
100
|
+
capabilities: z.strictObject({
|
|
101
|
+
before: z.array(z.enum(CAPABILITIES)), after: z.array(z.enum(CAPABILITIES)),
|
|
102
|
+
added: z.array(z.enum(CAPABILITIES)), removed: z.array(z.enum(CAPABILITIES)),
|
|
103
|
+
}),
|
|
104
|
+
environmentNames: z.strictObject({
|
|
105
|
+
preview: z.array(z.string().min(1)), production: z.array(z.string().min(1)),
|
|
106
|
+
previewOnly: z.array(z.string().min(1)), productionOnly: z.array(z.string().min(1)),
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
preview: z.strictObject({ deploymentId: z.string().min(1), url: z.url() }),
|
|
110
|
+
}).superRefine((value, context) => {
|
|
111
|
+
if (value.test.passed + value.test.failed !== value.test.total) {
|
|
112
|
+
context.addIssue({ code: 'custom', path: ['test', 'total'], message: 'must equal passed + failed' });
|
|
113
|
+
}
|
|
114
|
+
if (value.test.provenance === 'absent'
|
|
115
|
+
&& (value.test.status !== 'absent' || value.test.total !== 0 || value.test.completedAt !== null)) {
|
|
116
|
+
context.addIssue({ code: 'custom', path: ['test'], message: 'absent evidence must contain no results' });
|
|
117
|
+
}
|
|
118
|
+
if (value.test.provenance === 'reported' && value.test.completedAt === null) {
|
|
119
|
+
context.addIssue({ code: 'custom', path: ['test', 'completedAt'], message: 'reported evidence needs a time' });
|
|
120
|
+
}
|
|
121
|
+
if (value.test.status === 'passed' && (value.test.total === 0 || value.test.failed !== 0)) {
|
|
122
|
+
context.addIssue({ code: 'custom', path: ['test', 'status'], message: 'requires tests and zero failures' });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
export function artifactSourceHash(files) {
|
|
126
|
+
return canonicalHash([...files]
|
|
127
|
+
.map(({ path, size, hash }) => ({ path, size, hash }))
|
|
128
|
+
.sort((left, right) => left.path.localeCompare(right.path)));
|
|
129
|
+
}
|
|
130
|
+
export function artifactReviewMetadata(artifact) {
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
protocol: ARTIFACT_REVIEW_PROTOCOL,
|
|
133
|
+
sourceHash: artifactSourceHash(artifact.source.files),
|
|
134
|
+
schema: artifact.schema,
|
|
135
|
+
schemaIdentity: artifact.schemaIdentity,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
export function parseArtifactReviewMetadata(value) {
|
|
139
|
+
const parsed = artifactReview.parse(value);
|
|
140
|
+
const expected = applicationSchemaIdentity(parsed.schema);
|
|
141
|
+
if (parsed.schemaIdentity.applicationSchemaVersion !== expected.applicationSchemaVersion
|
|
142
|
+
|| parsed.schemaIdentity.schemaHash !== expected.schemaHash) {
|
|
143
|
+
throw new TypeError('Artifact review schemaIdentity does not match its normalized schema.');
|
|
144
|
+
}
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
export function parseTestEvidence(value) {
|
|
148
|
+
return testEvidence.parse(value);
|
|
149
|
+
}
|
|
150
|
+
export function parseReviewReceipt(value) {
|
|
151
|
+
return reviewReceipt.parse(value);
|
|
152
|
+
}
|
|
153
|
+
export function isReviewReceiptId(value) {
|
|
154
|
+
return REVIEW_RECEIPT_ID_PATTERN.test(value);
|
|
155
|
+
}
|
|
156
|
+
export function capabilityChange(before, after) {
|
|
157
|
+
const prior = [...new Set(before)].filter((name) => CAPABILITIES.includes(name)).sort();
|
|
158
|
+
const next = [...new Set(after)].filter((name) => CAPABILITIES.includes(name)).sort();
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
before: prior,
|
|
161
|
+
after: next,
|
|
162
|
+
added: next.filter((name) => !prior.includes(name)),
|
|
163
|
+
removed: prior.filter((name) => !next.includes(name)),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
export function environmentNameChange(preview, production) {
|
|
167
|
+
const previewNames = [...new Set(preview)].sort();
|
|
168
|
+
const productionNames = [...new Set(production)].sort();
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
preview: previewNames,
|
|
171
|
+
production: productionNames,
|
|
172
|
+
previewOnly: previewNames.filter((name) => !productionNames.includes(name)),
|
|
173
|
+
productionOnly: productionNames.filter((name) => !previewNames.includes(name)),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export declare const ENDPOINT_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
|
|
2
|
+
export type EndpointMethod = typeof ENDPOINT_METHODS[number];
|
|
3
|
+
export interface EndpointRoute {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly method: EndpointMethod;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
/** Dynamic parameter names are erased so equivalent route shapes collide. */
|
|
8
|
+
readonly shape: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseEndpointRoute(key: string): EndpointRoute | null;
|
|
11
|
+
export declare function endpointRouteIdentity(route: Pick<EndpointRoute, 'method' | 'shape'>): string;
|
|
12
|
+
export declare function endpointRouteMatches(route: Pick<EndpointRoute, 'method' | 'path'>, method: string, pathname: string): boolean;
|
|
13
|
+
export declare function normalizeEndpointRoutes(keys: Iterable<string>): EndpointRoute[];
|
|
14
|
+
/**
|
|
15
|
+
* The reserved runtime route that serves the live invalidation stream.
|
|
16
|
+
*
|
|
17
|
+
* `packages/runtime/src/live.ts` is normative for this path; this is a mirror, exactly as the SDK's
|
|
18
|
+
* `live.ts` already mirrors it. It is restated here rather than imported because the public
|
|
19
|
+
* dispatcher needs it and `xeer-auth` does not — and must not — depend on the runtime package: the
|
|
20
|
+
* dispatcher is a separate Worker, and an import edge would pull the whole application runtime into
|
|
21
|
+
* it to learn one string.
|
|
22
|
+
*/
|
|
23
|
+
export declare const LIVE_STREAM_PATH = "/__xeer/events";
|
|
24
|
+
/**
|
|
25
|
+
* Every `/__xeer/` path a public visitor may reach. **A closed allowlist, and it has to stay one.**
|
|
26
|
+
*
|
|
27
|
+
* The public dispatcher refuses anything under `/__xeer/` that is not listed here. That is the
|
|
28
|
+
* inversion of an exact-path denylist, which was safe only for as long as nobody added a route: a
|
|
29
|
+
* new builder-only surface in the runtime was publicly reachable the moment it existed, and nothing
|
|
30
|
+
* in its own diff said so. Enumerating what is *open* means the default for a route nobody has
|
|
31
|
+
* written yet is closed.
|
|
32
|
+
*
|
|
33
|
+
* The obvious alternative — refusing the whole `/__xeer/` prefix — is wrong, because the application
|
|
34
|
+
* protocol lives under it: `rpc/query`, `rpc/mutation` and the live stream are how an application is
|
|
35
|
+
* used at all. `health` and `identity` are the two readiness probes, deliberately excluded from the
|
|
36
|
+
* runtime's own `INSPECTOR_ROUTES` so that deploys and uptime checks keep working.
|
|
37
|
+
*
|
|
38
|
+
* Adding an entry here makes a route publicly reachable on every deployed application. Nothing else
|
|
39
|
+
* does, which is the point: the decision is visible in one diff, in one place.
|
|
40
|
+
*/
|
|
41
|
+
export declare const PUBLIC_RESERVED_ROUTES: ReadonlySet<string>;
|
|
42
|
+
/** Whether a path is reserved by the platform, and therefore governed by the allowlist above. */
|
|
43
|
+
export declare function reservedRuntimePath(pathname: string): boolean;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const ENDPOINT_METHODS = [
|
|
2
|
+
'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD',
|
|
3
|
+
];
|
|
4
|
+
const METHODS = new Set(ENDPOINT_METHODS);
|
|
5
|
+
const STATIC_SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
6
|
+
const PARAMETER_SEGMENT = /^:[A-Za-z][A-Za-z0-9_]*$/;
|
|
7
|
+
export function parseEndpointRoute(key) {
|
|
8
|
+
const separator = key.indexOf(' ');
|
|
9
|
+
if (separator <= 0 || key.indexOf(' ', separator + 1) !== -1)
|
|
10
|
+
return null;
|
|
11
|
+
const method = key.slice(0, separator);
|
|
12
|
+
const path = key.slice(separator + 1);
|
|
13
|
+
if (!METHODS.has(method) || (path !== '/api' && !path.startsWith('/api/')))
|
|
14
|
+
return null;
|
|
15
|
+
const segments = path.split('/').slice(2);
|
|
16
|
+
if (segments.some((segment) => !STATIC_SEGMENT.test(segment) && !PARAMETER_SEGMENT.test(segment)))
|
|
17
|
+
return null;
|
|
18
|
+
const shape = segments.length === 0
|
|
19
|
+
? '/api'
|
|
20
|
+
: `/api/${segments.map((segment) => segment.startsWith(':') ? ':' : segment).join('/')}`;
|
|
21
|
+
return { key, method: method, path, shape };
|
|
22
|
+
}
|
|
23
|
+
export function endpointRouteIdentity(route) {
|
|
24
|
+
return `${route.method} ${route.shape}`;
|
|
25
|
+
}
|
|
26
|
+
export function endpointRouteMatches(route, method, pathname) {
|
|
27
|
+
if (route.method !== method)
|
|
28
|
+
return false;
|
|
29
|
+
const expected = route.path.split('/');
|
|
30
|
+
const actual = pathname.split('/');
|
|
31
|
+
return expected.length === actual.length && expected.every((part, index) => part.startsWith(':') ? (actual[index]?.length ?? 0) > 0 : part === actual[index]);
|
|
32
|
+
}
|
|
33
|
+
export function normalizeEndpointRoutes(keys) {
|
|
34
|
+
const routes = [];
|
|
35
|
+
const identities = new Map();
|
|
36
|
+
for (const key of keys) {
|
|
37
|
+
const route = parseEndpointRoute(key);
|
|
38
|
+
if (!route)
|
|
39
|
+
throw new Error(`Invalid endpoint route: ${key}`);
|
|
40
|
+
const identity = endpointRouteIdentity(route);
|
|
41
|
+
const existing = identities.get(identity);
|
|
42
|
+
if (existing && existing !== route.key) {
|
|
43
|
+
throw new Error(`Ambiguous endpoint routes: ${existing} and ${route.key}`);
|
|
44
|
+
}
|
|
45
|
+
identities.set(identity, route.key);
|
|
46
|
+
routes.push(route);
|
|
47
|
+
}
|
|
48
|
+
return routes.sort((left, right) => left.key.localeCompare(right.key));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The reserved runtime route that serves the live invalidation stream.
|
|
52
|
+
*
|
|
53
|
+
* `packages/runtime/src/live.ts` is normative for this path; this is a mirror, exactly as the SDK's
|
|
54
|
+
* `live.ts` already mirrors it. It is restated here rather than imported because the public
|
|
55
|
+
* dispatcher needs it and `xeer-auth` does not — and must not — depend on the runtime package: the
|
|
56
|
+
* dispatcher is a separate Worker, and an import edge would pull the whole application runtime into
|
|
57
|
+
* it to learn one string.
|
|
58
|
+
*/
|
|
59
|
+
export const LIVE_STREAM_PATH = '/__xeer/events';
|
|
60
|
+
/**
|
|
61
|
+
* Every `/__xeer/` path a public visitor may reach. **A closed allowlist, and it has to stay one.**
|
|
62
|
+
*
|
|
63
|
+
* The public dispatcher refuses anything under `/__xeer/` that is not listed here. That is the
|
|
64
|
+
* inversion of an exact-path denylist, which was safe only for as long as nobody added a route: a
|
|
65
|
+
* new builder-only surface in the runtime was publicly reachable the moment it existed, and nothing
|
|
66
|
+
* in its own diff said so. Enumerating what is *open* means the default for a route nobody has
|
|
67
|
+
* written yet is closed.
|
|
68
|
+
*
|
|
69
|
+
* The obvious alternative — refusing the whole `/__xeer/` prefix — is wrong, because the application
|
|
70
|
+
* protocol lives under it: `rpc/query`, `rpc/mutation` and the live stream are how an application is
|
|
71
|
+
* used at all. `health` and `identity` are the two readiness probes, deliberately excluded from the
|
|
72
|
+
* runtime's own `INSPECTOR_ROUTES` so that deploys and uptime checks keep working.
|
|
73
|
+
*
|
|
74
|
+
* Adding an entry here makes a route publicly reachable on every deployed application. Nothing else
|
|
75
|
+
* does, which is the point: the decision is visible in one diff, in one place.
|
|
76
|
+
*/
|
|
77
|
+
export const PUBLIC_RESERVED_ROUTES = new Set([
|
|
78
|
+
'/__xeer/health',
|
|
79
|
+
'/__xeer/identity',
|
|
80
|
+
'/__xeer/rpc/query',
|
|
81
|
+
'/__xeer/rpc/mutation',
|
|
82
|
+
LIVE_STREAM_PATH,
|
|
83
|
+
]);
|
|
84
|
+
/** Whether a path is reserved by the platform, and therefore governed by the allowlist above. */
|
|
85
|
+
export function reservedRuntimePath(pathname) {
|
|
86
|
+
return pathname.startsWith('/__xeer/') || pathname === '/__xeer';
|
|
87
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ApplicationSchemaIdentityV0 } from './types.js';
|
|
2
|
+
import { type NormalizedDatabaseSchemaV0, type SchemaPlanV0 } from './schema-plan.js';
|
|
3
|
+
export * from './schema-plan.js';
|
|
4
|
+
export declare function applicationSchemaHash(schema: NormalizedDatabaseSchemaV0): `sha256:${string}`;
|
|
5
|
+
export declare function applicationSchemaIdentity(schema: NormalizedDatabaseSchemaV0): ApplicationSchemaIdentityV0;
|
|
6
|
+
export declare function planSchemaChange(application: string, fromSchema: NormalizedDatabaseSchemaV0, toSchema: NormalizedDatabaseSchemaV0): SchemaPlanV0;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { canonicalHash } from './canonical.js';
|
|
2
|
+
import { normalizedIndex } from './table-ddl.js';
|
|
3
|
+
import { planSchemaChangeWithIdentities, } from './schema-plan.js';
|
|
4
|
+
export * from './schema-plan.js';
|
|
5
|
+
/**
|
|
6
|
+
* The projection of a schema that its hash is taken over.
|
|
7
|
+
*
|
|
8
|
+
* Every key here is one that changes what the database will accept, store, or return: the hash is
|
|
9
|
+
* the identity of the *contract*, and two schemas that hold data to different rules must not share
|
|
10
|
+
* one. That is why nothing is omitted as a detail — a collation decides which values collide under
|
|
11
|
+
* a UNIQUE, a default decides what an omitted column receives, an index's direction and predicate
|
|
12
|
+
* decide which physical index has to exist. A key that only changed how a schema *reads* — a label,
|
|
13
|
+
* a description — would belong outside this projection, and there is no such key today.
|
|
14
|
+
*
|
|
15
|
+
* The projection is explicit rather than a spread of the definition so that adding a key to the
|
|
16
|
+
* manifest is a decision made here, in one place, instead of a hash that quietly moves.
|
|
17
|
+
*/
|
|
18
|
+
function normalizedSchemaPayload(schema) {
|
|
19
|
+
return {
|
|
20
|
+
tables: Object.fromEntries(Object.keys(schema.tables).sort().map((tableName) => {
|
|
21
|
+
const table = schema.tables[tableName];
|
|
22
|
+
return [tableName, {
|
|
23
|
+
fields: Object.fromEntries(Object.keys(table.fields).sort().map((fieldName) => {
|
|
24
|
+
const field = table.fields[fieldName];
|
|
25
|
+
return [fieldName, {
|
|
26
|
+
type: field.type,
|
|
27
|
+
optional: field.optional ?? false,
|
|
28
|
+
maxLength: field.maxLength ?? null,
|
|
29
|
+
unique: field.unique ?? false,
|
|
30
|
+
enum: field.enum === undefined ? null : [...field.enum],
|
|
31
|
+
default: field.default ?? null,
|
|
32
|
+
collate: field.collate ?? null,
|
|
33
|
+
table: field.table ?? null,
|
|
34
|
+
onDelete: field.onDelete ?? null,
|
|
35
|
+
onUpdate: field.onUpdate ?? null,
|
|
36
|
+
generated: field.generated === undefined
|
|
37
|
+
? null
|
|
38
|
+
: { expression: field.generated.expression, stored: field.generated.stored ?? false },
|
|
39
|
+
}];
|
|
40
|
+
})),
|
|
41
|
+
// Normalized, so the shorthand and the full form of one index hash the same.
|
|
42
|
+
indexes: Object.fromEntries(Object.keys(table.indexes ?? {}).sort()
|
|
43
|
+
.map((indexName) => [indexName, normalizedIndex(table.indexes[indexName])])),
|
|
44
|
+
unique: (table.unique ?? []).map((tuple) => [...tuple]),
|
|
45
|
+
checks: Object.fromEntries(Object.keys(table.checks ?? {}).sort()
|
|
46
|
+
.map((checkName) => [checkName, table.checks[checkName]])),
|
|
47
|
+
}];
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function applicationSchemaHash(schema) {
|
|
52
|
+
return canonicalHash(normalizedSchemaPayload(schema));
|
|
53
|
+
}
|
|
54
|
+
export function applicationSchemaIdentity(schema) {
|
|
55
|
+
return { applicationSchemaVersion: schema.version, schemaHash: applicationSchemaHash(schema) };
|
|
56
|
+
}
|
|
57
|
+
export function planSchemaChange(application, fromSchema, toSchema) {
|
|
58
|
+
return planSchemaChangeWithIdentities(application, fromSchema, applicationSchemaIdentity(fromSchema), toSchema, applicationSchemaIdentity(toSchema));
|
|
59
|
+
}
|