@openvtc/trust-tasks 0.27.8 → 0.27.9

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.
@@ -29,8 +29,14 @@
29
29
  import { EXTENDS_BY_TYPE_URI } from "../_extends.js";
30
30
  import { canonicalJson } from "./canonical.js";
31
31
  import type { RejectReason, SpecPolicy, TrustTaskDocument } from "./document.js";
32
+ import {
33
+ formatTypeUri,
34
+ sameSpecification,
35
+ tryParseTypeUri,
36
+ typeUriWithVersion,
37
+ type TypeUri,
38
+ } from "./type-uri.js";
32
39
 
33
- const PREFIX = "https://trusttasks.org/spec/";
34
40
  const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
35
41
 
36
42
  /**
@@ -45,27 +51,18 @@ export type ExtendsLookup =
45
51
  | Readonly<Record<string, string>>
46
52
  | ((bareTypeUri: string) => string | null | undefined);
47
53
 
48
- interface ParsedTypeUri {
49
- slug: string;
50
- version: string;
51
- major: number;
52
- minor: number;
53
- /** `""`, or the fragment including its `#`. */
54
- fragment: string;
55
- }
54
+ /** `M.N` of a parsed Type URI. */
55
+ const versionOf = (u: TypeUri): string => `${u.major}.${u.minor}`;
56
56
 
57
- function parseTypeUri(typeUri: string): ParsedTypeUri | null {
58
- if (typeof typeUri !== "string" || !typeUri.startsWith(PREFIX)) return null;
59
- const hash = typeUri.indexOf("#");
60
- const bare = hash < 0 ? typeUri : typeUri.slice(0, hash);
61
- const fragment = hash < 0 ? "" : typeUri.slice(hash);
62
- const rest = bare.slice(PREFIX.length);
63
- const cut = rest.lastIndexOf("/");
64
- if (cut <= 0) return null;
65
- const version = rest.slice(cut + 1);
66
- const m = VERSION_RE.exec(version);
67
- if (!m) return null;
68
- return { slug: rest.slice(0, cut), version, major: Number(m[1]), minor: Number(m[2]), fragment };
57
+ /**
58
+ * The key an {@link ExtendsLookup} is consulted with: the bare (fragment-free)
59
+ * request Type URI of `u` at `version`, under `u`'s own authority and form.
60
+ * Building it from `u` rather than from the slug is what keeps a chain inside
61
+ * one authority: a third-party `x/0.3` is never looked up as the registry's
62
+ * `x/0.3`.
63
+ */
64
+ function bareKey(u: TypeUri): string {
65
+ return formatTypeUri({ ...u, variant: null });
69
66
  }
70
67
 
71
68
  /**
@@ -115,31 +112,39 @@ function lookup(index: ExtendsLookup, bare: string): string | null {
115
112
  * specification's, covering both variants.
116
113
  */
117
114
  export function extendsOf(typeUri: string, index: ExtendsLookup = defaultLookup): string | null {
118
- const p = parseTypeUri(typeUri);
115
+ const p = tryParseTypeUri(typeUri);
119
116
  if (p === null) return null;
120
- return lookup(index, `${PREFIX}${p.slug}/${p.version}`);
117
+ return lookup(index, bareKey(p));
121
118
  }
122
119
 
123
120
  /**
124
121
  * Every version `typeUri` extends, transitively, nearest first: for 0.3
125
122
  * extends 0.2 extends 0.1, `["0.2", "0.1"]`.
126
123
  *
124
+ * `typeUri` may be in any SPEC §6.1 form. Each link is looked up under the
125
+ * same authority, form and slug as `typeUri` — only the version changes — so a
126
+ * chain never crosses authorities: a third-party specification's chain is read
127
+ * from its own declarations, never from a registry specification that happens
128
+ * to share its slug.
129
+ *
127
130
  * Defensive against a malformed index: the walk stops at a declaration that is
128
131
  * not strictly earlier within the same MAJOR (the build refuses those), and at
129
132
  * a cycle.
130
133
  */
131
134
  export function extendsChain(typeUri: string, index: ExtendsLookup = defaultLookup): string[] {
132
- const start = parseTypeUri(typeUri);
135
+ const start = tryParseTypeUri(typeUri);
133
136
  if (start === null) return [];
134
137
  const chain: string[] = [];
135
138
  let current = start;
136
139
  for (;;) {
137
- const next = lookup(index, `${PREFIX}${current.slug}/${current.version}`);
140
+ const next = lookup(index, bareKey(current));
138
141
  if (next === null) break;
139
- const p = parseTypeUri(`${PREFIX}${current.slug}/${next}`)!;
140
- if (p.major !== current.major || p.minor >= current.minor) break;
142
+ const m = VERSION_RE.exec(next)!;
143
+ const major = Number(m[1]);
144
+ const minor = Number(m[2]);
145
+ if (major !== current.major || minor >= current.minor) break;
141
146
  chain.push(next);
142
- current = p;
147
+ current = typeUriWithVersion(current, major, minor);
143
148
  }
144
149
  return chain;
145
150
  }
@@ -191,13 +196,16 @@ export function receivedDocumentOf<P>(doc: TrustTaskDocument<P>): TrustTaskDocum
191
196
  * implements (`spec`, a generated `SPEC` or `RESPONSE_SPEC`).
192
197
  *
193
198
  * - Same Type URI as `spec.typeUri` — accepted unchanged.
194
- * - Same slug and variant, later version whose `extends` chain reaches
195
- * `spec`'s version — accepted, payload pruned against `spec.payloadSchema`
196
- * with {@link prunePayload}, `type` kept as received.
197
- * - Same slug and variant, any other version — refused `unsupportedVersion`.
198
- * (Without a declaration a later MINOR SHOULD NOT be accepted: a draft may
199
- * break in one.)
200
- * - A different specification or variant — refused `unsupportedType`.
199
+ * - Same specification (authority, form and slug) and variant, later version
200
+ * whose `extends` chain reaches `spec`'s version — accepted, payload pruned
201
+ * against `spec.payloadSchema` with {@link prunePayload}, `type` kept as
202
+ * received.
203
+ * - Same specification and variant, any other version — refused
204
+ * `unsupportedVersion`. (Without a declaration a later MINOR SHOULD NOT be
205
+ * accepted: a draft may break in one.)
206
+ * - A different specification or variant — refused `unsupportedType`. The
207
+ * same slug under another authority is another specification (SPEC §6.1),
208
+ * so a third-party `x/0.3` never routes to a registry `x/0.1`, or back.
201
209
  * - Not a Trust Task document at all — refused `malformedRequest`.
202
210
  *
203
211
  * Proofs are not this function's business, and it neither reads nor alters
@@ -218,15 +226,17 @@ export function readForSpec<P>(
218
226
  return { kind: "accepted", doc: doc as TrustTaskDocument<P>, received: doc, extended: false };
219
227
  }
220
228
 
221
- const got = parseTypeUri(doc.type);
222
- const want = parseTypeUri(spec.typeUri);
223
- if (got === null || want === null || got.slug !== want.slug || got.fragment !== want.fragment) {
229
+ const got = tryParseTypeUri(doc.type);
230
+ const want = tryParseTypeUri(spec.typeUri);
231
+ if (got === null || want === null || !sameSpecification(got, want) || got.variant !== want.variant) {
224
232
  return refused("unsupportedType", `unsupported Trust Task type ${JSON.stringify(doc.type)}`);
225
233
  }
226
- if (got.version === want.version || !extendsChain(doc.type, index).includes(want.version)) {
234
+ const gotVersion = versionOf(got);
235
+ const wantVersion = versionOf(want);
236
+ if (gotVersion === wantVersion || !extendsChain(doc.type, index).includes(wantVersion)) {
227
237
  return refused(
228
238
  "unsupportedVersion",
229
- `version ${got.version} of ${got.slug} is not supported; this consumer implements ${want.version}`,
239
+ `version ${gotVersion} of ${got.slug} is not supported; this consumer implements ${wantVersion}`,
230
240
  );
231
241
  }
232
242
 
@@ -68,6 +68,20 @@ export {
68
68
  type ProofVerifier,
69
69
  } from "./consume.js";
70
70
 
71
+ export {
72
+ FRAMEWORK_RESERVED_SLUGS,
73
+ REGISTRY_TYPE_URI_AUTHORITY,
74
+ TypeUriError,
75
+ formatTypeUri,
76
+ parseTypeUri,
77
+ sameSpecification,
78
+ tryParseTypeUri,
79
+ typeUriWithVersion,
80
+ type TypeUri,
81
+ type TypeUriErrorKind,
82
+ type TypeUriVariant,
83
+ } from "./type-uri.js";
84
+
71
85
  export { canonicalJson, sha256Hex } from "./canonical.js";
72
86
 
73
87
  export {
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Parsed Trust Task *Type URI* — every form SPEC.md §6.1 defines: the public
3
+ * registry's canonical form, and the two shapes a third-party specification
4
+ * (SPEC.md §6.5) may use under any other authority:
5
+ *
6
+ * ```text
7
+ * https://trusttasks.org/spec/<slug>/<MAJOR.MINOR> public registry
8
+ * <prefix>/spec/<slug>/<MAJOR.MINOR> path form (https, DID URL, …)
9
+ * urn:<NID>:<prefix>:spec:<slug-segments>:<MAJOR.MINOR> URN form
10
+ * ```
11
+ *
12
+ * each optionally followed by `#request` or `#response` (§4.4.1). A Type URI
13
+ * whose scheme is `urn` must use the URN form, and every other scheme the path
14
+ * form; `http` is refused in every form. {@link formatTypeUri} reproduces the
15
+ * parsed string exactly — a Type URI is a name, compared by its whole string
16
+ * (§6.1), so the authority is kept verbatim and is part of identity: the same
17
+ * slug under two authorities is two specifications.
18
+ *
19
+ * Hand-written. Mirrors `type_uri.rs` in trust-tasks-rs, whose unit tests are
20
+ * the source of most of the vectors in `test/type-uri.test.ts`.
21
+ */
22
+
23
+ /** The `#request` / `#response` fragment of a Type URI (SPEC §4.4.1). */
24
+ export type TypeUriVariant = "request" | "response";
25
+
26
+ /** A parsed Type URI. Plain data; build a string back with {@link formatTypeUri}. */
27
+ export interface TypeUri {
28
+ /**
29
+ * Everything before `/spec/` (path form) or `:spec:` (URN form), verbatim —
30
+ * e.g. `https://trusttasks.org`, `did:web:acme.example`, `urn:example:tasks`.
31
+ */
32
+ readonly authority: string;
33
+ /** `"path"` — `<authority>/spec/<slug>/<M.N>`; `"urn"` — `<authority>:spec:<seg>:…:<M.N>`. */
34
+ readonly form: "path" | "urn";
35
+ /** The slug, `/`-separated in both forms (`payments:settle` in a URN is `payments/settle`). */
36
+ readonly slug: string;
37
+ readonly major: number;
38
+ readonly minor: number;
39
+ /** The fragment, or `null` when absent (semantically equivalent to `"request"`, §4.4.1). */
40
+ readonly variant: TypeUriVariant | null;
41
+ }
42
+
43
+ /** Why a string is not a Type URI — the cases of Rust's `ParseTypeUriError`. */
44
+ export type TypeUriErrorKind =
45
+ /** No scheme, a malformed one, or `http` (refused in every form, §6.1). */
46
+ | "scheme"
47
+ /** Neither the path form nor (for `urn:`) the URN form. */
48
+ | "shape"
49
+ /** The slug fails the §6.1 grammar. */
50
+ | "slug"
51
+ /** The slug is in the §6.1 reserved namespace and is not a framework slug. */
52
+ | "reservedSlug"
53
+ /** The version is not `MAJOR.MINOR` (§5.1). */
54
+ | "version"
55
+ /** A fragment other than `#request` / `#response`. */
56
+ | "fragment";
57
+
58
+ /** Thrown by {@link parseTypeUri}. */
59
+ export class TypeUriError extends Error {
60
+ readonly kind: TypeUriErrorKind;
61
+ /** The string that failed to parse (for `slug` / `version` / `fragment`, the offending part). */
62
+ readonly input: string;
63
+
64
+ constructor(kind: TypeUriErrorKind, input: string, message: string) {
65
+ super(message);
66
+ this.name = "TypeUriError";
67
+ this.kind = kind;
68
+ this.input = input;
69
+ }
70
+ }
71
+
72
+ /** The public registry's authority. */
73
+ export const REGISTRY_TYPE_URI_AUTHORITY = "https://trusttasks.org";
74
+
75
+ const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*$/;
76
+ const SEGMENT_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
77
+ const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
78
+ const U32_MAX = 0xffff_ffff;
79
+
80
+ /**
81
+ * The framework slugs permitted inside the §6.1 reservation. A third
82
+ * hand-maintained copy of `#/properties/slug/anyOf[1]/enum` in
83
+ * `specs/spec.meta.schema.json` (Rust's `is_allowed_framework_slug` is the
84
+ * second); `test/type-uri.test.ts` reads that file and fails if they disagree.
85
+ */
86
+ export const FRAMEWORK_RESERVED_SLUGS: readonly string[] = Object.freeze([
87
+ "trust-task",
88
+ "trust-task-error",
89
+ "trust-task-ok",
90
+ "trust-task-next-step",
91
+ "trust-task-discovery",
92
+ "trust-task-control",
93
+ "trust-ceremony-receipt",
94
+ ]);
95
+
96
+ /** Whether `slug` is in the §6.1 reserved namespace `^trust-(task|ceremony)($|-|/)`. */
97
+ function isReservedNamespace(slug: string): boolean {
98
+ const first = slug.split("/")[0]!;
99
+ return ["trust-task", "trust-ceremony"].some((stem) => first === stem || first.startsWith(`${stem}-`));
100
+ }
101
+
102
+ /** Throws unless `slug` satisfies the §6.1 grammar and reservation. */
103
+ function checkSlug(slug: string): void {
104
+ if (slug.length === 0 || !slug.split("/").every((s) => SEGMENT_RE.test(s))) {
105
+ throw new TypeUriError("slug", slug, `slug ${JSON.stringify(slug)} does not satisfy SPEC §6.1 grammar`);
106
+ }
107
+ if (isReservedNamespace(slug) && !FRAMEWORK_RESERVED_SLUGS.includes(slug)) {
108
+ throw new TypeUriError("reservedSlug", slug, `slug ${JSON.stringify(slug)} is reserved per SPEC §6.1`);
109
+ }
110
+ }
111
+
112
+ function parseVersion(s: string): [number, number] {
113
+ const m = VERSION_RE.exec(s);
114
+ const major = m ? Number(m[1]) : NaN;
115
+ const minor = m ? Number(m[2]) : NaN;
116
+ if (!(major <= U32_MAX && minor <= U32_MAX)) {
117
+ throw new TypeUriError("version", s, `version ${JSON.stringify(s)} must be MAJOR.MINOR per SPEC §5.1`);
118
+ }
119
+ return [major, minor];
120
+ }
121
+
122
+ /**
123
+ * Parse a Type URI in any SPEC §6.1 form.
124
+ *
125
+ * @throws {TypeUriError} if `typeUri` is not one — its `kind` says why.
126
+ */
127
+ export function parseTypeUri(typeUri: string): TypeUri {
128
+ if (typeof typeUri !== "string") {
129
+ throw new TypeUriError("scheme", String(typeUri), "a Type URI must be a string");
130
+ }
131
+ const hash = typeUri.indexOf("#");
132
+ const bare = hash < 0 ? typeUri : typeUri.slice(0, hash);
133
+ const fragment = hash < 0 ? null : typeUri.slice(hash + 1);
134
+ let variant: TypeUriVariant | null = null;
135
+ if (fragment !== null) {
136
+ if (fragment !== "request" && fragment !== "response") {
137
+ throw new TypeUriError(
138
+ "fragment",
139
+ fragment,
140
+ `only #request and #response fragments are permitted in a Type URI (got #${fragment})`,
141
+ );
142
+ }
143
+ variant = fragment;
144
+ }
145
+
146
+ // SPEC §6.1: any absolute URI, but never `http` — an `http` Type URI would
147
+ // normalise a transport-downgrade path for a consumer that dereferences it.
148
+ const colon = bare.indexOf(":");
149
+ const scheme = colon < 0 ? "" : bare.slice(0, colon);
150
+ if (!SCHEME_RE.test(scheme) || scheme.toLowerCase() === "http") {
151
+ throw new TypeUriError(
152
+ "scheme",
153
+ typeUri,
154
+ `Type URI must have a scheme other than http (SPEC §6.1): ${JSON.stringify(typeUri)}`,
155
+ );
156
+ }
157
+ const shape = (): TypeUriError =>
158
+ new TypeUriError(
159
+ "shape",
160
+ typeUri,
161
+ `Type URI must be <prefix>/spec/<slug>/<major.minor>, or ` +
162
+ `urn:<nid>:<prefix>:spec:<slug-segments>:<major.minor>: ${JSON.stringify(typeUri)}`,
163
+ );
164
+
165
+ let authority: string;
166
+ let slug: string;
167
+ let version: string;
168
+ let form: TypeUri["form"];
169
+ if (scheme.toLowerCase() === "urn") {
170
+ // `urn:<NID>:<prefix>:spec:<seg>:…:<MAJOR.MINOR>` — the slug is the
171
+ // segments between the first `:spec:` and the version.
172
+ const at = bare.indexOf(":spec:");
173
+ if (at < 0) throw shape();
174
+ authority = bare.slice(0, at);
175
+ const rest = bare.slice(at + ":spec:".length);
176
+ const parts = authority.split(":");
177
+ const nid = parts[1];
178
+ const ns = parts.slice(2).join(":");
179
+ if (!nid || !ns || rest.includes("/")) throw shape();
180
+ const cut = rest.lastIndexOf(":");
181
+ if (cut < 0) throw shape();
182
+ slug = rest.slice(0, cut).replace(/:/g, "/");
183
+ version = rest.slice(cut + 1);
184
+ form = "urn";
185
+ } else {
186
+ // `<prefix>/spec/<slug>/<MAJOR.MINOR>` — an https authority (with an
187
+ // optional path prefix), a DID URL, or any other scheme's prefix.
188
+ const at = bare.indexOf("/spec/");
189
+ if (at < 0) throw shape();
190
+ authority = bare.slice(0, at);
191
+ const afterScheme = authority.slice(scheme.length + 1);
192
+ if (
193
+ afterScheme.replace(/^\/+/, "").length === 0 ||
194
+ (scheme.toLowerCase() === "https" && !afterScheme.startsWith("//"))
195
+ ) {
196
+ throw shape();
197
+ }
198
+ const rest = bare.slice(at + "/spec/".length);
199
+ // The version is the final '/' segment; everything before it is the slug.
200
+ const cut = rest.lastIndexOf("/");
201
+ if (cut < 0) throw shape();
202
+ slug = rest.slice(0, cut);
203
+ version = rest.slice(cut + 1);
204
+ form = "path";
205
+ }
206
+
207
+ checkSlug(slug);
208
+ const [major, minor] = parseVersion(version);
209
+ return { authority, form, slug, major, minor, variant };
210
+ }
211
+
212
+ /** {@link parseTypeUri}, returning `null` instead of throwing. */
213
+ export function tryParseTypeUri(typeUri: string): TypeUri | null {
214
+ try {
215
+ return parseTypeUri(typeUri);
216
+ } catch (e) {
217
+ if (e instanceof TypeUriError) return null;
218
+ throw e;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The string form of `uri`. For a value {@link parseTypeUri} returned, exactly
224
+ * the string it parsed.
225
+ */
226
+ export function formatTypeUri(uri: TypeUri): string {
227
+ const v = `${uri.major}.${uri.minor}`;
228
+ const base =
229
+ uri.form === "urn"
230
+ ? `${uri.authority}:spec:${uri.slug.replace(/\//g, ":")}:${v}`
231
+ : `${uri.authority}/spec/${uri.slug}/${v}`;
232
+ return uri.variant === null ? base : `${base}#${uri.variant}`;
233
+ }
234
+
235
+ /**
236
+ * A sibling of `uri` at `major.minor`: the same authority, form, slug and
237
+ * variant, only the version changed. A declared-extension chain (SPEC §5.2)
238
+ * names only versions, so this is how it is walked — and why a chain never
239
+ * crosses authorities. (Rust's `TypeUri::with_version`.)
240
+ */
241
+ export function typeUriWithVersion(uri: TypeUri, major: number, minor: number): TypeUri {
242
+ return { ...uri, major, minor };
243
+ }
244
+
245
+ /**
246
+ * Whether `a` and `b` name versions of the same specification: the same
247
+ * authority, form and slug. Two Type URIs with the same slug under different
248
+ * authorities are different specifications (SPEC §6.1).
249
+ */
250
+ export function sameSpecification(a: TypeUri, b: TypeUri): boolean {
251
+ return a.authority === b.authority && a.form === b.form && a.slug === b.slug;
252
+ }