@aventara/client 0.0.0-stage → 0.1.0-pilot.0

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.
Files changed (84) hide show
  1. package/LICENSE +91 -0
  2. package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
  3. package/README.md +268 -2
  4. package/dist/avclient.bin.d.ts +2 -0
  5. package/dist/avclient.bin.js +15 -0
  6. package/dist/cli/command.parser.d.ts +30 -0
  7. package/dist/cli/command.parser.js +132 -0
  8. package/dist/cli/generate.command.d.ts +24 -0
  9. package/dist/cli/generate.command.js +41 -0
  10. package/dist/cli/generation-failure.renderer.d.ts +6 -0
  11. package/dist/cli/generation-failure.renderer.js +54 -0
  12. package/dist/cli/generation-success.renderer.d.ts +32 -0
  13. package/dist/cli/generation-success.renderer.js +47 -0
  14. package/dist/cli/terminal.prompter.d.ts +13 -0
  15. package/dist/cli/terminal.prompter.js +53 -0
  16. package/dist/cli/warning.renderer.d.ts +10 -0
  17. package/dist/cli/warning.renderer.js +14 -0
  18. package/dist/cli.d.ts +29 -0
  19. package/dist/cli.js +71 -0
  20. package/dist/config/client-config.interface.d.ts +62 -0
  21. package/dist/config/client-config.interface.js +14 -0
  22. package/dist/config/config.loader.d.ts +33 -0
  23. package/dist/config/config.loader.js +80 -0
  24. package/dist/config/config.resolver.d.ts +50 -0
  25. package/dist/config/config.resolver.js +126 -0
  26. package/dist/config/env.cascade.d.ts +84 -0
  27. package/dist/config/env.cascade.js +126 -0
  28. package/dist/contract/contract.acceptance.d.ts +77 -0
  29. package/dist/contract/contract.acceptance.js +124 -0
  30. package/dist/contract/contract.fetcher.d.ts +64 -0
  31. package/dist/contract/contract.fetcher.js +85 -0
  32. package/dist/contract/contract.loader.d.ts +32 -0
  33. package/dist/contract/contract.loader.js +32 -0
  34. package/dist/emit/banner.emitter.d.ts +31 -0
  35. package/dist/emit/banner.emitter.js +42 -0
  36. package/dist/emit/client-surface.emitter.d.ts +32 -0
  37. package/dist/emit/client-surface.emitter.js +236 -0
  38. package/dist/emit/client-tree.emitter.d.ts +37 -0
  39. package/dist/emit/client-tree.emitter.js +103 -0
  40. package/dist/emit/contract-carrier.emitter.d.ts +13 -0
  41. package/dist/emit/contract-carrier.emitter.js +60 -0
  42. package/dist/emit/derivation.emitter.d.ts +45 -0
  43. package/dist/emit/derivation.emitter.js +233 -0
  44. package/dist/emit/descriptor.emitter.d.ts +4 -0
  45. package/dist/emit/descriptor.emitter.js +97 -0
  46. package/dist/emit/emitted-tree.interface.d.ts +61 -0
  47. package/dist/emit/emitted-tree.interface.js +18 -0
  48. package/dist/emit/enum.emitter.d.ts +24 -0
  49. package/dist/emit/enum.emitter.js +42 -0
  50. package/dist/emit/name.deriver.d.ts +153 -0
  51. package/dist/emit/name.deriver.js +411 -0
  52. package/dist/emit/named-type.emitter.d.ts +32 -0
  53. package/dist/emit/named-type.emitter.js +50 -0
  54. package/dist/emit/runtime.emitter.d.ts +87 -0
  55. package/dist/emit/runtime.emitter.js +707 -0
  56. package/dist/emit/scalar.codec.d.ts +63 -0
  57. package/dist/emit/scalar.codec.js +498 -0
  58. package/dist/emit/transaction.emitter.d.ts +17 -0
  59. package/dist/emit/transaction.emitter.js +438 -0
  60. package/dist/generate.d.ts +123 -0
  61. package/dist/generate.js +98 -0
  62. package/dist/index.d.ts +8 -0
  63. package/dist/index.js +8 -0
  64. package/dist/init/client-config.template.d.ts +6 -0
  65. package/dist/init/client-config.template.js +22 -0
  66. package/dist/init/client-init.errors.d.ts +9 -0
  67. package/dist/init/client-init.errors.js +9 -0
  68. package/dist/init/client-init.orchestrator.d.ts +3 -0
  69. package/dist/init/client-init.orchestrator.js +82 -0
  70. package/dist/init/client-init.planner.d.ts +26 -0
  71. package/dist/init/client-init.planner.js +88 -0
  72. package/dist/init/client-init.questions.d.ts +52 -0
  73. package/dist/init/client-init.questions.js +124 -0
  74. package/dist/init/client-project.inspector.d.ts +15 -0
  75. package/dist/init/client-project.inspector.js +32 -0
  76. package/dist/init/command.runner.d.ts +8 -0
  77. package/dist/init/command.runner.js +17 -0
  78. package/dist/node-version.guard.d.ts +8 -0
  79. package/dist/node-version.guard.js +59 -0
  80. package/dist/output/output.validator.d.ts +76 -0
  81. package/dist/output/output.validator.js +254 -0
  82. package/dist/output/output.writer.d.ts +162 -0
  83. package/dist/output/output.writer.js +499 -0
  84. package/package.json +47 -3
@@ -0,0 +1,126 @@
1
+ import path from "node:path";
2
+ import { AvProtocol } from "@aventara/core/protocol";
3
+ /** The identity function that gives a `framework.client.ts` its type (§15.2). */
4
+ export function defineClientConfig(config) {
5
+ return config;
6
+ }
7
+ /** Names an environment variable to be read from the resolved cascade (§15.2). */
8
+ export function env(name) {
9
+ return { kind: "env", name };
10
+ }
11
+ /**
12
+ * The configuration cannot be resolved. The message is the whole diagnosis — the
13
+ * CLI prints it and exits non-zero; no stack is owed for a user's mistake.
14
+ */
15
+ export class ClientConfigError extends Error {
16
+ name = "ClientConfigError";
17
+ }
18
+ /**
19
+ * Resolves a config against an already-resolved cascade. Pure: it reads no
20
+ * file and no global, so it is tested against literal inputs.
21
+ */
22
+ export function resolveClientConfig(input) {
23
+ const entrypoint = resolveValue("entrypoint", input.config.entrypoint, input.cascade);
24
+ const generateAt = resolveValue("generateAt", input.config.generateAt, input.cascade);
25
+ return {
26
+ entrypoint: resolveEntrypoint(entrypoint),
27
+ generateAt: path.resolve(input.configDirectory, generateAt),
28
+ mode: input.cascade.mode,
29
+ };
30
+ }
31
+ function resolveValue(field, value, cascade) {
32
+ if (typeof value === "string") {
33
+ if (value === "")
34
+ throw new ClientConfigError(`${field} is empty.`);
35
+ return value;
36
+ }
37
+ const resolved = cascade.env[value.name];
38
+ if (resolved === undefined || resolved === "") {
39
+ throw new ClientConfigError(`${field} reads ${value.name}, which is not set in the process environment ` +
40
+ `or in any of ${cascade.candidates.join(", ")} (mode "${cascade.mode}").`);
41
+ }
42
+ return resolved;
43
+ }
44
+ /**
45
+ * The refusal's closing clause: what an entrypoint is. Never the value itself —
46
+ * an entrypoint may carry credentials.
47
+ */
48
+ const ENTRYPOINT_IS = "it must be the origin plus a mount path";
49
+ /**
50
+ * An entrypoint value resolved to its canonical form (F-822, architect's
51
+ * decision 2026-10-04; Q15). The URL half is this function's: whitespace (which
52
+ * the URL parser would trim or drop unseen), a value that is not an absolute
53
+ * URL, a scheme other than http(s), credentials (Q5, architect 2026-10-05), a
54
+ * query or fragment (even an empty one, which the parser forgets), and a `..`
55
+ * segment (which the parser would resolve away) are refused here, reading the
56
+ * value as written.
57
+ *
58
+ * The mount-path half is core's: the path AS WRITTEN — before `URL` has turned
59
+ * a backslash into a slash, resolved a `.` segment away or percent-encoded a
60
+ * character — goes to `AvProtocol.normalizeEntrypoint`, the rule the server's
61
+ * config resolution applies, so the two halves of F-822 cannot disagree
62
+ * (`entrypoint.parity.spec.ts`). Only its slash spelling is coerced — `api`,
63
+ * `/api/` and `//api//` are one intent, `/api` — and root is `""`.
64
+ *
65
+ * @throws ClientConfigError in one sentence that does not echo the value.
66
+ */
67
+ export function resolveEntrypoint(value) {
68
+ if (/\s/u.test(value)) {
69
+ throw new ClientConfigError(`entrypoint contains whitespace; ${ENTRYPOINT_IS}, written without spaces, tabs or line breaks.`);
70
+ }
71
+ let url;
72
+ try {
73
+ url = new URL(value);
74
+ }
75
+ catch {
76
+ throw new ClientConfigError("entrypoint is not an absolute URL; it must be the deployed origin plus mount path.");
77
+ }
78
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
79
+ throw new ClientConfigError(`entrypoint must use http: or https:, not ${url.protocol}`);
80
+ }
81
+ if (url.username !== "" || url.password !== "") {
82
+ throw new ClientConfigError("entrypoint must not carry credentials: the platform's fetch refuses a URL that " +
83
+ "includes them, and the entrypoint is embedded in the generated client, which ships — " +
84
+ "authenticate through a custom fetch instead.");
85
+ }
86
+ if (value.includes("?") || value.includes("#")) {
87
+ throw new ClientConfigError("entrypoint must not carry a query or fragment; it is the origin plus mount path only.");
88
+ }
89
+ // Over the whole URL, authority included — wider than the mount-path rule,
90
+ // which refuses a `..` too — so a `..` anywhere keeps its shipped message.
91
+ if (value.split(/[/\\]/u).some(isDotDotSegment)) {
92
+ throw new ClientConfigError(`entrypoint's mount path has a .. segment; ${ENTRYPOINT_IS}, with no dot segments.`);
93
+ }
94
+ const normalized = AvProtocol.normalizeEntrypoint(writtenMountPath(value));
95
+ if (!normalized.ok) {
96
+ throw new ClientConfigError(`entrypoint's mount path ${normalized.message}.`);
97
+ }
98
+ const deployment = new URL(url.href);
99
+ deployment.pathname = "/";
100
+ return { deployment, path: normalized.path };
101
+ }
102
+ /**
103
+ * The mount path of an http(s) URL exactly as written: everything after the
104
+ * scheme, the slashes that follow it, and the authority. The authority ends
105
+ * where WHATWG URL parsing ends it for a special scheme — at the first `/`,
106
+ * `\`, `?` or `#` — so this is the text the parser turns into `pathname`,
107
+ * before it does.
108
+ */
109
+ function writtenMountPath(value) {
110
+ return value.replace(/^[A-Za-z][A-Za-z0-9+.-]*:[/\\]*[^/\\?#]*/u, "");
111
+ }
112
+ /**
113
+ * The entrypoint as one absolute URL with no trailing slash — the form the
114
+ * emitted transport joins its routes to (§12.1), and the default a generated
115
+ * client embeds (§15.2, Q5: `generated/metadata.ts`'s `DEFAULT_ENTRYPOINT`).
116
+ * Root is the bare origin.
117
+ */
118
+ export function entrypointHref(entrypoint) {
119
+ const url = new URL(entrypoint.deployment.href);
120
+ url.pathname = entrypoint.path === "" ? "/" : entrypoint.path;
121
+ return url.href.replace(/\/$/, "");
122
+ }
123
+ /** `..`, spelled as WHATWG URL resolution recognises it: `.` or `%2e`, any case. */
124
+ function isDotDotSegment(segment) {
125
+ return /^(?:\.|%2e){2}$/iu.test(segment);
126
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * §15.2's `.env` cascade, resolved into a returned record.
3
+ *
4
+ * Precedence, highest first: the existing process environment
5
+ * > `.env.<mode>.local` > `.env.<mode>` > `.env.local` > `.env`.
6
+ *
7
+ * # Why a returned record, and not `process.loadEnvFile`
8
+ *
9
+ * Measured on Node v24.20.0, `process.loadEnvFile` is first-write-wins and never
10
+ * overrides an existing value — the right semantics — but it is the wrong
11
+ * mechanism here, for three reasons that were measured rather than argued:
12
+ *
13
+ * 1. It writes into the live `process.env`. A resolution that mutates the global
14
+ * leaks across tests and across invocations in one process; a suite that
15
+ * passes alone and fails in sequence is exactly that leak.
16
+ * 2. It reports an unreadable file (`EACCES`) as `ENOENT`. Catching `ENOENT`
17
+ * from it would treat a `.env` the user cannot read as a `.env` that is not
18
+ * there — a silent wrong answer.
19
+ * 3. It silently drops lines that are not assignments. So does `util.parseEnv`;
20
+ * neither has a notion of a malformed file.
21
+ *
22
+ * So each file is read with `readFileSync` (whose error code is the truth),
23
+ * parsed with `util.parseEnv` (the same parser `loadEnvFile` uses — pinned by a
24
+ * parity test), checked for lines the parser would discard, and folded
25
+ * first-write-wins over the candidates in priority order. Nothing global is
26
+ * read or written: the process environment is an INPUT.
27
+ */
28
+ /** A resolved environment: names to values, never `undefined`. */
29
+ export type EnvRecord = Readonly<Record<string, string>>;
30
+ /** The process environment as Node types it: values may be `undefined`. */
31
+ export type ProcessEnvInput = Readonly<Record<string, string | undefined>>;
32
+ /** The mode used when neither an explicit mode nor `NODE_ENV` supplies one. */
33
+ export declare const DEFAULT_ENV_MODE = "development";
34
+ /**
35
+ * Reads one candidate file. Returns `undefined` only when the file does not
36
+ * exist; any other failure throws. The seam exists so resolution can be tested
37
+ * without a filesystem — the default reads from disk.
38
+ */
39
+ export type EnvFileReader = (filePath: string) => string | undefined;
40
+ export interface EnvCascadeInput {
41
+ /** The directory the five candidates are looked up in. */
42
+ readonly directory: string;
43
+ /** A snapshot of the process environment. Never mutated. */
44
+ readonly processEnv: ProcessEnvInput;
45
+ /** An explicit mode; otherwise `NODE_ENV`, otherwise `development`. */
46
+ readonly mode?: string;
47
+ readonly read?: EnvFileReader;
48
+ }
49
+ export interface EnvCascadeResolution {
50
+ readonly mode: string;
51
+ readonly env: EnvRecord;
52
+ /** The candidate file names, highest precedence first. */
53
+ readonly candidates: readonly string[];
54
+ /** The candidates that existed and were read, highest precedence first. */
55
+ readonly loaded: readonly string[];
56
+ }
57
+ /** A candidate exists but could not be read, or contains a discarded line. */
58
+ export declare class EnvFileError extends Error {
59
+ readonly filePath: string;
60
+ readonly reason: "unreadable" | "malformed";
61
+ readonly name = "EnvFileError";
62
+ constructor(filePath: string, reason: "unreadable" | "malformed", detail: string);
63
+ }
64
+ /** The four file candidates for a mode, highest precedence first. */
65
+ export declare function envCascadeCandidates(mode: string): readonly string[];
66
+ export declare function resolveEnvMode(processEnv: ProcessEnvInput, explicit?: string): string;
67
+ export declare function resolveEnvCascade(input: EnvCascadeInput): EnvCascadeResolution;
68
+ /** Reads a candidate from disk; `ENOENT` alone means "not there". */
69
+ export declare function readEnvFileFromDisk(filePath: string): string | undefined;
70
+ /**
71
+ * Parses one file's content. A file whose content holds a line the parser would
72
+ * silently discard is malformed: the user wrote something they expect to take
73
+ * effect, and it would not. The error names the line number, never its content
74
+ * — a `.env` line is as likely as not to be a secret.
75
+ */
76
+ export declare function parseEnvFile(filePath: string, content: string): Record<string, string>;
77
+ /**
78
+ * The 1-based number of the first line `util.parseEnv` would drop, or
79
+ * `undefined`. Mirrors the parser's line model: blank and `#` lines are
80
+ * skipped, an optional `export ` prefix is allowed, and a value opening with a
81
+ * quote runs to the next matching quote anywhere later in the content —
82
+ * spanning lines — or, when there is none, to the end of its own line.
83
+ */
84
+ export declare function firstDiscardedLine(content: string): number | undefined;
@@ -0,0 +1,126 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseEnv } from "node:util";
4
+ /** The mode used when neither an explicit mode nor `NODE_ENV` supplies one. */
5
+ export const DEFAULT_ENV_MODE = "development";
6
+ /** A candidate exists but could not be read, or contains a discarded line. */
7
+ export class EnvFileError extends Error {
8
+ filePath;
9
+ reason;
10
+ name = "EnvFileError";
11
+ constructor(filePath, reason, detail) {
12
+ super(`${filePath}: ${detail}`);
13
+ this.filePath = filePath;
14
+ this.reason = reason;
15
+ }
16
+ }
17
+ /** The four file candidates for a mode, highest precedence first. */
18
+ export function envCascadeCandidates(mode) {
19
+ return [`.env.${mode}.local`, `.env.${mode}`, ".env.local", ".env"];
20
+ }
21
+ export function resolveEnvMode(processEnv, explicit) {
22
+ if (explicit !== undefined && explicit !== "")
23
+ return explicit;
24
+ const nodeEnv = processEnv.NODE_ENV;
25
+ return nodeEnv !== undefined && nodeEnv !== "" ? nodeEnv : DEFAULT_ENV_MODE;
26
+ }
27
+ export function resolveEnvCascade(input) {
28
+ const mode = resolveEnvMode(input.processEnv, input.mode);
29
+ const read = input.read ?? readEnvFileFromDisk;
30
+ const candidates = envCascadeCandidates(mode);
31
+ const env = {};
32
+ for (const [name, value] of Object.entries(input.processEnv)) {
33
+ if (value !== undefined)
34
+ env[name] = value;
35
+ }
36
+ const loaded = [];
37
+ // Highest precedence first: under first-write-wins, the first source to
38
+ // define a name owns it. Reversing this loop inverts the precedence.
39
+ for (const candidate of candidates) {
40
+ const filePath = path.join(input.directory, candidate);
41
+ const content = read(filePath);
42
+ if (content === undefined)
43
+ continue;
44
+ loaded.push(candidate);
45
+ for (const [name, value] of Object.entries(parseEnvFile(filePath, content))) {
46
+ if (!Object.hasOwn(env, name))
47
+ env[name] = value;
48
+ }
49
+ }
50
+ return { mode, env: Object.freeze(env), candidates, loaded };
51
+ }
52
+ /** Reads a candidate from disk; `ENOENT` alone means "not there". */
53
+ export function readEnvFileFromDisk(filePath) {
54
+ try {
55
+ return readFileSync(filePath, "utf8");
56
+ }
57
+ catch (error) {
58
+ if (isErrnoException(error) && error.code === "ENOENT")
59
+ return undefined;
60
+ const code = isErrnoException(error) ? error.code : undefined;
61
+ throw new EnvFileError(filePath, "unreadable", `exists but cannot be read (${code ?? "unknown error"})`);
62
+ }
63
+ }
64
+ /**
65
+ * Parses one file's content. A file whose content holds a line the parser would
66
+ * silently discard is malformed: the user wrote something they expect to take
67
+ * effect, and it would not. The error names the line number, never its content
68
+ * — a `.env` line is as likely as not to be a secret.
69
+ */
70
+ export function parseEnvFile(filePath, content) {
71
+ const source = content.startsWith("") ? content.slice(1) : content;
72
+ const discarded = firstDiscardedLine(source);
73
+ if (discarded !== undefined) {
74
+ throw new EnvFileError(filePath, "malformed", `line ${discarded} is not a NAME=value assignment and would be ignored`);
75
+ }
76
+ return parseEnv(source);
77
+ }
78
+ const QUOTES = new Set(['"', "'", "`"]);
79
+ /**
80
+ * The 1-based number of the first line `util.parseEnv` would drop, or
81
+ * `undefined`. Mirrors the parser's line model: blank and `#` lines are
82
+ * skipped, an optional `export ` prefix is allowed, and a value opening with a
83
+ * quote runs to the next matching quote anywhere later in the content —
84
+ * spanning lines — or, when there is none, to the end of its own line.
85
+ */
86
+ export function firstDiscardedLine(content) {
87
+ let offset = 0;
88
+ while (offset < content.length) {
89
+ const lineEnd = endOfLine(content, offset);
90
+ const line = content.slice(offset, lineEnd);
91
+ const raw = line.trim();
92
+ if (raw !== "" && !raw.startsWith("#")) {
93
+ const assignment = raw.startsWith("export ")
94
+ ? raw.slice("export ".length)
95
+ : raw;
96
+ const equals = assignment.indexOf("=");
97
+ if (equals === -1 || assignment.slice(0, equals).trim() === "") {
98
+ return lineNumberAt(content, offset);
99
+ }
100
+ let valueStart = offset + line.indexOf("=") + 1;
101
+ while (content[valueStart] === " " || content[valueStart] === "\t") {
102
+ valueStart += 1;
103
+ }
104
+ const quote = content[valueStart];
105
+ if (quote !== undefined && QUOTES.has(quote)) {
106
+ const closing = content.indexOf(quote, valueStart + 1);
107
+ if (closing !== -1) {
108
+ offset = endOfLine(content, closing) + 1;
109
+ continue;
110
+ }
111
+ }
112
+ }
113
+ offset = lineEnd + 1;
114
+ }
115
+ return undefined;
116
+ }
117
+ function endOfLine(content, from) {
118
+ const newline = content.indexOf("\n", from);
119
+ return newline === -1 ? content.length : newline;
120
+ }
121
+ function lineNumberAt(content, offset) {
122
+ return content.slice(0, offset).split("\n").length;
123
+ }
124
+ function isErrnoException(error) {
125
+ return error instanceof Error && "code" in error;
126
+ }
@@ -0,0 +1,77 @@
1
+ import { type ClientContract } from "@aventara/core";
2
+ /**
3
+ * §15.3's validation steps over a fetched body, in the specification's order, as
4
+ * one decision (plan §7, group 2): protocol support (§19.1), then ClientContract
5
+ * structure, then the advertised hash recomputed and compared (§19.2).
6
+ *
7
+ * # The structure check is core's, never a second implementation
8
+ *
9
+ * What a ClientContract IS belongs to core, so the structure step is core's
10
+ * `validateClientContractStructure` and nothing here restates a member, a kind or
11
+ * a vocabulary. Before it, a body with a valid envelope and a malformed member —
12
+ * `resources: { Spell: 5 }` — reached core's canonicalizer, which reads members
13
+ * without validating them, and escaped as a plain `TypeError` and a stack.
14
+ *
15
+ * # Protocol support is judged first, from a read rather than a rule
16
+ *
17
+ * A body from a protocol this generator does not speak may have a different
18
+ * structure, and "unsupported" is then the true and useful answer. So the
19
+ * advertised version is READ — leniently, never judged — and judged against the
20
+ * supported set before any structure is. A body whose version cannot be read at
21
+ * all goes on to the structure step, which names what is wrong with it.
22
+ *
23
+ * # A value, not a throw site
24
+ *
25
+ * M3's rule — failure is a message and an exit code, never a stack — needs both to
26
+ * be typed members, so a rejection is the `ContractRejected` arm carrying its
27
+ * sentence and its exit code, and `ContractProtocolError` is built from it only
28
+ * where generation stops.
29
+ *
30
+ * # The hash is core's, never a second implementation
31
+ *
32
+ * The advertised `protocol.hash` is recomputed with `computeClientContractHash` —
33
+ * the function the compiler stamps it with (M6a) — and compared. A hash computed
34
+ * here would be a second source of the Contract's identity.
35
+ *
36
+ * # Bodies the hash cannot be recomputed over (S0)
37
+ *
38
+ * The ClientContract type admits values RFC 8785 cannot encode, and a valid JSON
39
+ * body produces two of them: `1e999` parses to `Infinity`, and `"\ud800"` parses to
40
+ * a lone surrogate. Core rejects both with `CanonicalJsonError`. No server can have
41
+ * stamped a hash over such a body — its own canonicalizer would have refused — so it
42
+ * is refused at THIS step, as `hash-mismatch` with its own sentence and core's path,
43
+ * never let escape as a stack. Both values are ones the ClientContract TYPE admits
44
+ * (`number`, `string`), so core's structure check accepts them by design; what
45
+ * fails is §19.2's recompute. ONLY that class is caught: anything else thrown while
46
+ * hashing is not a known property of the input, and keeps its stack as a defect.
47
+ */
48
+ /** The protocol versions this generator speaks (§19.1: support is explicit). */
49
+ export declare const SUPPORTED_PROTOCOL_VERSIONS: ReadonlySet<number>;
50
+ /** Closed at three (plan §7): §19.1, §19.2, and §15.3's structure step. */
51
+ export type ContractRejectionReason = "protocol-unsupported" | "hash-mismatch" | "structure-invalid";
52
+ export type ContractAccepted = {
53
+ readonly accepted: true;
54
+ /**
55
+ * Trusted to the depth this step checks: core's structure check and the hash.
56
+ * It is the erased ClientContract (ADR 0009): no capability is interpreted here
57
+ * (plan §1).
58
+ */
59
+ readonly contract: ClientContract;
60
+ };
61
+ export type ContractRejected = {
62
+ readonly accepted: false;
63
+ readonly reason: ContractRejectionReason;
64
+ /** A sentence for the person running generate: what is wrong, and what to do. */
65
+ readonly message: string;
66
+ /** The process exit code generation stops with. Never 0. */
67
+ readonly exitCode: number;
68
+ };
69
+ export type ContractAcceptance = ContractAccepted | ContractRejected;
70
+ export declare function acceptClientContract(body: unknown): Promise<ContractAcceptance>;
71
+ /** The refusal that stops generation, carrying the rejection's reason and sentence. */
72
+ export declare class ContractProtocolError extends Error {
73
+ readonly name = "ContractProtocolError";
74
+ readonly reason: ContractRejectionReason;
75
+ readonly exitCode: number;
76
+ constructor(rejection: ContractRejected, contractUrl: string);
77
+ }
@@ -0,0 +1,124 @@
1
+ import { AVENTARA_PROTOCOL_VERSION, CanonicalJsonError, computeClientContractHash, validateClientContractStructure, } from "@aventara/core";
2
+ /**
3
+ * §15.3's validation steps over a fetched body, in the specification's order, as
4
+ * one decision (plan §7, group 2): protocol support (§19.1), then ClientContract
5
+ * structure, then the advertised hash recomputed and compared (§19.2).
6
+ *
7
+ * # The structure check is core's, never a second implementation
8
+ *
9
+ * What a ClientContract IS belongs to core, so the structure step is core's
10
+ * `validateClientContractStructure` and nothing here restates a member, a kind or
11
+ * a vocabulary. Before it, a body with a valid envelope and a malformed member —
12
+ * `resources: { Spell: 5 }` — reached core's canonicalizer, which reads members
13
+ * without validating them, and escaped as a plain `TypeError` and a stack.
14
+ *
15
+ * # Protocol support is judged first, from a read rather than a rule
16
+ *
17
+ * A body from a protocol this generator does not speak may have a different
18
+ * structure, and "unsupported" is then the true and useful answer. So the
19
+ * advertised version is READ — leniently, never judged — and judged against the
20
+ * supported set before any structure is. A body whose version cannot be read at
21
+ * all goes on to the structure step, which names what is wrong with it.
22
+ *
23
+ * # A value, not a throw site
24
+ *
25
+ * M3's rule — failure is a message and an exit code, never a stack — needs both to
26
+ * be typed members, so a rejection is the `ContractRejected` arm carrying its
27
+ * sentence and its exit code, and `ContractProtocolError` is built from it only
28
+ * where generation stops.
29
+ *
30
+ * # The hash is core's, never a second implementation
31
+ *
32
+ * The advertised `protocol.hash` is recomputed with `computeClientContractHash` —
33
+ * the function the compiler stamps it with (M6a) — and compared. A hash computed
34
+ * here would be a second source of the Contract's identity.
35
+ *
36
+ * # Bodies the hash cannot be recomputed over (S0)
37
+ *
38
+ * The ClientContract type admits values RFC 8785 cannot encode, and a valid JSON
39
+ * body produces two of them: `1e999` parses to `Infinity`, and `"\ud800"` parses to
40
+ * a lone surrogate. Core rejects both with `CanonicalJsonError`. No server can have
41
+ * stamped a hash over such a body — its own canonicalizer would have refused — so it
42
+ * is refused at THIS step, as `hash-mismatch` with its own sentence and core's path,
43
+ * never let escape as a stack. Both values are ones the ClientContract TYPE admits
44
+ * (`number`, `string`), so core's structure check accepts them by design; what
45
+ * fails is §19.2's recompute. ONLY that class is caught: anything else thrown while
46
+ * hashing is not a known property of the input, and keeps its stack as a defect.
47
+ */
48
+ /** The protocol versions this generator speaks (§19.1: support is explicit). */
49
+ export const SUPPORTED_PROTOCOL_VERSIONS = new Set([
50
+ AVENTARA_PROTOCOL_VERSION,
51
+ ]);
52
+ /** A refused contract stops generation; the adapter CLI's precedent is 1 (M3). */
53
+ const REFUSED_EXIT_CODE = 1;
54
+ const CHECK_ENTRYPOINT = "Check that the entrypoint is the deployed Aventara entrypoint (origin plus mount path).";
55
+ export async function acceptClientContract(body) {
56
+ const version = advertisedProtocolVersion(body);
57
+ if (version !== undefined && !SUPPORTED_PROTOCOL_VERSIONS.has(version)) {
58
+ return reject("protocol-unsupported", `the deployment speaks Aventara protocol version ${version}, and this generator ` +
59
+ `supports only ${describeSupported()}. Use a generator release that supports protocol ` +
60
+ `version ${version}. A client running against this deployment meets the same ` +
61
+ "fact as A2006 PROTOCOL_MISMATCH.");
62
+ }
63
+ const structure = validateClientContractStructure(body);
64
+ if (!structure.valid) {
65
+ return reject("structure-invalid", `it is not a ClientContract: ${structure.message}. ${CHECK_ENTRYPOINT}`);
66
+ }
67
+ const contract = structure.contract;
68
+ let recomputed;
69
+ try {
70
+ recomputed = await computeClientContractHash(contract);
71
+ }
72
+ catch (error) {
73
+ if (!(error instanceof CanonicalJsonError)) {
74
+ throw error;
75
+ }
76
+ return reject("hash-mismatch", `it holds a value canonical JSON cannot represent (${error.message}), so its ` +
77
+ "advertised hash cannot be verified. No Aventara server stamps a hash over such a " +
78
+ `body. ${CHECK_ENTRYPOINT}`);
79
+ }
80
+ if (recomputed !== contract.protocol.hash) {
81
+ return reject("hash-mismatch", `its advertised hash ${contract.protocol.hash} does not match its contents, which ` +
82
+ `hash to ${recomputed}. The contract changed after the server stamped it; check for ` +
83
+ "a proxy or cache rewriting the response, then generate again.");
84
+ }
85
+ return { accepted: true, contract };
86
+ }
87
+ /** The refusal that stops generation, carrying the rejection's reason and sentence. */
88
+ export class ContractProtocolError extends Error {
89
+ name = "ContractProtocolError";
90
+ reason;
91
+ exitCode;
92
+ constructor(rejection, contractUrl) {
93
+ super(`The ClientContract from ${contractUrl} was refused: ${rejection.message}`);
94
+ this.reason = rejection.reason;
95
+ this.exitCode = rejection.exitCode;
96
+ }
97
+ }
98
+ /**
99
+ * `protocol.version` where the body states a number there, `undefined` otherwise.
100
+ * A read, not a rule: it judges nothing, and a body it cannot read a version from
101
+ * is left for the structure step to describe.
102
+ */
103
+ function advertisedProtocolVersion(body) {
104
+ if (!isJsonObject(body) || !Object.hasOwn(body, "protocol")) {
105
+ return undefined;
106
+ }
107
+ const protocol = body.protocol;
108
+ if (!isJsonObject(protocol) || !Object.hasOwn(protocol, "version")) {
109
+ return undefined;
110
+ }
111
+ return typeof protocol.version === "number" ? protocol.version : undefined;
112
+ }
113
+ function isJsonObject(value) {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
115
+ }
116
+ function reject(reason, message) {
117
+ return { accepted: false, reason, message, exitCode: REFUSED_EXIT_CODE };
118
+ }
119
+ function describeSupported() {
120
+ const versions = [...SUPPORTED_PROTOCOL_VERSIONS];
121
+ return versions.length === 1
122
+ ? `version ${versions[0]}`
123
+ : `versions ${versions.join(", ")}`;
124
+ }
@@ -0,0 +1,64 @@
1
+ import type { ClientEntrypoint } from "../config/client-config.interface.js";
2
+ /**
3
+ * §12.5's discovery request: one `GET <entrypoint>/_contract`, with no headers —
4
+ * §12.4: *"The contract GET itself requires neither header because it is the
5
+ * discovery mechanism."* The entrypoint is the CLIENT config's URL (§15.2,
6
+ * resolved in S2), not the server's mount-path setting.
7
+ *
8
+ * # Transport, and only transport
9
+ *
10
+ * This file's success is "a JSON value arrived". It does not judge the value: a
11
+ * body that is a JSON array is still a successful fetch, and refusing it is
12
+ * `contract.acceptance.ts`'s job. Every way of failing to produce a JSON value is a
13
+ * `ContractTransportError`, a different class from `ContractProtocolError`,
14
+ * because the two have different remedies — reach the deployment, versus regenerate
15
+ * or upgrade against what it served — and a message that blurs them sends the
16
+ * developer to the wrong one.
17
+ *
18
+ * `fetch` is injected, so the transport is tested without a server; the default is
19
+ * the platform's.
20
+ */
21
+ /** The part of a `Response` the fetcher reads. */
22
+ export type ContractResponse = Pick<Response, "ok" | "status" | "text">;
23
+ /** The `fetch` the fetcher calls. `globalThis.fetch` satisfies it. */
24
+ export type ContractFetch = (url: URL, init: {
25
+ readonly method: "GET";
26
+ readonly headers?: Readonly<Record<string, string>>;
27
+ }) => Promise<ContractResponse>;
28
+ /**
29
+ * What a conditional GET answers when the served ClientContract is the one named
30
+ * (`304 Not Modified`, Phase 10 Q8): no body, nothing to judge.
31
+ */
32
+ export declare const CONTRACT_NOT_MODIFIED: unique symbol;
33
+ export type ContractTransportFailureReason =
34
+ /** No response at all: DNS, refused connection, TLS, abort. */
35
+ "request-failed"
36
+ /** A response that is not 2xx — nothing was served for the protocol to judge. */
37
+ | "http-status"
38
+ /** A 2xx response whose body could not be read to the end. */
39
+ | "body-unreadable"
40
+ /** A body that is not JSON — an HTML page, an empty body, a truncation. */
41
+ | "body-not-json";
42
+ export declare class ContractTransportError extends Error {
43
+ readonly reason: ContractTransportFailureReason;
44
+ readonly name = "ContractTransportError";
45
+ constructor(reason: ContractTransportFailureReason, message: string, options?: ErrorOptions);
46
+ }
47
+ /**
48
+ * `<entrypoint>/_contract`: the canonical mount path and the route, concatenated.
49
+ * The path's canonical form is resolution's (F-822) — root is `""` — so the join
50
+ * repairs nothing and special-cases nothing. URL resolution would be wrong here:
51
+ * `new URL("_contract", "https://h/api")` replaces `api`.
52
+ */
53
+ export declare function contractUrlOf(entrypoint: ClientEntrypoint): URL;
54
+ /** The URL as it may be printed: credentials in the entrypoint never are. */
55
+ export declare function displayUrl(url: URL): string;
56
+ /**
57
+ * GETs the ClientContract and returns the parsed body, unjudged — or, when
58
+ * `ifNoneMatch` (a quoted contract hash, the deployment's entity tag) is sent and
59
+ * the deployment answers `304`, {@link CONTRACT_NOT_MODIFIED} (Phase 12-rest Q6).
60
+ * A `304` to a request that sent none is no answer, like any other non-2xx.
61
+ *
62
+ * @throws ContractTransportError when no JSON value arrives.
63
+ */
64
+ export declare function fetchClientContractBody(entrypoint: ClientEntrypoint, fetch?: ContractFetch, ifNoneMatch?: string): Promise<unknown>;