@asturiu/v0-sdk 0.0.0-stage → 1.0.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 (239) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +82 -2
  5. package/contract/assertion-vectors.json +90 -0
  6. package/contract/schemas/branding.schema.json +46 -0
  7. package/contract/schemas/definitions.schema.json +376 -0
  8. package/contract/schemas/portal.schema.json +56 -0
  9. package/contract/tenant-api-routes.json +165 -0
  10. package/dist/branding.d.ts +131 -0
  11. package/dist/branding.d.ts.map +1 -0
  12. package/dist/branding.js +206 -0
  13. package/dist/branding.js.map +1 -0
  14. package/dist/changesets.d.ts +58 -0
  15. package/dist/changesets.d.ts.map +1 -0
  16. package/dist/changesets.js +18 -0
  17. package/dist/changesets.js.map +1 -0
  18. package/dist/cli/bin.d.ts +3 -0
  19. package/dist/cli/bin.d.ts.map +1 -0
  20. package/dist/cli/bin.js +12 -0
  21. package/dist/cli/bin.js.map +1 -0
  22. package/dist/cli/changesets.d.ts +14 -0
  23. package/dist/cli/changesets.d.ts.map +1 -0
  24. package/dist/cli/changesets.js +252 -0
  25. package/dist/cli/changesets.js.map +1 -0
  26. package/dist/cli/cli.d.ts +37 -0
  27. package/dist/cli/cli.d.ts.map +1 -0
  28. package/dist/cli/cli.js +415 -0
  29. package/dist/cli/cli.js.map +1 -0
  30. package/dist/cli/context.d.ts +147 -0
  31. package/dist/cli/context.d.ts.map +1 -0
  32. package/dist/cli/context.js +258 -0
  33. package/dist/cli/context.js.map +1 -0
  34. package/dist/cli/engine.d.ts +59 -0
  35. package/dist/cli/engine.d.ts.map +1 -0
  36. package/dist/cli/engine.js +85 -0
  37. package/dist/cli/engine.js.map +1 -0
  38. package/dist/cli/feedback.d.ts +40 -0
  39. package/dist/cli/feedback.d.ts.map +1 -0
  40. package/dist/cli/feedback.js +420 -0
  41. package/dist/cli/feedback.js.map +1 -0
  42. package/dist/cli/files.d.ts +83 -0
  43. package/dist/cli/files.d.ts.map +1 -0
  44. package/dist/cli/files.js +389 -0
  45. package/dist/cli/files.js.map +1 -0
  46. package/dist/cli/index.d.ts +8 -0
  47. package/dist/cli/index.d.ts.map +1 -0
  48. package/dist/cli/index.js +8 -0
  49. package/dist/cli/index.js.map +1 -0
  50. package/dist/cli/overview.d.ts +6 -0
  51. package/dist/cli/overview.d.ts.map +1 -0
  52. package/dist/cli/overview.js +379 -0
  53. package/dist/cli/overview.js.map +1 -0
  54. package/dist/cli/parts.d.ts +5 -0
  55. package/dist/cli/parts.d.ts.map +1 -0
  56. package/dist/cli/parts.js +414 -0
  57. package/dist/cli/parts.js.map +1 -0
  58. package/dist/cli/sources.d.ts +37 -0
  59. package/dist/cli/sources.d.ts.map +1 -0
  60. package/dist/cli/sources.js +91 -0
  61. package/dist/cli/sources.js.map +1 -0
  62. package/dist/cli/terminal.d.ts +135 -0
  63. package/dist/cli/terminal.d.ts.map +1 -0
  64. package/dist/cli/terminal.js +353 -0
  65. package/dist/cli/terminal.js.map +1 -0
  66. package/dist/cli/ui-page.d.ts +8 -0
  67. package/dist/cli/ui-page.d.ts.map +1 -0
  68. package/dist/cli/ui-page.js +216 -0
  69. package/dist/cli/ui-page.js.map +1 -0
  70. package/dist/cli/ui.d.ts +54 -0
  71. package/dist/cli/ui.d.ts.map +1 -0
  72. package/dist/cli/ui.js +251 -0
  73. package/dist/cli/ui.js.map +1 -0
  74. package/dist/client.d.ts +88 -0
  75. package/dist/client.d.ts.map +1 -0
  76. package/dist/client.js +165 -0
  77. package/dist/client.js.map +1 -0
  78. package/dist/contacts.d.ts +43 -0
  79. package/dist/contacts.d.ts.map +1 -0
  80. package/dist/contacts.js +46 -0
  81. package/dist/contacts.js.map +1 -0
  82. package/dist/definition-set.d.ts +731 -0
  83. package/dist/definition-set.d.ts.map +1 -0
  84. package/dist/definition-set.js +659 -0
  85. package/dist/definition-set.js.map +1 -0
  86. package/dist/definitions.d.ts +42 -0
  87. package/dist/definitions.d.ts.map +1 -0
  88. package/dist/definitions.js +154 -0
  89. package/dist/definitions.js.map +1 -0
  90. package/dist/enrolment.d.ts +9 -0
  91. package/dist/enrolment.d.ts.map +1 -0
  92. package/dist/enrolment.js +8 -0
  93. package/dist/enrolment.js.map +1 -0
  94. package/dist/fill.d.ts +73 -0
  95. package/dist/fill.d.ts.map +1 -0
  96. package/dist/fill.js +46 -0
  97. package/dist/fill.js.map +1 -0
  98. package/dist/index.d.ts +39 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +39 -0
  101. package/dist/index.js.map +1 -0
  102. package/dist/notifications.d.ts +33 -0
  103. package/dist/notifications.d.ts.map +1 -0
  104. package/dist/notifications.js +36 -0
  105. package/dist/notifications.js.map +1 -0
  106. package/dist/oidc.d.ts +122 -0
  107. package/dist/oidc.d.ts.map +1 -0
  108. package/dist/oidc.js +150 -0
  109. package/dist/oidc.js.map +1 -0
  110. package/dist/overlay.d.ts +109 -0
  111. package/dist/overlay.d.ts.map +1 -0
  112. package/dist/overlay.js +58 -0
  113. package/dist/overlay.js.map +1 -0
  114. package/dist/passes.d.ts +95 -0
  115. package/dist/passes.d.ts.map +1 -0
  116. package/dist/passes.js +140 -0
  117. package/dist/passes.js.map +1 -0
  118. package/dist/portal-options.d.ts +65 -0
  119. package/dist/portal-options.d.ts.map +1 -0
  120. package/dist/portal-options.js +120 -0
  121. package/dist/portal-options.js.map +1 -0
  122. package/dist/reads.d.ts +41 -0
  123. package/dist/reads.d.ts.map +1 -0
  124. package/dist/reads.js +44 -0
  125. package/dist/reads.js.map +1 -0
  126. package/dist/recovery-words.d.ts +8 -0
  127. package/dist/recovery-words.d.ts.map +1 -0
  128. package/dist/recovery-words.js +136 -0
  129. package/dist/recovery-words.js.map +1 -0
  130. package/dist/recovery.d.ts +198 -0
  131. package/dist/recovery.d.ts.map +1 -0
  132. package/dist/recovery.js +112 -0
  133. package/dist/recovery.js.map +1 -0
  134. package/dist/reveal.d.ts +35 -0
  135. package/dist/reveal.d.ts.map +1 -0
  136. package/dist/reveal.js +66 -0
  137. package/dist/reveal.js.map +1 -0
  138. package/dist/seal.d.ts +15 -0
  139. package/dist/seal.d.ts.map +1 -0
  140. package/dist/seal.js +0 -0
  141. package/dist/seal.js.map +1 -0
  142. package/dist/session.d.ts +66 -0
  143. package/dist/session.d.ts.map +1 -0
  144. package/dist/session.js +61 -0
  145. package/dist/session.js.map +1 -0
  146. package/dist/signing-in-mark.d.ts +14 -0
  147. package/dist/signing-in-mark.d.ts.map +1 -0
  148. package/dist/signing-in-mark.js +24 -0
  149. package/dist/signing-in-mark.js.map +1 -0
  150. package/dist/signing-in.d.ts +22 -0
  151. package/dist/signing-in.d.ts.map +1 -0
  152. package/dist/signing-in.js +203 -0
  153. package/dist/signing-in.js.map +1 -0
  154. package/dist/tags.d.ts +96 -0
  155. package/dist/tags.d.ts.map +1 -0
  156. package/dist/tags.js +88 -0
  157. package/dist/tags.js.map +1 -0
  158. package/dist/tenant-subscription.d.ts +47 -0
  159. package/dist/tenant-subscription.d.ts.map +1 -0
  160. package/dist/tenant-subscription.js +7 -0
  161. package/dist/tenant-subscription.js.map +1 -0
  162. package/dist/testing/index.d.ts +125 -0
  163. package/dist/testing/index.d.ts.map +1 -0
  164. package/dist/testing/index.js +320 -0
  165. package/dist/testing/index.js.map +1 -0
  166. package/dist/theme.d.ts +237 -0
  167. package/dist/theme.d.ts.map +1 -0
  168. package/dist/theme.js +504 -0
  169. package/dist/theme.js.map +1 -0
  170. package/dist/tickets.d.ts +152 -0
  171. package/dist/tickets.d.ts.map +1 -0
  172. package/dist/tickets.js +65 -0
  173. package/dist/tickets.js.map +1 -0
  174. package/dist/tiers.d.ts +168 -0
  175. package/dist/tiers.d.ts.map +1 -0
  176. package/dist/tiers.js +341 -0
  177. package/dist/tiers.js.map +1 -0
  178. package/dist/types.d.ts +885 -0
  179. package/dist/types.d.ts.map +1 -0
  180. package/dist/types.js +12 -0
  181. package/dist/types.js.map +1 -0
  182. package/dist/urlid.d.ts +50 -0
  183. package/dist/urlid.d.ts.map +1 -0
  184. package/dist/urlid.js +78 -0
  185. package/dist/urlid.js.map +1 -0
  186. package/dist/version.d.ts +15 -0
  187. package/dist/version.d.ts.map +1 -0
  188. package/dist/version.js +15 -0
  189. package/dist/version.js.map +1 -0
  190. package/make/asturiu.mk +43 -0
  191. package/package.json +63 -4
  192. package/src/branding.ts +221 -0
  193. package/src/changesets.ts +61 -0
  194. package/src/cli/bin.ts +12 -0
  195. package/src/cli/changesets.ts +233 -0
  196. package/src/cli/cli.ts +425 -0
  197. package/src/cli/context.ts +299 -0
  198. package/src/cli/engine.ts +112 -0
  199. package/src/cli/feedback.ts +406 -0
  200. package/src/cli/files.ts +371 -0
  201. package/src/cli/index.ts +7 -0
  202. package/src/cli/overview.ts +343 -0
  203. package/src/cli/parts.ts +371 -0
  204. package/src/cli/sources.ts +111 -0
  205. package/src/cli/terminal.ts +384 -0
  206. package/src/cli/ui-page.ts +215 -0
  207. package/src/cli/ui.ts +266 -0
  208. package/src/client.ts +202 -0
  209. package/src/contacts.ts +61 -0
  210. package/src/definition-set.ts +965 -0
  211. package/src/definitions.ts +156 -0
  212. package/src/enrolment.ts +10 -0
  213. package/src/fill.ts +83 -0
  214. package/src/index.ts +38 -0
  215. package/src/notifications.ts +55 -0
  216. package/src/oidc.ts +199 -0
  217. package/src/overlay.ts +137 -0
  218. package/src/passes.ts +168 -0
  219. package/src/portal-options.ts +125 -0
  220. package/src/reads.ts +71 -0
  221. package/src/recovery-words.ts +137 -0
  222. package/src/recovery.ts +269 -0
  223. package/src/reveal.ts +72 -0
  224. package/src/seal.ts +0 -0
  225. package/src/session.ts +93 -0
  226. package/src/signing-in-mark.ts +23 -0
  227. package/src/signing-in.ts +213 -0
  228. package/src/tags.ts +146 -0
  229. package/src/tenant-subscription.ts +44 -0
  230. package/src/testing/index.ts +337 -0
  231. package/src/theme.ts +628 -0
  232. package/src/tickets.ts +195 -0
  233. package/src/tiers.ts +397 -0
  234. package/src/types.ts +847 -0
  235. package/src/urlid.ts +77 -0
  236. package/src/version.ts +15 -0
  237. package/templates/asturiu/branding.json +4 -0
  238. package/templates/asturiu/definitions.json +23 -0
  239. package/templates/asturiu/portal.json +16 -0
package/src/cli/ui.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * `asturiu-v0-tenant ui` (ADR-074 §7): a page on this machine for the tasks that touch the repository's files, served
3
+ * by the command line's own process.
4
+ *
5
+ * - Loopback only: 127.0.0.1 on a free port. The address is printed once with a one-time token; the token becomes an
6
+ * HttpOnly, SameSite=Strict cookie and the address loses it. Every request must carry the Host of that address and
7
+ * every write its Origin. There are no CORS headers.
8
+ * - The key stays in this process. The page never receives it and never signs.
9
+ * - Each button runs `run()` (the same function the terminal does) with `--json`, from a closed list of actions, and
10
+ * shows the answer beside the line to type instead.
11
+ * - Text from the Engine is data: the page writes it as text, never as markup. Nothing is fetched from elsewhere.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { existsSync, statSync } from "node:fs";
16
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
17
+ import path from "node:path";
18
+ import { run } from "./cli.js";
19
+ import { type Ctx, EXIT, Fail, emit, str } from "./context.js";
20
+ import { UI_PAGE } from "./ui-page.js";
21
+
22
+ /** One action the page may ask for: the arguments it runs with, and the line the person would type instead. */
23
+ export interface UiAction {
24
+ argv: string[];
25
+ shown: string[];
26
+ /** Whether it writes to the repository or sends to the Engine: the page asks first. */
27
+ writes: boolean;
28
+ }
29
+
30
+ const PARTS = ["definitions", "branding", "portal"];
31
+ const KINDS = ["defect", "limitation", "question"];
32
+
33
+ /**
34
+ * The closed list of what the page may run. Nothing outside it, and no argument the page sends goes on unchecked:
35
+ * no keys, no people, no roles, no Customers and no tickets of theirs (§7).
36
+ */
37
+ export function uiAction(root: string, name: string, p: Record<string, unknown> = {}): UiAction | null {
38
+ const same = (argv: string[], writes = false): UiAction => ({ argv, shown: argv, writes });
39
+ switch (name) {
40
+ case "status":
41
+ case "doctor":
42
+ case "engine":
43
+ case "definitions":
44
+ case "branding":
45
+ case "changesets":
46
+ return same([name]);
47
+ case "apply": {
48
+ const parts = Array.isArray(p.parts) ? p.parts.filter((x): x is string => typeof x === "string" && PARTS.includes(x)) : [];
49
+ return same(["apply", ...new Set(parts)], true);
50
+ }
51
+ case "pull":
52
+ return { argv: ["pull", "--dry-run"], shown: ["pull", "--dry-run"], writes: false };
53
+ case "pull-write":
54
+ return { argv: ["pull", "--write"], shown: ["pull", "--write"], writes: true };
55
+ case "changesets-pull":
56
+ return same(["changesets", "pull"], true);
57
+ case "changeset": {
58
+ const n = Number(p.number);
59
+ return Number.isInteger(n) && n > 0 && n < 10_000 ? same(["changeset", String(n)]) : null;
60
+ }
61
+ case "feedback-queue":
62
+ return same(["feedback", "queue"]);
63
+ case "feedback-add": {
64
+ const file = typeof p.file === "string" ? p.file.trim() : "";
65
+ const full = path.resolve(root, file);
66
+ // A file of this repository only: nothing outside it, nothing that is not a regular file.
67
+ if (!file || !(full === root || full.startsWith(root + path.sep)) || !existsSync(full) || !statSync(full).isFile() || !/\.(md|json)$/i.test(full)) return null;
68
+ const argv = ["feedback", "add", path.relative(root, full)];
69
+ if (typeof p.kind === "string" && KINDS.includes(p.kind)) argv.push("--kind", p.kind);
70
+ if (typeof p.area === "string" && /^[a-z0-9][a-z0-9 ,_-]{0,60}$/i.test(p.area)) argv.push("--area", p.area);
71
+ return same(argv, true);
72
+ }
73
+ case "feedback-drop": {
74
+ const n = Number(p.number);
75
+ return Number.isInteger(n) && n > 0 && n < 1000 ? { argv: ["feedback", "drop", String(n), "--yes"], shown: ["feedback", "drop", String(n)], writes: true } : null;
76
+ }
77
+ case "feedback-dry-run":
78
+ return same(["feedback", "send", "--dry-run"]);
79
+ case "feedback-send":
80
+ return { argv: ["feedback", "send", "--yes"], shown: ["feedback", "send"], writes: true };
81
+ default:
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export interface UiRequest {
87
+ method: string;
88
+ url: string;
89
+ headers: Record<string, string | undefined>;
90
+ body?: string;
91
+ }
92
+
93
+ export interface UiResponse {
94
+ status: number;
95
+ headers: Record<string, string>;
96
+ body: string;
97
+ }
98
+
99
+ const SAFE_HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", "x-frame-options": "DENY", "cross-origin-resource-policy": "same-origin" };
100
+
101
+ const equal = (a: string, b: string) => {
102
+ const x = Buffer.from(a);
103
+ const y = Buffer.from(b);
104
+ return x.length === y.length && timingSafeEqual(x, y);
105
+ };
106
+
107
+ function cookieOf(header: string | undefined, name: string): string | null {
108
+ for (const part of (header ?? "").split(";")) {
109
+ const [k, ...v] = part.trim().split("=");
110
+ if (k === name) return v.join("=");
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * The page's request handler, without a socket: what the server runs per request, and what the tests call. `runAction`
117
+ * runs one action and answers its exit code and its JSON.
118
+ */
119
+ export function createUiHandler(o: { port: number; token: string; session?: string; command: string; root: string; runAction: (a: UiAction) => Promise<{ exit: number; result: unknown; output: string }> }) {
120
+ const address = `127.0.0.1:${o.port}`;
121
+ const origin = `http://${address}`;
122
+ const cookie = `asturiu_ui_${o.port}`;
123
+ const session = o.session ?? randomBytes(32).toString("base64url");
124
+ let token: string | null = o.token;
125
+ let busy: Promise<unknown> = Promise.resolve();
126
+
127
+ const text = (status: number, body: string, extra: Record<string, string> = {}): UiResponse => ({ status, headers: { ...SAFE_HEADERS, "content-type": "text/plain; charset=utf-8", ...extra }, body });
128
+ const json = (status: number, body: unknown): UiResponse => ({ status, headers: { ...SAFE_HEADERS, "content-type": "application/json; charset=utf-8" }, body: JSON.stringify(body) });
129
+
130
+ async function handle(req: UiRequest): Promise<UiResponse> {
131
+ // The address the page was printed with, and no other: a page another site loads in this browser sends another Host.
132
+ if (req.headers.host !== address) return text(421, "Open the address this command printed.");
133
+ const url = new URL(req.url, origin);
134
+ const signedIn = (() => {
135
+ const c = cookieOf(req.headers.cookie, cookie);
136
+ return c !== null && equal(c, session);
137
+ })();
138
+
139
+ if (req.method === "GET" && url.pathname === "/") {
140
+ const offered = url.searchParams.get("token");
141
+ if (offered !== null) {
142
+ // The one-time token: it becomes the cookie once, and the address loses it.
143
+ if (token === null || !equal(offered, token)) return text(403, "This address has been used already. Run the command again for a new one.");
144
+ token = null;
145
+ return { status: 303, headers: { ...SAFE_HEADERS, location: "/", "set-cookie": `${cookie}=${session}; HttpOnly; SameSite=Strict; Path=/`, "content-type": "text/plain; charset=utf-8" }, body: "" };
146
+ }
147
+ if (!signedIn) return text(403, "Open the address this command printed, with its token.");
148
+ const nonce = randomBytes(16).toString("base64");
149
+ return {
150
+ status: 200,
151
+ headers: {
152
+ ...SAFE_HEADERS,
153
+ "content-type": "text/html; charset=utf-8",
154
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
155
+ },
156
+ body: UI_PAGE.replaceAll("__NONCE__", nonce).replaceAll("__COMMAND__", escapeHtml(o.command)),
157
+ };
158
+ }
159
+
160
+ if (url.pathname === "/api/run") {
161
+ if (req.method !== "POST") return text(405, "POST only.", { allow: "POST" });
162
+ if (!signedIn) return json(403, { error: "not signed in to this page" });
163
+ // A write from another origin never reaches here: the browser sends this page's own Origin, and nothing else is let through.
164
+ if (req.headers.origin !== origin) return json(403, { error: "wrong origin" });
165
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? "")) return json(415, { error: "JSON only" });
166
+ if ((req.body ?? "").length > 16 * 1024) return json(413, { error: "too large" });
167
+ let body: { action?: unknown; params?: unknown };
168
+ try {
169
+ body = JSON.parse(req.body ?? "{}");
170
+ } catch {
171
+ return json(400, { error: "not JSON" });
172
+ }
173
+ const name = typeof body.action === "string" ? body.action : "";
174
+ const params = body.params && typeof body.params === "object" ? (body.params as Record<string, unknown>) : {};
175
+ const action = uiAction(o.root, name, params);
176
+ if (!action) return json(400, { error: `not an action this page runs: ${name.slice(0, 40)}` });
177
+ // One at a time: a command reads and writes the repository's files.
178
+ const done = busy.then(() => o.runAction(action));
179
+ busy = done.catch(() => undefined);
180
+ const r = await done;
181
+ return json(200, { action: name, exit: r.exit, terminal: `${o.command} ${action.shown.map(quote).join(" ")}`, writes: action.writes, result: r.result, ...(r.result === null ? { output: r.output } : {}) });
182
+ }
183
+ return text(404, "Nothing here.");
184
+ }
185
+
186
+ return { handle, address, origin, session };
187
+ }
188
+
189
+ const quote = (s: string) => (/^[A-Za-z0-9_:./=@-]+$/.test(s) ? s : JSON.stringify(s));
190
+
191
+ export function escapeHtml(s: string): string {
192
+ return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
193
+ }
194
+
195
+ /** Runs one action in this process, as the terminal would with `--json`, and answers its exit code and JSON. */
196
+ export function actionRunner(ctx: Ctx) {
197
+ return async (a: UiAction) => {
198
+ let output = "";
199
+ const sink = { write: (s: string) => ((output += s), true), isTTY: false };
200
+ const exit = await run([...a.argv, "--json"], { root: ctx.root, env: ctx.env, config: ctx.config, fetch: ctx.fetch, invokedAs: ctx.cmd, io: { stdout: sink, stderr: { write: () => true, isTTY: false }, stdin: { isTTY: false } } });
201
+ let result: unknown = null;
202
+ try {
203
+ result = JSON.parse(output);
204
+ } catch {
205
+ result = null;
206
+ }
207
+ return { exit, result, output: output.slice(0, 64 * 1024) };
208
+ };
209
+ }
210
+
211
+ function toUiRequest(req: IncomingMessage, body: string): UiRequest {
212
+ const headers: Record<string, string | undefined> = {};
213
+ for (const [k, v] of Object.entries(req.headers)) headers[k] = Array.isArray(v) ? v.join(", ") : v;
214
+ return { method: req.method ?? "GET", url: req.url ?? "/", headers, body };
215
+ }
216
+
217
+ export async function cmdUi(ctx: Ctx) {
218
+ const { t } = ctx;
219
+ const wanted = Number(str(ctx.flags.port) ?? 0);
220
+ if (!Number.isInteger(wanted) || wanted < 0 || wanted > 65535) throw new Fail("--port takes a port number, or leave it out for a free one.", { code: EXIT.misunderstood });
221
+ const token = randomBytes(24).toString("base64url");
222
+ let handler: ReturnType<typeof createUiHandler> | null = null;
223
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
224
+ const chunks: Buffer[] = [];
225
+ let size = 0;
226
+ req.on("data", (c: Buffer) => {
227
+ size += c.length;
228
+ if (size <= 64 * 1024) chunks.push(c);
229
+ });
230
+ req.on("end", () => {
231
+ if (!handler) return res.writeHead(503).end();
232
+ handler
233
+ .handle(toUiRequest(req, Buffer.concat(chunks).toString("utf8")))
234
+ .then((r) => res.writeHead(r.status, r.headers).end(r.body))
235
+ .catch(() => res.writeHead(500, { "content-type": "text/plain" }).end("The command failed."));
236
+ });
237
+ });
238
+ await new Promise<void>((resolve, reject) => {
239
+ server.once("error", (err: NodeJS.ErrnoException) => reject(new Fail(`The page could not start: ${err.code === "EADDRINUSE" ? `port ${wanted} is taken` : err.message}.`, { hint: err.code === "EPERM" ? "this sandbox forbids a local port: run it in your own terminal" : undefined })));
240
+ server.listen(wanted, "127.0.0.1", () => resolve());
241
+ });
242
+ const port = (server.address() as { port: number }).port;
243
+ handler = createUiHandler({ port, token, command: ctx.cmd, root: ctx.root, runAction: actionRunner(ctx) });
244
+ const url = `${handler.origin}/?token=${token}`;
245
+ if (ctx.json) emit(ctx, { url, note: "The token works once. The page stops with this process." });
246
+ else {
247
+ t.banner("Local page", [`127.0.0.1:${port}`, "this machine only"]);
248
+ t.out(` ${t.paint.bold("Open")} ${t.link(url, url)}`);
249
+ t.out();
250
+ t.out(` ${t.paint.gray("The address works once: open it in your browser now. The key stays in this process. Ctrl+C stops the page.")}`);
251
+ t.out();
252
+ }
253
+ if (ctx.flags.open) {
254
+ const opener = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
255
+ try {
256
+ spawn(opener[0] as string, opener[1] as string[], { detached: true, stdio: "ignore" }).on("error", () => undefined).unref();
257
+ } catch {
258
+ // Opening a browser is a courtesy; the address is printed either way.
259
+ }
260
+ }
261
+ await new Promise<void>((resolve) => {
262
+ const stop = () => server.close(() => resolve());
263
+ process.once("SIGINT", stop);
264
+ process.once("SIGTERM", stop);
265
+ });
266
+ }
package/src/client.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { SESSION_HEADER } from "./session.js";
2
+ import type { Page, Problem } from "./types.js";
3
+
4
+ /**
5
+ * The Interface API as a Frontend consumes it (components/05-interface-api.md
6
+ * §Conventions): `X-Asturiu-Session` on every call except session exchange,
7
+ * `Idempotency-Key` on writes, `If-Match` for concurrency, cursors for
8
+ * collections, `application/problem+json` for errors — and a `404` that never
9
+ * distinguishes absent from forbidden.
10
+ *
11
+ * Runs wherever `fetch` exists: a Next.js server, an edge runtime, a browser
12
+ * talking to the Web UI's own `/api/v0` proxy.
13
+ */
14
+
15
+ export class InterfaceApiError extends Error {
16
+ constructor(
17
+ readonly status: number,
18
+ readonly problem: Problem,
19
+ ) {
20
+ super(problem.detail ?? problem.title ?? `HTTP ${status}`);
21
+ this.name = "InterfaceApiError";
22
+ }
23
+
24
+ get type(): string {
25
+ return this.problem.type ?? "";
26
+ }
27
+ get isUnauthorized(): boolean {
28
+ return this.status === 401;
29
+ }
30
+ get isNotFound(): boolean {
31
+ return this.status === 404;
32
+ }
33
+ get isPaused(): boolean {
34
+ return this.status === 503 && this.type.endsWith("platform-paused");
35
+ }
36
+ get isSuspended(): boolean {
37
+ return this.status === 403 && this.type.endsWith("account-suspended");
38
+ }
39
+ /** `POST /v0/sessions` for someone with no Customer yet, while the Tenant takes no new ones (ADR-044). */
40
+ get isEnrolmentClosed(): boolean {
41
+ return this.status === 403 && this.type.endsWith("enrolment-closed");
42
+ }
43
+ }
44
+
45
+ export interface CallOptions {
46
+ body?: unknown;
47
+ /** Raw payload (Attachment upload) — sent as-is with `contentType`. */
48
+ raw?: ArrayBuffer | Uint8Array | Blob;
49
+ contentType?: string;
50
+ headers?: Record<string, string | undefined>;
51
+ query?: Record<string, string | number | boolean | undefined | null>;
52
+ /** Default `true`; `false` for `POST /v0/sessions`, `/v0/status`, `/v0/health`. */
53
+ session?: boolean;
54
+ /** Default: a fresh key per `POST`/`PUT`. Pass a stable key to make a retry a replay. */
55
+ idempotencyKey?: string | false;
56
+ ifMatch?: number | string;
57
+ signal?: AbortSignal;
58
+ }
59
+
60
+ export interface Response<T> {
61
+ status: number;
62
+ body: T;
63
+ headers: Headers;
64
+ }
65
+
66
+ export interface InterfaceApiClientOptions {
67
+ /** `http://interface-api:8081` server-side, or the Web UI's own origin for the browser. */
68
+ baseUrl: string;
69
+ /** The opaque session identifier, if this client acts for a signed-in Customer. */
70
+ session?: string | null;
71
+ /** Path prefix: `/v0` against the Interface API, `/api/v0` against the Web UI proxy. */
72
+ prefix?: string;
73
+ /**
74
+ * The transport carries the session (the Web UI's `HttpOnly` cookie on its
75
+ * own `/api/v0` proxy): send no header and require no identifier. The
76
+ * browser never holds the session identifier itself.
77
+ */
78
+ ambientSession?: boolean;
79
+ fetch?: typeof fetch;
80
+ timeoutMs?: number;
81
+ }
82
+
83
+ /** The ambient `fetch`, invoked on the global so it is valid as a method of any object. */
84
+ const globalFetch: typeof fetch = (input, init) => globalThis.fetch(input, init);
85
+
86
+ function safeJson(text: string): unknown {
87
+ if (!text) return null;
88
+ try {
89
+ return JSON.parse(text);
90
+ } catch {
91
+ return { raw: text };
92
+ }
93
+ }
94
+
95
+ export class InterfaceApiClient {
96
+ readonly baseUrl: string;
97
+ readonly prefix: string;
98
+ readonly session: string | null;
99
+ readonly ambientSession: boolean;
100
+ private readonly f: typeof fetch;
101
+ private readonly timeoutMs: number;
102
+
103
+ constructor(opts: InterfaceApiClientOptions) {
104
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
105
+ this.prefix = opts.prefix ?? "/v0";
106
+ this.session = opts.session ?? null;
107
+ this.ambientSession = opts.ambientSession ?? false;
108
+ // Never store the bare global `fetch` and call it as `this.f(...)`: browsers
109
+ // then see `this === InterfaceApiClient` and throw
110
+ // "Failed to execute 'fetch' on 'Window': Illegal invocation" (Node is
111
+ // lenient, so only the Web UI breaks). Always call through `globalFetch`.
112
+ this.f = opts.fetch ?? globalFetch;
113
+ this.timeoutMs = opts.timeoutMs ?? 30000;
114
+ }
115
+
116
+ /** The same client acting for another (or no) session. */
117
+ withSession(session: string | null): InterfaceApiClient {
118
+ return new InterfaceApiClient({ baseUrl: this.baseUrl, prefix: this.prefix, session, ambientSession: this.ambientSession, fetch: this.f, timeoutMs: this.timeoutMs });
119
+ }
120
+
121
+ url(path: string, query?: CallOptions["query"]): string {
122
+ const u = new URL(`${this.baseUrl}${this.prefix}${path.startsWith("/") ? path : `/${path}`}`);
123
+ for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v));
124
+ return u.toString();
125
+ }
126
+
127
+ async call<T = unknown>(method: string, path: string, opts: CallOptions = {}): Promise<Response<T>> {
128
+ const headers: Record<string, string> = { accept: "application/json, application/problem+json, */*" };
129
+ for (const [k, v] of Object.entries(opts.headers ?? {})) if (v !== undefined) headers[k.toLowerCase()] = v;
130
+ if (opts.session !== false && !this.ambientSession) {
131
+ if (!this.session) throw new InterfaceApiError(401, { type: "urn:asturiu:problem:unauthorized", title: "Unauthorized", status: 401, detail: "no session" });
132
+ headers[SESSION_HEADER] = this.session;
133
+ }
134
+ let body: RequestInit["body"] | undefined;
135
+ if (opts.raw !== undefined) {
136
+ body = opts.raw as RequestInit["body"];
137
+ headers["content-type"] = opts.contentType ?? "application/octet-stream";
138
+ } else if (opts.body !== undefined) {
139
+ headers["content-type"] = "application/json";
140
+ body = JSON.stringify(opts.body);
141
+ }
142
+ if (["POST", "PUT"].includes(method) && opts.idempotencyKey !== false && !headers["idempotency-key"]) headers["idempotency-key"] = opts.idempotencyKey ?? crypto.randomUUID();
143
+ if (opts.ifMatch !== undefined) headers["if-match"] = String(opts.ifMatch);
144
+ const res = await this.f(this.url(path, opts.query), { method, headers, body, signal: opts.signal ?? AbortSignal.timeout(this.timeoutMs), cache: "no-store" } as RequestInit);
145
+ const text = await res.text();
146
+ const json = safeJson(text);
147
+ if (!res.ok) {
148
+ const problem: Problem = json && typeof json === "object" && "status" in (json as object) ? (json as Problem) : { type: "urn:asturiu:problem:http", title: res.statusText || "Error", status: res.status, detail: typeof json === "object" && json && "raw" in json ? String((json as { raw: string }).raw).slice(0, 300) : undefined };
149
+ throw new InterfaceApiError(res.status, problem);
150
+ }
151
+ return { status: res.status, body: json as T, headers: res.headers };
152
+ }
153
+
154
+ /** Binary download (Attachment content). */
155
+ async download(path: string, opts: Omit<CallOptions, "body" | "raw"> = {}): Promise<{ status: number; bytes: ArrayBuffer; contentType: string; filename?: string }> {
156
+ const headers: Record<string, string> = {};
157
+ if (opts.session !== false && !this.ambientSession) {
158
+ if (!this.session) throw new InterfaceApiError(401, { type: "urn:asturiu:problem:unauthorized", title: "Unauthorized", status: 401 });
159
+ headers[SESSION_HEADER] = this.session;
160
+ }
161
+ const res = await this.f(this.url(path, opts.query), { method: "GET", headers, signal: opts.signal ?? AbortSignal.timeout(this.timeoutMs), cache: "no-store" } as RequestInit);
162
+ if (!res.ok) throw new InterfaceApiError(res.status, (safeJson(await res.text()) as Problem) ?? { type: "urn:asturiu:problem:http", title: "Error", status: res.status });
163
+ const cd = res.headers.get("content-disposition") ?? "";
164
+ const m = /filename="?([^";]+)"?/.exec(cd);
165
+ return { status: res.status, bytes: await res.arrayBuffer(), contentType: res.headers.get("content-type") ?? "application/octet-stream", filename: m?.[1] };
166
+ }
167
+
168
+ get<T = unknown>(path: string, query?: CallOptions["query"], opts: CallOptions = {}): Promise<T> {
169
+ return this.call<T>("GET", path, { ...opts, query }).then((r) => r.body);
170
+ }
171
+ post<T = unknown>(path: string, body?: unknown, opts: CallOptions = {}): Promise<T> {
172
+ return this.call<T>("POST", path, { ...opts, body }).then((r) => r.body);
173
+ }
174
+ put<T = unknown>(path: string, body?: unknown, opts: CallOptions = {}): Promise<T> {
175
+ return this.call<T>("PUT", path, { ...opts, body }).then((r) => r.body);
176
+ }
177
+ patch<T = unknown>(path: string, body?: unknown, opts: CallOptions = {}): Promise<T> {
178
+ return this.call<T>("PATCH", path, { ...opts, body }).then((r) => r.body);
179
+ }
180
+ delete<T = unknown>(path: string, opts: CallOptions = {}): Promise<T> {
181
+ return this.call<T>("DELETE", path, opts).then((r) => r.body);
182
+ }
183
+
184
+ /** Walk every page of a cursor-paginated collection. */
185
+ async all<T>(path: string, query: CallOptions["query"] = {}, limit = 100): Promise<T[]> {
186
+ const out: T[] = [];
187
+ let cursor: string | null = null;
188
+ do {
189
+ const page: Page<T> = await this.get<Page<T>>(path, { ...query, limit, cursor: cursor ?? undefined });
190
+ out.push(...page.items);
191
+ cursor = page.next_cursor;
192
+ } while (cursor);
193
+ return out;
194
+ }
195
+ }
196
+
197
+ /** Every caller-facing error becomes a problem shape, so UIs render one thing. */
198
+ export function toProblem(err: unknown): Problem {
199
+ if (err instanceof InterfaceApiError) return err.problem;
200
+ if (err instanceof Error) return { type: "urn:asturiu:problem:client", title: err.name, status: 0, detail: err.message };
201
+ return { type: "urn:asturiu:problem:client", title: "Error", status: 0, detail: String(err) };
202
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Contacts, handles and disclosure (ADR-024). Pure vocabulary shared by the
3
+ * Engine, the CLI and every frontend: the pair-state function is the one
4
+ * place the two rows of a Contact are read into one answer, so it must be the
5
+ * same everywhere. Dependency-free on purpose.
6
+ */
7
+
8
+ /** A holder's own decision about one Contact, stored on its row. */
9
+ export const CONTACT_INTENTS = ["accepted", "incoming", "blocked"] as const;
10
+ export type ContactIntent = (typeof CONTACT_INTENTS)[number];
11
+
12
+ /** What one side sees, derived from both rows and never stored. */
13
+ export const CONTACT_STATES = ["active", "pending", "incoming", "blocked", "removed"] as const;
14
+ export type ContactState = (typeof CONTACT_STATES)[number];
15
+
16
+ /** How the Contact came to exist. */
17
+ export const CONTACT_ORIGINS = ["request", "grant", "transfer", "recovery"] as const;
18
+ export type ContactOrigin = (typeof CONTACT_ORIGINS)[number];
19
+
20
+ /** The profile fields a holder may disclose to one Contact. The handle is always visible to an active Contact and is not on this list. */
21
+ export const DISCLOSABLE_FIELDS = ["display_name", "name", "email", "phone"] as const;
22
+ export type DisclosableField = (typeof DISCLOSABLE_FIELDS)[number];
23
+
24
+ /**
25
+ * The counterpart row as the holder's side sees it: an intent when the row
26
+ * is readable, `missing` when no counterpart row was ever linked (an external
27
+ * Principal not redeemed yet), `removed` when the linked row is gone (the
28
+ * counterpart deleted the Contact, or the Customer was deleted).
29
+ */
30
+ export type CounterpartIntent = ContactIntent | "missing" | "removed";
31
+
32
+ /**
33
+ * The state one holder sees. Order matters:
34
+ *
35
+ * - my own block is always shown to me as `blocked`;
36
+ * - a counterpart that is gone is `removed`, whatever I had decided;
37
+ * - a request I have not answered is `incoming`;
38
+ * - both accepted is `active`;
39
+ * - everything else — the counterpart has not answered, has declined
40
+ * silently, or has blocked me — reads `pending`. A block is never visible
41
+ * to the blocked side.
42
+ */
43
+ export function contactState(mine: ContactIntent, theirs: CounterpartIntent): ContactState {
44
+ if (mine === "blocked") return "blocked";
45
+ if (theirs === "removed") return "removed";
46
+ if (mine === "incoming") return "incoming";
47
+ if (theirs === "accepted") return "active";
48
+ return "pending";
49
+ }
50
+
51
+ /** Platform-wide handle (ADR-024): lowercase, 3–30 characters, starts with a letter or digit. */
52
+ export const HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]{2,29}$/;
53
+
54
+ export function isHandle(value: unknown): value is string {
55
+ return typeof value === "string" && HANDLE_PATTERN.test(value);
56
+ }
57
+
58
+ /** Normalise what a person typed as a handle: trim, lower-case, strip a leading `@`. Validation is `isHandle`. */
59
+ export function normalizeHandle(value: string): string {
60
+ return value.trim().replace(/^@/, "").toLowerCase();
61
+ }