@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.
- package/CHANGELOG.md +42 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +82 -2
- package/contract/assertion-vectors.json +90 -0
- package/contract/schemas/branding.schema.json +46 -0
- package/contract/schemas/definitions.schema.json +376 -0
- package/contract/schemas/portal.schema.json +56 -0
- package/contract/tenant-api-routes.json +165 -0
- package/dist/branding.d.ts +131 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +206 -0
- package/dist/branding.js.map +1 -0
- package/dist/changesets.d.ts +58 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +18 -0
- package/dist/changesets.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/changesets.d.ts +14 -0
- package/dist/cli/changesets.d.ts.map +1 -0
- package/dist/cli/changesets.js +252 -0
- package/dist/cli/changesets.js.map +1 -0
- package/dist/cli/cli.d.ts +37 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +415 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/context.d.ts +147 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +258 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/engine.d.ts +59 -0
- package/dist/cli/engine.d.ts.map +1 -0
- package/dist/cli/engine.js +85 -0
- package/dist/cli/engine.js.map +1 -0
- package/dist/cli/feedback.d.ts +40 -0
- package/dist/cli/feedback.d.ts.map +1 -0
- package/dist/cli/feedback.js +420 -0
- package/dist/cli/feedback.js.map +1 -0
- package/dist/cli/files.d.ts +83 -0
- package/dist/cli/files.d.ts.map +1 -0
- package/dist/cli/files.js +389 -0
- package/dist/cli/files.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/overview.d.ts +6 -0
- package/dist/cli/overview.d.ts.map +1 -0
- package/dist/cli/overview.js +379 -0
- package/dist/cli/overview.js.map +1 -0
- package/dist/cli/parts.d.ts +5 -0
- package/dist/cli/parts.d.ts.map +1 -0
- package/dist/cli/parts.js +414 -0
- package/dist/cli/parts.js.map +1 -0
- package/dist/cli/sources.d.ts +37 -0
- package/dist/cli/sources.d.ts.map +1 -0
- package/dist/cli/sources.js +91 -0
- package/dist/cli/sources.js.map +1 -0
- package/dist/cli/terminal.d.ts +135 -0
- package/dist/cli/terminal.d.ts.map +1 -0
- package/dist/cli/terminal.js +353 -0
- package/dist/cli/terminal.js.map +1 -0
- package/dist/cli/ui-page.d.ts +8 -0
- package/dist/cli/ui-page.d.ts.map +1 -0
- package/dist/cli/ui-page.js +216 -0
- package/dist/cli/ui-page.js.map +1 -0
- package/dist/cli/ui.d.ts +54 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +251 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +165 -0
- package/dist/client.js.map +1 -0
- package/dist/contacts.d.ts +43 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +46 -0
- package/dist/contacts.js.map +1 -0
- package/dist/definition-set.d.ts +731 -0
- package/dist/definition-set.d.ts.map +1 -0
- package/dist/definition-set.js +659 -0
- package/dist/definition-set.js.map +1 -0
- package/dist/definitions.d.ts +42 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +154 -0
- package/dist/definitions.js.map +1 -0
- package/dist/enrolment.d.ts +9 -0
- package/dist/enrolment.d.ts.map +1 -0
- package/dist/enrolment.js +8 -0
- package/dist/enrolment.js.map +1 -0
- package/dist/fill.d.ts +73 -0
- package/dist/fill.d.ts.map +1 -0
- package/dist/fill.js +46 -0
- package/dist/fill.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +33 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +36 -0
- package/dist/notifications.js.map +1 -0
- package/dist/oidc.d.ts +122 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +150 -0
- package/dist/oidc.js.map +1 -0
- package/dist/overlay.d.ts +109 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +58 -0
- package/dist/overlay.js.map +1 -0
- package/dist/passes.d.ts +95 -0
- package/dist/passes.d.ts.map +1 -0
- package/dist/passes.js +140 -0
- package/dist/passes.js.map +1 -0
- package/dist/portal-options.d.ts +65 -0
- package/dist/portal-options.d.ts.map +1 -0
- package/dist/portal-options.js +120 -0
- package/dist/portal-options.js.map +1 -0
- package/dist/reads.d.ts +41 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +44 -0
- package/dist/reads.js.map +1 -0
- package/dist/recovery-words.d.ts +8 -0
- package/dist/recovery-words.d.ts.map +1 -0
- package/dist/recovery-words.js +136 -0
- package/dist/recovery-words.js.map +1 -0
- package/dist/recovery.d.ts +198 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +112 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reveal.d.ts +35 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +66 -0
- package/dist/reveal.js.map +1 -0
- package/dist/seal.d.ts +15 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +0 -0
- package/dist/seal.js.map +1 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +61 -0
- package/dist/session.js.map +1 -0
- package/dist/signing-in-mark.d.ts +14 -0
- package/dist/signing-in-mark.d.ts.map +1 -0
- package/dist/signing-in-mark.js +24 -0
- package/dist/signing-in-mark.js.map +1 -0
- package/dist/signing-in.d.ts +22 -0
- package/dist/signing-in.d.ts.map +1 -0
- package/dist/signing-in.js +203 -0
- package/dist/signing-in.js.map +1 -0
- package/dist/tags.d.ts +96 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +88 -0
- package/dist/tags.js.map +1 -0
- package/dist/tenant-subscription.d.ts +47 -0
- package/dist/tenant-subscription.d.ts.map +1 -0
- package/dist/tenant-subscription.js +7 -0
- package/dist/tenant-subscription.js.map +1 -0
- package/dist/testing/index.d.ts +125 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +320 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/theme.d.ts +237 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +504 -0
- package/dist/theme.js.map +1 -0
- package/dist/tickets.d.ts +152 -0
- package/dist/tickets.d.ts.map +1 -0
- package/dist/tickets.js +65 -0
- package/dist/tickets.js.map +1 -0
- package/dist/tiers.d.ts +168 -0
- package/dist/tiers.d.ts.map +1 -0
- package/dist/tiers.js +341 -0
- package/dist/tiers.js.map +1 -0
- package/dist/types.d.ts +885 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/urlid.d.ts +50 -0
- package/dist/urlid.d.ts.map +1 -0
- package/dist/urlid.js +78 -0
- package/dist/urlid.js.map +1 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/make/asturiu.mk +43 -0
- package/package.json +63 -4
- package/src/branding.ts +221 -0
- package/src/changesets.ts +61 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/changesets.ts +233 -0
- package/src/cli/cli.ts +425 -0
- package/src/cli/context.ts +299 -0
- package/src/cli/engine.ts +112 -0
- package/src/cli/feedback.ts +406 -0
- package/src/cli/files.ts +371 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/overview.ts +343 -0
- package/src/cli/parts.ts +371 -0
- package/src/cli/sources.ts +111 -0
- package/src/cli/terminal.ts +384 -0
- package/src/cli/ui-page.ts +215 -0
- package/src/cli/ui.ts +266 -0
- package/src/client.ts +202 -0
- package/src/contacts.ts +61 -0
- package/src/definition-set.ts +965 -0
- package/src/definitions.ts +156 -0
- package/src/enrolment.ts +10 -0
- package/src/fill.ts +83 -0
- package/src/index.ts +38 -0
- package/src/notifications.ts +55 -0
- package/src/oidc.ts +199 -0
- package/src/overlay.ts +137 -0
- package/src/passes.ts +168 -0
- package/src/portal-options.ts +125 -0
- package/src/reads.ts +71 -0
- package/src/recovery-words.ts +137 -0
- package/src/recovery.ts +269 -0
- package/src/reveal.ts +72 -0
- package/src/seal.ts +0 -0
- package/src/session.ts +93 -0
- package/src/signing-in-mark.ts +23 -0
- package/src/signing-in.ts +213 -0
- package/src/tags.ts +146 -0
- package/src/tenant-subscription.ts +44 -0
- package/src/testing/index.ts +337 -0
- package/src/theme.ts +628 -0
- package/src/tickets.ts +195 -0
- package/src/tiers.ts +397 -0
- package/src/types.ts +847 -0
- package/src/urlid.ts +77 -0
- package/src/version.ts +15 -0
- package/templates/asturiu/branding.json +4 -0
- package/templates/asturiu/definitions.json +23 -0
- package/templates/asturiu/portal.json +16 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@asturiu/v0-sdk/testing` (ADR-074 §5): an in-memory stand-in for the Tenant API, so a Tenant's CI can run its
|
|
3
|
+
* start-up code and `asturiu-v0-tenant` without a key the Engine knows, a network or an Engine. Node only.
|
|
4
|
+
*
|
|
5
|
+
* It answers every route of `TENANT_API_ROUTES` the way the Tenant API does: a signed assertion on every call (checked
|
|
6
|
+
* against the stand-in's key when one is given), a `404` for a route the key's scopes do not reach, problem documents
|
|
7
|
+
* for refusals. It holds its state in memory and records each call. It is a stand-in, not the Engine: it plans no
|
|
8
|
+
* Definition change and checks no Definition rule.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, createPrivateKey, createPublicKey, verify } from "node:crypto";
|
|
11
|
+
import type { RepositoryBranding, RepositoryPortal, TenantSource } from "../branding.js";
|
|
12
|
+
import type { ChangesetStatus, FeedbackReport, TenantChangeset } from "../changesets.js";
|
|
13
|
+
import { type DefinitionStatus, type DefinitionSubmitResult, type EngineInfo, TENANT_API_ROUTES, TENANT_ASSERTION_AUDIENCE, TENANT_ASSERTION_MAX_SECONDS, type TenantKeyRecord, type TenantKeyScope, TENANT_KEY_SCOPES, definitionSetHash, formatTenantKey, fromB64url, generateTenantKeyPair, keyReachesRoute, matchTenantApiRoute, parseTenantKey } from "../definition-set.js";
|
|
14
|
+
import type { PortalPage, PortalSwitch } from "../portal-options.js";
|
|
15
|
+
import type { TagView } from "../tags.js";
|
|
16
|
+
import type { AnnouncementItem, AnyStatus, PostView, TicketView } from "../tickets.js";
|
|
17
|
+
import { SDK_VERSION } from "../version.js";
|
|
18
|
+
|
|
19
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
20
|
+
|
|
21
|
+
export interface StandInOptions {
|
|
22
|
+
/** The Tenant key whose assertions it takes; without one, any well-formed assertion passes. */
|
|
23
|
+
key?: string;
|
|
24
|
+
/** What the key reaches (default: every scope). */
|
|
25
|
+
scopes?: TenantKeyScope[];
|
|
26
|
+
/** Routes it answers with 404, as an older Engine does: `"GET /tenant/v0/key"`. */
|
|
27
|
+
missing?: string[];
|
|
28
|
+
/** Answer every call with this status (`401`: the key was refused). */
|
|
29
|
+
refuse?: number;
|
|
30
|
+
/** Throw as `fetch` does when nothing answers (`ECONNREFUSED`), or another network code. */
|
|
31
|
+
down?: boolean | string;
|
|
32
|
+
engine?: Partial<EngineInfo>;
|
|
33
|
+
definitions?: Partial<DefinitionStatus> & {
|
|
34
|
+
/** How it answers a submitted set other than the live one: applied at once (default) or waiting for approval. */
|
|
35
|
+
submit?: "apply" | "wait" | ((set: Record<string, unknown>, hash: string) => DefinitionSubmitResult);
|
|
36
|
+
};
|
|
37
|
+
branding?: { source?: TenantSource; branding?: RepositoryBranding };
|
|
38
|
+
portal?: { source?: TenantSource; portal?: Record<string, unknown>; redirected?: PortalPage[] };
|
|
39
|
+
changesets?: TenantChangeset[];
|
|
40
|
+
/** Documents of the Engine site, by their whole address: what `changesets pull` fetches. */
|
|
41
|
+
documents?: Record<string, string>;
|
|
42
|
+
feedback?: TicketView[];
|
|
43
|
+
tickets?: TicketView[];
|
|
44
|
+
announcements?: { items?: TicketView[]; platform?: AnnouncementItem[] };
|
|
45
|
+
tags?: TagView[];
|
|
46
|
+
keyRecord?: Partial<TenantKeyRecord>;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StandInCall {
|
|
51
|
+
method: string;
|
|
52
|
+
path: string;
|
|
53
|
+
query: string;
|
|
54
|
+
body: unknown;
|
|
55
|
+
status: number;
|
|
56
|
+
/** The SDK version the caller named in its User-Agent, as the Tenant API forwards it. */
|
|
57
|
+
sdk_version: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ALL_PAGES: PortalPage[] = ["profile", "security", "plan", "privacy", "recovery", "support"];
|
|
61
|
+
const ALL_SWITCHES: PortalSwitch[] = ["account_link", "open_app", "back_link", "language_menu", "appearance"];
|
|
62
|
+
|
|
63
|
+
/** A Tenant key for tests: made here, registered with no Engine, so a real Tenant API refuses it. */
|
|
64
|
+
export async function testTenantKey(slug = "acme", kid = "0123456789abcdef"): Promise<string> {
|
|
65
|
+
return formatTenantKey({ slug, kid, pkcs8: (await generateTenantKeyPair()).privatePkcs8 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const problem = (status: number, type: string, detail: string) => new Response(JSON.stringify({ type, title: type.replace(/-/g, " "), status, detail }), { status, headers: { "content-type": "application/problem+json" } });
|
|
69
|
+
const ok = (body: unknown, status = 200, headers: Record<string, string> = {}) => new Response(body === undefined ? null : JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
70
|
+
const b64 = (s: string) => JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as Record<string, any>;
|
|
71
|
+
|
|
72
|
+
function post(at: string, body: string, mine: boolean, label: string): PostView {
|
|
73
|
+
return { id: `pst-${createHash("sha256").update(at + body).digest("hex").slice(0, 12)}`, at, side: mine ? "requester" : "handling", visibility: "all", author: { label, mine }, body, refs: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ticket(o: { arid: string; subject: string; body: string; status?: AnyStatus; primary?: TicketView["primary"]; direction?: TicketView["direction"]; at: string; with_label?: string }): TicketView {
|
|
77
|
+
return {
|
|
78
|
+
arid: o.arid,
|
|
79
|
+
scope: "tenant",
|
|
80
|
+
direction: o.direction ?? "tenant_platform",
|
|
81
|
+
primary: o.primary ?? "request",
|
|
82
|
+
secondary: null,
|
|
83
|
+
type_label: o.primary === "announcement" ? "Announcement" : "Request",
|
|
84
|
+
subject: o.subject,
|
|
85
|
+
status: o.status ?? "new",
|
|
86
|
+
priority: "normal",
|
|
87
|
+
archived: false,
|
|
88
|
+
created_at: o.at,
|
|
89
|
+
updated_at: o.at,
|
|
90
|
+
side: "requester",
|
|
91
|
+
unread: false,
|
|
92
|
+
with_label: o.with_label ?? "Asturiu",
|
|
93
|
+
parties: [],
|
|
94
|
+
posts: o.body ? [post(o.at, o.body, true, "Your servers")] : [],
|
|
95
|
+
links: [],
|
|
96
|
+
refs: [],
|
|
97
|
+
audience: null,
|
|
98
|
+
hold_until: null,
|
|
99
|
+
closed_at: null,
|
|
100
|
+
can: { post: true, note: false, triage: false, statuses: ["canceled", "resolved"], reopen: false },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The in-memory Tenant API. `fetch` is what the SDK's calls and `asturiu-v0-tenant` take as their `fetch`. */
|
|
105
|
+
export function createTenantApiStandIn(o: StandInOptions = {}) {
|
|
106
|
+
const now = o.now ?? (() => Date.now());
|
|
107
|
+
const iso = () => new Date(now()).toISOString();
|
|
108
|
+
const scopes = o.scopes ?? [...TENANT_KEY_SCOPES];
|
|
109
|
+
let counter = 0;
|
|
110
|
+
const id = (type: string) => `ARID:0:global:${type}:01STANDIN${String(++counter).padStart(17, "0")}`;
|
|
111
|
+
const seenJti = new Set<string>();
|
|
112
|
+
const publicKey = o.key ? createPublicKey(createPrivateKey({ key: Buffer.from(fromB64url(parseTenantKey(o.key).pkcs8)), format: "der", type: "pkcs8" })) : null;
|
|
113
|
+
|
|
114
|
+
const state = {
|
|
115
|
+
engine: { version: "0.1.0", released_at: "2026-11-09", latest_changeset: 0, site: { docs: "https://engine.example/docs/tenants", changesets: "https://engine.example/changesets" }, sdk: { built_with: SDK_VERSION, minimum: SDK_VERSION }, ...o.engine } as EngineInfo,
|
|
116
|
+
definitions: { revision: 1, hash: null, source: "repository", auto_apply: "safe", waiting: [], ...o.definitions } as DefinitionStatus,
|
|
117
|
+
branding: { source: o.branding?.source ?? "repository", branding: o.branding?.branding ?? {} },
|
|
118
|
+
portal: { source: o.portal?.source ?? "repository", portal: { ...Object.fromEntries(ALL_SWITCHES.map((s) => [s, true])), pages: Object.fromEntries(ALL_PAGES.map((p) => [p, { mode: "portal" }])), ...o.portal?.portal } as Record<string, unknown>, redirected: o.portal?.redirected ?? [] },
|
|
119
|
+
changesets: (o.changesets ?? []).map((c) => ({ ...c })),
|
|
120
|
+
feedback: (o.feedback ?? []).map((t) => structuredClone(t)),
|
|
121
|
+
tickets: (o.tickets ?? []).map((t) => structuredClone(t)),
|
|
122
|
+
announcements: (o.announcements?.items ?? []).map((t) => structuredClone(t)),
|
|
123
|
+
platform: o.announcements?.platform ?? [],
|
|
124
|
+
tags: (o.tags ?? []).map((t) => ({ ...t })),
|
|
125
|
+
customerTags: new Map<string, Set<string>>(),
|
|
126
|
+
tiers: { source: "repository" as TenantSource, tiers: {}, default_tier: null as string | null },
|
|
127
|
+
reports: [] as FeedbackReport[],
|
|
128
|
+
};
|
|
129
|
+
const calls: StandInCall[] = [];
|
|
130
|
+
const submit = o.definitions?.submit ?? "apply";
|
|
131
|
+
|
|
132
|
+
/** The assertion, as the Tenant API and the Operations API check it; null when it passes. */
|
|
133
|
+
function refuseAssertion(headers: Headers, method: string, path: string, body: string): Response | null {
|
|
134
|
+
const m = /^Asturiu-Assertion ([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(headers.get("authorization") ?? "");
|
|
135
|
+
if (!m) return problem(401, "unauthorized", "a signed assertion is required");
|
|
136
|
+
let header: Record<string, any>;
|
|
137
|
+
let claims: Record<string, any>;
|
|
138
|
+
try {
|
|
139
|
+
header = b64(m[1]);
|
|
140
|
+
claims = b64(m[2]);
|
|
141
|
+
} catch {
|
|
142
|
+
return problem(401, "unauthorized", "a signed assertion is required");
|
|
143
|
+
}
|
|
144
|
+
if (header.alg !== "EdDSA" || typeof header.kid !== "string") return problem(401, "unauthorized", "invalid assertion");
|
|
145
|
+
if (o.key) {
|
|
146
|
+
const k = parseTenantKey(o.key);
|
|
147
|
+
// An unknown key or Tenant is nothing to the Engine: 404, as the Operations API answers.
|
|
148
|
+
if (header.kid !== k.kid || claims.iss !== k.slug) return problem(404, "not-found", "Not Found");
|
|
149
|
+
if (!verify(null, Buffer.from(`${m[1]}.${m[2]}`), publicKey!, Buffer.from(m[3], "base64url"))) return problem(401, "unauthorized", "invalid signature");
|
|
150
|
+
}
|
|
151
|
+
const t = Math.floor(now() / 1000);
|
|
152
|
+
if (claims.aud !== TENANT_ASSERTION_AUDIENCE) return problem(401, "unauthorized", "wrong audience");
|
|
153
|
+
if (claims.htm !== method || claims.htu !== path) return problem(401, "unauthorized", "the assertion names another request");
|
|
154
|
+
if (typeof claims.exp !== "number" || typeof claims.iat !== "number" || claims.exp < t || claims.exp - claims.iat > TENANT_ASSERTION_MAX_SECONDS) return problem(401, "unauthorized", "assertion expired");
|
|
155
|
+
if (claims.bdh !== createHash("sha256").update(body).digest("base64url")) return problem(401, "unauthorized", "the body is not the one signed");
|
|
156
|
+
if (seenJti.has(claims.jti)) return problem(401, "unauthorized", "assertion already used");
|
|
157
|
+
seenJti.add(claims.jti);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function route(method: string, path: string, query: URLSearchParams, body: any): Promise<Response> {
|
|
162
|
+
const p = path.split("/").slice(3).map(decodeURIComponent); // after /tenant/v0
|
|
163
|
+
const [a, b, c, d] = p;
|
|
164
|
+
if (a === "engine") return ok(state.engine);
|
|
165
|
+
if (a === "key") {
|
|
166
|
+
const k = o.key ? parseTenantKey(o.key) : { slug: "acme", kid: "0123456789abcdef" };
|
|
167
|
+
return ok({ id: k.kid, name: "Test key", tenant: k.slug, scopes, created_at: "2026-11-01T00:00:00.000Z", expires_at: new Date(now() + 120 * 86_400_000).toISOString(), ...o.keyRecord });
|
|
168
|
+
}
|
|
169
|
+
if (a === "definitions" && b === "status") return ok(state.definitions);
|
|
170
|
+
if (a === "definitions" && method === "POST") {
|
|
171
|
+
if (state.definitions.source !== "repository") return problem(409, "definitions-not-repository", "the Definitions are edited in the Admin UI");
|
|
172
|
+
const hash = await definitionSetHash(body ?? {});
|
|
173
|
+
if (hash === state.definitions.hash) return ok({ status: "unchanged", hash, revision: state.definitions.revision });
|
|
174
|
+
if (typeof submit === "function") return ok(submit(body, hash));
|
|
175
|
+
if (submit === "wait") {
|
|
176
|
+
const plan_id = `plan-${hash.slice(0, 8)}`;
|
|
177
|
+
if (!state.definitions.waiting.some((w) => w.hash === hash)) state.definitions.waiting.push({ plan_id, hash, created_at: iso() });
|
|
178
|
+
return ok({ status: "waiting", hash, revision: state.definitions.revision, plan_id, summary: { changes: 1, unsafe: 1 }, changes: [{ address: "wallet:example", kind: "wallet", change: "label", effect: "repins", safe: false }], approve_url: `https://admin.example/tenants/acme?tab=definitions&plan=${plan_id}` });
|
|
179
|
+
}
|
|
180
|
+
state.definitions = { ...state.definitions, revision: state.definitions.revision + 1, hash, waiting: [] };
|
|
181
|
+
return ok({ status: "applied", hash, revision: state.definitions.revision, summary: { changes: 1, unsafe: 0 }, changes: [{ address: "wallet:example", kind: "wallet", change: "registered", effect: "creates", safe: true }] });
|
|
182
|
+
}
|
|
183
|
+
for (const tab of ["branding", "portal"] as const) {
|
|
184
|
+
if (a !== tab) continue;
|
|
185
|
+
if (method === "GET") return ok(tab === "branding" ? state.branding : state.portal);
|
|
186
|
+
if (state[tab].source !== "repository") return problem(409, `${tab}-not-repository`, `the ${tab === "branding" ? "Branding" : "Portal"} tab is edited in the Admin UI`);
|
|
187
|
+
if (tab === "branding") {
|
|
188
|
+
const same = JSON.stringify(state.branding.branding) === JSON.stringify(body);
|
|
189
|
+
state.branding.branding = body;
|
|
190
|
+
return ok({ status: same ? "unchanged" : "applied" });
|
|
191
|
+
}
|
|
192
|
+
const next: Record<string, unknown> = { ...Object.fromEntries(ALL_SWITCHES.map((s) => [s, (body as RepositoryPortal)[s] ?? true])), pages: { ...(state.portal.portal.pages as object), ...((body as RepositoryPortal).pages ?? {}) } };
|
|
193
|
+
const same = JSON.stringify(next) === JSON.stringify(state.portal.portal);
|
|
194
|
+
state.portal.portal = next;
|
|
195
|
+
const kept = state.portal.redirected.filter((pg) => (body as RepositoryPortal).pages?.[pg]);
|
|
196
|
+
return ok({ status: same ? "unchanged" : "applied", ...(kept.length ? { kept } : {}) });
|
|
197
|
+
}
|
|
198
|
+
if (a === "tiers") {
|
|
199
|
+
if (method === "GET") return ok(state.tiers);
|
|
200
|
+
state.tiers = { ...state.tiers, ...body };
|
|
201
|
+
return ok({ status: "applied" });
|
|
202
|
+
}
|
|
203
|
+
if (a === "subscription") return ok({ arid: null, tier: "starter", label: "Starter", state: "active", frozen: false, renews: true, period_end: null, trial_end: null, allowance: { active_customers: 100, storage: 1_000_000_000 }, used: { active_customers: 3, storage: 0 }, overage: null, closing_at: null, features: null });
|
|
204
|
+
if (a === "customers" && c === "entitlement") return ok({ customer: b, tier: body?.tier, billing: { status: body?.status ?? "active" } });
|
|
205
|
+
if (a === "customers" && c === "entitlements") return ok({ customer: b, tier: null, features: {} });
|
|
206
|
+
if (a === "customers" && c === "tags") {
|
|
207
|
+
const tag = state.tags.find((t) => t.name === d);
|
|
208
|
+
if (!tag) return problem(404, "not-found", "Not Found");
|
|
209
|
+
const set = state.customerTags.get(b) ?? new Set<string>();
|
|
210
|
+
state.customerTags.set(b, set);
|
|
211
|
+
if (method === "PUT") {
|
|
212
|
+
const added = !set.has(d);
|
|
213
|
+
set.add(d);
|
|
214
|
+
return ok({ customer: b, tag: d, added });
|
|
215
|
+
}
|
|
216
|
+
const removed = set.delete(d);
|
|
217
|
+
return ok({ customer: b, tag: d, removed });
|
|
218
|
+
}
|
|
219
|
+
if (a === "tags" && !b) return ok({ items: state.tags });
|
|
220
|
+
if (a === "tags" && b) {
|
|
221
|
+
const existing = state.tags.find((t) => t.name === b);
|
|
222
|
+
if (existing) {
|
|
223
|
+
Object.assign(existing, body, { updated_at: iso() });
|
|
224
|
+
return ok({ ...existing, created: false });
|
|
225
|
+
}
|
|
226
|
+
const made: TagView = { id: `tag-${b}`, name: b, shown: `acme:${b}`, scope: "tenant", subject: "customer", visibility: body?.visibility ?? "staff", description: body?.description ?? "", colour: body?.colour ?? "slate", rules: body?.rules ?? [], effects: body?.effects ?? [], system: false, subjects: 0, created_at: iso(), updated_at: iso() };
|
|
227
|
+
state.tags.push(made);
|
|
228
|
+
return ok({ ...made, created: true }, 201);
|
|
229
|
+
}
|
|
230
|
+
if (a === "changesets" && !b) {
|
|
231
|
+
const since = Number(query.get("since") ?? 0) || 0;
|
|
232
|
+
return ok({ latest: state.engine.latest_changeset || (state.changesets.at(-1)?.number ?? 0), changesets: state.changesets.filter((x) => x.number > since) });
|
|
233
|
+
}
|
|
234
|
+
if (a === "changesets" && c === "status") {
|
|
235
|
+
const cs = state.changesets.find((x) => x.number === Number(b));
|
|
236
|
+
if (!cs || !cs.ticket) return problem(404, "not-found", "Not Found");
|
|
237
|
+
if (body?.status === "canceled" && !body.reason) return problem(422, "unprocessable", "a canceled changeset needs a reason");
|
|
238
|
+
cs.status = body?.status as ChangesetStatus;
|
|
239
|
+
return ok(cs);
|
|
240
|
+
}
|
|
241
|
+
if (a === "feedback" && !b && method === "POST") {
|
|
242
|
+
state.reports.push(body);
|
|
243
|
+
const arid = id("tkt");
|
|
244
|
+
state.feedback.push(ticket({ arid, subject: body.summary, body: body.detail, primary: "feedback", at: iso() }));
|
|
245
|
+
return ok({ id: arid, received_at: iso() }, 201);
|
|
246
|
+
}
|
|
247
|
+
if (a === "feedback" && !b) return ok({ items: state.feedback, next: state.feedback.length ? iso() : null });
|
|
248
|
+
if (a === "feedback" && c === "posts") {
|
|
249
|
+
const t = state.feedback.find((x) => x.arid === b);
|
|
250
|
+
if (!t) return problem(404, "not-found", "Not Found");
|
|
251
|
+
t.posts.push(post(iso(), body.body, true, "Your servers"));
|
|
252
|
+
return ok(t, 201);
|
|
253
|
+
}
|
|
254
|
+
if (a === "tickets" && !b && method === "GET") {
|
|
255
|
+
const status = query.get("status");
|
|
256
|
+
return ok({ items: state.tickets.filter((t) => !status || t.status === status).map(({ parties: _p, posts: _q, links: _l, refs: _r, audience: _a, hold_until: _h, closed_at: _c, can: _k, ...summary }) => summary) });
|
|
257
|
+
}
|
|
258
|
+
if (a === "tickets" && !b && method === "POST") {
|
|
259
|
+
if (body?.to !== "platform" && !/^ARID:0:[a-z0-9-]+:cst:/.test(body?.to ?? "")) return problem(422, "unprocessable", "to is platform or a Customer of this Tenant");
|
|
260
|
+
const t = ticket({ arid: id("tkt"), subject: body.subject, body: body.body, direction: body.to === "platform" ? "tenant_platform" : "tenant_customer", at: iso(), with_label: body.to === "platform" ? "Asturiu" : (body.to_name ?? "A Customer") });
|
|
261
|
+
state.tickets.push(t);
|
|
262
|
+
return ok(t, 201);
|
|
263
|
+
}
|
|
264
|
+
for (const [family, list] of [["tickets", state.tickets], ["announcements", state.announcements]] as const) {
|
|
265
|
+
if (a !== family || !b) continue;
|
|
266
|
+
const t = list.find((x) => x.arid === b);
|
|
267
|
+
if (!t) return problem(404, "not-found", "Not Found");
|
|
268
|
+
if (method === "GET") return ok(t);
|
|
269
|
+
if (method === "PATCH") {
|
|
270
|
+
if (body?.status) t.status = body.status;
|
|
271
|
+
if (body?.priority) t.priority = body.priority;
|
|
272
|
+
if (body?.archived !== undefined) t.archived = body.archived;
|
|
273
|
+
t.updated_at = iso();
|
|
274
|
+
return ok(t);
|
|
275
|
+
}
|
|
276
|
+
if (method === "POST" && c === "posts") {
|
|
277
|
+
t.posts.push(post(iso(), body.body, true, "Your servers"));
|
|
278
|
+
return ok(t, 201);
|
|
279
|
+
}
|
|
280
|
+
if (method === "DELETE") {
|
|
281
|
+
if (t.status !== "draft") return problem(409, "conflict", "only a draft is deleted; withdraw a published announcement");
|
|
282
|
+
state.announcements.splice(state.announcements.indexOf(t), 1);
|
|
283
|
+
return new Response(null, { status: 204 });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (a === "announcements" && method === "GET") return ok({ items: state.announcements, platform: state.platform });
|
|
287
|
+
if (a === "announcements" && method === "POST") {
|
|
288
|
+
const t = ticket({ arid: id("tkt"), subject: body.subject, body: body.body ?? "", status: "draft", primary: "announcement", direction: "tenant_customers", at: iso(), with_label: "Customers" });
|
|
289
|
+
state.announcements.push(t);
|
|
290
|
+
return ok(t, 201);
|
|
291
|
+
}
|
|
292
|
+
return problem(404, "not-found", "Not Found");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function handle(request: Request): Promise<Response> {
|
|
296
|
+
const url = new URL(request.url);
|
|
297
|
+
const body = request.method === "GET" || request.method === "HEAD" ? "" : await request.text();
|
|
298
|
+
const record = (status: number) => calls.push({ method: request.method, path: url.pathname, query: url.search, body: body ? safeJson(body) : undefined, status, sdk_version: /(?:^|\s)asturiu-v0-sdk\/(\S+)/.exec(request.headers.get("user-agent") ?? "")?.[1] ?? null });
|
|
299
|
+
// The Engine site's documents.
|
|
300
|
+
if (!url.pathname.startsWith("/tenant/v0/")) {
|
|
301
|
+
const doc = o.documents?.[url.href];
|
|
302
|
+
record(doc === undefined ? 404 : 200);
|
|
303
|
+
return doc === undefined ? new Response("Not Found", { status: 404 }) : new Response(doc, { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" } });
|
|
304
|
+
}
|
|
305
|
+
const respond = (r: Response) => {
|
|
306
|
+
record(r.status);
|
|
307
|
+
return r;
|
|
308
|
+
};
|
|
309
|
+
if (o.refuse) return respond(problem(o.refuse, o.refuse === 401 ? "unauthorized" : "refused", o.refuse === 401 ? "the key was refused" : "refused"));
|
|
310
|
+
const m = matchTenantApiRoute(request.method, url.pathname);
|
|
311
|
+
if (!m || o.missing?.includes(`${m.route.method} ${m.route.path}`)) return respond(problem(404, "not-found", "Not Found"));
|
|
312
|
+
const refused = refuseAssertion(request.headers, request.method, url.pathname, body);
|
|
313
|
+
if (refused) return respond(refused);
|
|
314
|
+
if (!keyReachesRoute(scopes, m.route)) return respond(problem(404, "not-found", "Not Found"));
|
|
315
|
+
return respond(await route(request.method, url.pathname, url.searchParams, body ? safeJson(body) : undefined));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const fetch: FetchLike = async (input, init) => {
|
|
319
|
+
if (o.down) {
|
|
320
|
+
const code = typeof o.down === "string" ? o.down : "ECONNREFUSED";
|
|
321
|
+
throw Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error(code), { code }) });
|
|
322
|
+
}
|
|
323
|
+
return handle(new Request(input, init));
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return { fetch, handle, state, calls, routes: TENANT_API_ROUTES };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function safeJson(text: string): unknown {
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(text);
|
|
332
|
+
} catch {
|
|
333
|
+
return text;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export type TenantApiStandIn = ReturnType<typeof createTenantApiStandIn>;
|