@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
@@ -0,0 +1,965 @@
1
+ /**
2
+ * Definition sets and Tenant keys (ADR-029, ADR-030) — the part a Tenant's own
3
+ * application server uses. A Tenant keeps its whole Definition set as one file;
4
+ * when its application starts, `applyDefinitions` sends it through the Tenant
5
+ * API, signed with a Tenant key whose private half never leaves the Tenant.
6
+ *
7
+ * Dependency-free and Web Crypto only, like the rest of the kit; the signing
8
+ * functions refuse to run in a browser, so a key bundled by mistake fails
9
+ * instead of working.
10
+ */
11
+
12
+ import type { TenantSubscriptionView } from "./tenant-subscription.js";
13
+ import type { FieldValueType } from "./types.js";
14
+ import type { CustomerEntitlements, TenantTierSet, TierRules } from "./tiers.js";
15
+ import type { RevealLevel } from "./reveal.js";
16
+ import type { FillMode, LocatorRule } from "./fill.js";
17
+ import type { RepositoryBranding, RepositoryPortal, TenantSource } from "./branding.js";
18
+ import type { PortalPage, PortalSwitch } from "./portal-options.js";
19
+ import type { ChangesetStatus, FeedbackReport, TenantChangeset } from "./changesets.js";
20
+ import { SDK_USER_AGENT } from "./version.js";
21
+ import type { AnnouncementItem, AnyStatus, Priority, TicketSummary, TicketView } from "./tickets.js";
22
+ import { type TagColour, type TagEffect, type TagRule, type TagSelector, type TagView, type TagVisibility, encodeSelector } from "./tags.js";
23
+
24
+ // ------------------------------------------------------------- the set
25
+
26
+ /** A field's rules in a Definition set: a FieldDefinition without its `name`, which is the key it stands under. */
27
+ export interface SetFieldSpec {
28
+ label?: string;
29
+ description?: string;
30
+ value_type: FieldValueType;
31
+ validation?: { pattern?: string; min?: number; max?: number; min_length?: number; max_length?: number; enum?: (string | number | boolean)[] };
32
+ secret?: boolean;
33
+ reveal?: RevealLevel;
34
+ searchable?: boolean;
35
+ immutable?: boolean;
36
+ locked?: boolean;
37
+ /** ADR-046: the modes a Field may use, the first preselected. Absent: `value`, `internal`, `external`. */
38
+ filled_by?: FillMode[];
39
+ /** Resource type tokens an internal reference may point at (`fld`, `ent`, `wlt`, `att`, …). Absent: any. */
40
+ targets?: string[];
41
+ /**
42
+ * Definitions of this set a target must be of. A bare key is an Entry type, as in `accepts`; a Wallet type or a
43
+ * field is named by its address (`wallet:<key>`, `field:<entry>.<key>`, `field:shared.<key>`). Each restricts
44
+ * targets of its own kind. Absent: any.
45
+ */
46
+ target_definitions?: string[];
47
+ locator?: LocatorRule;
48
+ }
49
+
50
+ /** What belongs to the reference, wherever the field is declared. `fill_in` only with `required`. */
51
+ export interface SetFieldReference {
52
+ required?: boolean;
53
+ order?: number;
54
+ fill_in?: unknown;
55
+ }
56
+
57
+ /** A field owned by its Entry type (address `<entry>.<key>`), or one of the shared fields (`{ use: "<key>" }`, optionally narrowing its modes here). */
58
+ export type SetEntryField = (SetFieldSpec & SetFieldReference) | ({ use: string; filled_by?: FillMode[] } & SetFieldReference);
59
+
60
+ export interface SetEntry {
61
+ label?: string;
62
+ description?: string;
63
+ kind?: "file" | "folder";
64
+ nestable?: boolean;
65
+ /** Folders only: Entry type keys. */
66
+ contains?: string[];
67
+ customizable?: boolean;
68
+ value_types?: FieldValueType[];
69
+ secret_reveal_min?: RevealLevel;
70
+ /** The modes a custom field on this type may allow (ADR-046). Absent: all three. */
71
+ custom_filled_by?: FillMode[];
72
+ fields: Record<string, SetEntryField>;
73
+ }
74
+
75
+ export interface SetWallet {
76
+ label?: string;
77
+ description?: string;
78
+ /**
79
+ * The Entry types this Wallet type owns, by key; it accepts each of them and
80
+ * no other Wallet type may. An Entry type key is unique across the set.
81
+ */
82
+ entries?: Record<string, SetEntry>;
83
+ /** Entry types from `shared_entries` this Wallet type accepts, by key. */
84
+ accepts?: string[];
85
+ nestable?: boolean;
86
+ immutable?: boolean;
87
+ metering?: "storage" | "events";
88
+ event_retention_days?: number;
89
+ default_policy?: Record<string, unknown>;
90
+ }
91
+
92
+ /**
93
+ * Every Definition of one Tenant, as one document (ADR-029, ADR-031): Wallet
94
+ * types with the Entry types they own, Entry types with the fields they own,
95
+ * and what is shared on purpose — fields used by several Entry types
96
+ * (`shared_fields`, through `use`) and Entry types accepted by several Wallet
97
+ * types or by none (`shared_entries`, through `accepts`). Keys are permanent;
98
+ * labels are what people see. The tree is only how the set is written: an
99
+ * Entry type is one EntryDefinition, addressed `entry:<key>` wherever it is
100
+ * declared.
101
+ */
102
+ export interface DefinitionSet {
103
+ shared_fields?: Record<string, SetFieldSpec>;
104
+ shared_entries?: Record<string, SetEntry>;
105
+ wallets: Record<string, SetWallet>;
106
+ /** `expose:<address>` for each change that makes values more visible. Not part of the set's hash. */
107
+ acknowledge?: string[];
108
+ }
109
+
110
+ /** One Entry type of a set with the Wallet type that owns it, or `null` for a shared one. */
111
+ export interface SetEntryOf extends SetEntry {
112
+ wallet: string | null;
113
+ }
114
+
115
+ /**
116
+ * Every Entry type of a set by its key, wherever it is declared. When a key is
117
+ * declared twice the first one wins here (shared, then Wallet types in order);
118
+ * the Engine refuses such a set.
119
+ */
120
+ export function entryTypesOf(set: Pick<DefinitionSet, "wallets" | "shared_entries">): Record<string, SetEntryOf> {
121
+ const out: Record<string, SetEntryOf> = {};
122
+ for (const [ek, e] of Object.entries(set.shared_entries ?? {})) out[ek] = { ...e, wallet: null };
123
+ for (const [wk, w] of Object.entries(set.wallets ?? {})) for (const [ek, e] of Object.entries(w.entries ?? {})) if (!out[ek]) out[ek] = { ...e, wallet: wk };
124
+ return out;
125
+ }
126
+
127
+ /** The Entry type keys a Wallet type accepts: those it owns, then the shared ones it names in `accepts`. */
128
+ export function acceptedBy(wallet: SetWallet): string[] {
129
+ return [...new Set([...Object.keys(wallet.entries ?? {}), ...(wallet.accepts ?? [])])];
130
+ }
131
+
132
+ /** A set in the format of changeset 003: Entry types at the top level, Wallet types naming them in `accepts`. */
133
+ export interface FlatDefinitionSet {
134
+ shared_fields?: Record<string, SetFieldSpec>;
135
+ entries: Record<string, SetEntry>;
136
+ wallets: Record<string, Omit<SetWallet, "entries" | "accepts"> & { accepts: string[] }>;
137
+ acknowledge?: string[];
138
+ }
139
+
140
+ /**
141
+ * A set with Entry types at the top level, as the tree — the same rule as for
142
+ * fields: an Entry type accepted by exactly one Wallet type moves under it;
143
+ * one accepted by several Wallet types, or by none, goes to `shared_entries`
144
+ * and the Wallet types that accept it name it in `accepts`. The Definitions
145
+ * the set describes do not change.
146
+ */
147
+ export function nestDefinitionSet(flat: FlatDefinitionSet): DefinitionSet {
148
+ const users = new Map<string, string[]>();
149
+ for (const [wk, w] of Object.entries(flat.wallets ?? {})) for (const ek of new Set(w.accepts ?? [])) users.set(ek, [...(users.get(ek) ?? []), wk]);
150
+ const owned = (ek: string) => (users.get(ek)?.length ?? 0) === 1 && Object.prototype.hasOwnProperty.call(flat.entries ?? {}, ek);
151
+ const shared_entries: Record<string, SetEntry> = {};
152
+ for (const [ek, e] of Object.entries(flat.entries ?? {})) if (!owned(ek)) shared_entries[ek] = e;
153
+ const wallets: Record<string, SetWallet> = {};
154
+ for (const [wk, w] of Object.entries(flat.wallets ?? {})) {
155
+ const { accepts, ...rest } = w;
156
+ const keys = [...new Set(accepts ?? [])];
157
+ const own = Object.fromEntries(keys.filter(owned).map((ek) => [ek, flat.entries[ek]]));
158
+ const others = keys.filter((ek) => !owned(ek));
159
+ wallets[wk] = { ...rest, ...(Object.keys(own).length ? { entries: own } : {}), ...(others.length ? { accepts: others } : {}) };
160
+ }
161
+ return {
162
+ ...(flat.shared_fields ? { shared_fields: flat.shared_fields } : {}),
163
+ ...(Object.keys(shared_entries).length ? { shared_entries } : {}),
164
+ wallets,
165
+ ...(flat.acknowledge ? { acknowledge: flat.acknowledge } : {}),
166
+ };
167
+ }
168
+
169
+ /** The effect of one change, as a plan reports it (ADR-029, decision 5). */
170
+ export type PlanEffect = "repins" | "creates" | "tightens" | "stays_pinned" | "may_stay_pinned" | "retires_values" | "withdraws" | "exposes";
171
+
172
+ export interface PlanChange {
173
+ /** `wallet:<key>`, `entry:<key>`, `field:<entry>.<key>` or `field:shared.<key>`. */
174
+ address: string;
175
+ kind: "wallet" | "entry" | "field";
176
+ /** What changed, in a word: `registered`, `label`, `value_type`, `validation_tighter`, `field_added_required`, … */
177
+ change: string;
178
+ effect: PlanEffect;
179
+ safe: boolean;
180
+ /** Resources on this Definition per version, counted from their pins — never from their content. */
181
+ in_use?: { version: number; count: number }[];
182
+ detail?: Record<string, unknown>;
183
+ }
184
+
185
+ export interface PlanRefusal {
186
+ address?: string;
187
+ reason: string;
188
+ }
189
+
190
+ export interface DefinitionPlan {
191
+ plan_id: string;
192
+ based_on: number;
193
+ hash: string;
194
+ changes: PlanChange[];
195
+ refused: PlanRefusal[];
196
+ /** What the set still says in an older form, read as its current form (e.g. a reference value type, ADR-046). Nothing to approve. */
197
+ warnings?: PlanRefusal[];
198
+ /** Every change is safe: a submission may apply it at once under `auto_apply: safe`. */
199
+ safe: boolean;
200
+ /** Nothing would be published. */
201
+ empty: boolean;
202
+ expires_at: string;
203
+ /** The revision a revert plan brings back (ADR-049). */
204
+ reverts?: number;
205
+ }
206
+
207
+ export type DefinitionSubmitStatus = "unchanged" | "applied" | "waiting" | "refused";
208
+
209
+ export interface DefinitionSubmitResult {
210
+ status: DefinitionSubmitStatus;
211
+ hash: string;
212
+ /** The revision now live: the new one when `applied`, the current one otherwise. */
213
+ revision: number;
214
+ plan_id?: string;
215
+ summary?: { changes: number; unsafe: number };
216
+ changes?: PlanChange[];
217
+ reasons?: PlanRefusal[];
218
+ /** What the set still says in an older form, read as its current form (ADR-046). */
219
+ warnings?: PlanRefusal[];
220
+ /** Where a tenant administrator approves a waiting plan. */
221
+ approve_url?: string;
222
+ /** The set waits because an administrator reverted it, in this revision (ADR-049). */
223
+ reverted_in?: number;
224
+ }
225
+
226
+ export interface DefinitionStatus {
227
+ revision: number;
228
+ hash: string | null;
229
+ source: "admin_ui" | "repository";
230
+ auto_apply: "safe" | "none";
231
+ waiting: { plan_id: string; hash: string; created_at: string }[];
232
+ }
233
+
234
+ /**
235
+ * JSON with object keys sorted and `undefined` dropped: the same bytes for the
236
+ * same value wherever it is computed, so the kit and the Engine agree on a hash.
237
+ */
238
+ export function canonicalJson(value: unknown): string {
239
+ if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
240
+ if (Array.isArray(value)) return `[${value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",")}]`;
241
+ const obj = value as Record<string, unknown>;
242
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
243
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
244
+ }
245
+
246
+ /** The set without `acknowledge`, canonical: what its hash covers. */
247
+ export function definitionSetHashInput(set: DefinitionSet | Record<string, unknown>): string {
248
+ const { acknowledge: _a, ...rest } = set as Record<string, unknown>;
249
+ return canonicalJson(rest);
250
+ }
251
+
252
+ /** SHA-256 hex of the set's canonical form, as the Engine reports it (`hash`). */
253
+ export async function definitionSetHash(set: DefinitionSet | Record<string, unknown>): Promise<string> {
254
+ return hex(await subtle().digest("SHA-256", new TextEncoder().encode(definitionSetHashInput(set))));
255
+ }
256
+
257
+ // ----------------------------------------------------------- Tenant API
258
+
259
+ /**
260
+ * The Tenant API's routes (ADR-030, decision 5; ADR-038; ADR-039; ADR-057;
261
+ * ADR-058; ADR-059): the only paths it forwards, and the scope each needs. A
262
+ * route whose scope is `null` is reached by any live key of the Tenant.
263
+ */
264
+ export const TENANT_API_ROUTES: readonly { method: string; path: string; scope: TenantKeyScope | null }[] = [
265
+ { method: "POST", path: "/tenant/v0/definitions", scope: "definitions" },
266
+ { method: "GET", path: "/tenant/v0/definitions/status", scope: "definitions" },
267
+ { method: "GET", path: "/tenant/v0/branding", scope: "branding" },
268
+ { method: "PUT", path: "/tenant/v0/branding", scope: "branding" },
269
+ { method: "GET", path: "/tenant/v0/portal", scope: "portal" },
270
+ { method: "PUT", path: "/tenant/v0/portal", scope: "portal" },
271
+ { method: "GET", path: "/tenant/v0/tiers", scope: "tiers" },
272
+ { method: "PUT", path: "/tenant/v0/tiers", scope: "tiers" },
273
+ { method: "PUT", path: "/tenant/v0/customers/:cst/entitlement", scope: "entitlements" },
274
+ { method: "GET", path: "/tenant/v0/customers/:cst/entitlements", scope: "entitlements" },
275
+ // A Tenant's agent reads its tier and use (ADR-064 §11). Paying stays with people.
276
+ { method: "GET", path: "/tenant/v0/subscription", scope: null },
277
+ // Tags (ADR-057 §7): the Tenant's own tags, and tagging its Customers, by name.
278
+ { method: "GET", path: "/tenant/v0/tags", scope: "tags" },
279
+ { method: "PUT", path: "/tenant/v0/tags/:name", scope: "tags" },
280
+ { method: "PUT", path: "/tenant/v0/customers/:cst/tags/:name", scope: "tags" },
281
+ { method: "DELETE", path: "/tenant/v0/customers/:cst/tags/:name", scope: "tags" },
282
+ // The Engine, its changesets and the Tenant's feedback (ADR-054 §7, ADR-059 §5): any live key, no scope.
283
+ { method: "GET", path: "/tenant/v0/engine", scope: null },
284
+ // The calling key's own record (ADR-074 §8): any live key, no scope.
285
+ { method: "GET", path: "/tenant/v0/key", scope: null },
286
+ { method: "GET", path: "/tenant/v0/changesets", scope: null },
287
+ { method: "PUT", path: "/tenant/v0/changesets/:n/status", scope: null },
288
+ { method: "POST", path: "/tenant/v0/feedback", scope: null },
289
+ { method: "GET", path: "/tenant/v0/feedback", scope: null },
290
+ { method: "POST", path: "/tenant/v0/feedback/:tkt/posts", scope: null },
291
+ // Tickets and announcements (ADR-058 §10): the Tenant's servers as a party of their own Tenant's tickets.
292
+ { method: "GET", path: "/tenant/v0/tickets", scope: "tickets" },
293
+ { method: "POST", path: "/tenant/v0/tickets", scope: "tickets" },
294
+ { method: "GET", path: "/tenant/v0/tickets/:tkt", scope: "tickets" },
295
+ { method: "PATCH", path: "/tenant/v0/tickets/:tkt", scope: "tickets" },
296
+ { method: "POST", path: "/tenant/v0/tickets/:tkt/posts", scope: "tickets" },
297
+ { method: "GET", path: "/tenant/v0/announcements", scope: "tickets" },
298
+ { method: "POST", path: "/tenant/v0/announcements", scope: "tickets" },
299
+ { method: "GET", path: "/tenant/v0/announcements/:tkt", scope: "tickets" },
300
+ { method: "PATCH", path: "/tenant/v0/announcements/:tkt", scope: "tickets" },
301
+ { method: "DELETE", path: "/tenant/v0/announcements/:tkt", scope: "tickets" },
302
+ ];
303
+
304
+ export type TenantKeyScope = "definitions" | "branding" | "portal" | "tiers" | "entitlements" | "tags" | "tickets";
305
+ export const TENANT_KEY_SCOPES: readonly TenantKeyScope[] = ["definitions", "branding", "portal", "tiers", "entitlements", "tags", "tickets"];
306
+ /** What a Tenant keeps in its repository (ADR-038, decision 4): one key may carry any of these together. */
307
+ export const REPOSITORY_SCOPES: readonly TenantKeyScope[] = ["definitions", "branding", "portal", "tiers"];
308
+
309
+ /** Why a key cannot carry these scopes, or null: at least one known scope, none twice, and `entitlements` only on its own (the billing backend keeps its own key). */
310
+ export function tenantKeyScopesProblem(scopes: readonly string[]): string | null {
311
+ if (!scopes.length) return "a key needs at least one scope";
312
+ const unknown = scopes.filter((s) => !(TENANT_KEY_SCOPES as readonly string[]).includes(s));
313
+ if (unknown.length) return `unknown scope: ${unknown.join(", ")}`;
314
+ if (new Set(scopes).size !== scopes.length) return "a scope is named twice";
315
+ if (scopes.includes("entitlements") && scopes.length > 1) return "entitlements is never combined with another scope: the billing backend keeps its own key";
316
+ return null;
317
+ }
318
+
319
+ /** A key's scopes from its stored or listed form: `scopes`, or `scope` as one scope or several separated by spaces (keys from before ADR-038). */
320
+ export function tenantKeyScopesOf(record: { scopes?: unknown; scope?: unknown }): TenantKeyScope[] {
321
+ const raw = Array.isArray(record.scopes) ? record.scopes : typeof record.scope === "string" ? record.scope.split(/\s+/) : [];
322
+ return raw.filter((s): s is TenantKeyScope => typeof s === "string" && (TENANT_KEY_SCOPES as readonly string[]).includes(s));
323
+ }
324
+ export type TenantApiRoute = (typeof TENANT_API_ROUTES)[number];
325
+
326
+ /** Whether a key with these scopes reaches a route: it carries the route's scope, or the route names none. */
327
+ export function keyReachesRoute(scopes: readonly string[], route: Pick<TenantApiRoute, "scope">): boolean {
328
+ return route.scope === null || scopes.includes(route.scope);
329
+ }
330
+
331
+ function matchPattern(pattern: string, path: string): Record<string, string> | null {
332
+ const p = pattern.split("/");
333
+ const a = path.split("/");
334
+ if (p.length !== a.length) return null;
335
+ const params: Record<string, string> = {};
336
+ for (let i = 0; i < p.length; i++) {
337
+ if (p[i].startsWith(":")) {
338
+ if (!a[i]) return null;
339
+ params[p[i].slice(1)] = decodeURIComponent(a[i]);
340
+ } else if (p[i] !== a[i]) return null;
341
+ }
342
+ return params;
343
+ }
344
+
345
+ const fill = (pattern: string, params: Record<string, string>) => pattern.replace(/:([a-z_]+)/g, (_, k: string) => encodeURIComponent(params[k] ?? ""));
346
+
347
+ /** The route a Tenant API request names, with its parameters, or null when the Tenant API has no such route. */
348
+ export function matchTenantApiRoute(method: string, path: string): { route: TenantApiRoute; params: Record<string, string> } | null {
349
+ for (const route of TENANT_API_ROUTES) {
350
+ if (route.method !== method.toUpperCase()) continue;
351
+ const params = matchPattern(route.path, path);
352
+ if (params) return { route, params };
353
+ }
354
+ return null;
355
+ }
356
+
357
+ /** A route pattern's parameters, or null when the path does not fit: shared with the Engine, which matches its forwards the same way. */
358
+ export function matchRoutePattern(pattern: string, path: string): Record<string, string> | null {
359
+ return matchPattern(pattern, path);
360
+ }
361
+
362
+ /** A route pattern with its parameters filled in, each encoded. */
363
+ export function fillRoutePattern(pattern: string, params: Record<string, string>): string {
364
+ return fill(pattern, params);
365
+ }
366
+
367
+ // ----------------------------------------------------------- Tenant keys
368
+
369
+ export const TENANT_KEY_PREFIX = "atk";
370
+ export const TENANT_ASSERTION_AUDIENCE = "asturiu-tenant-api";
371
+ /** Longest an assertion may live, and the most a key may live (ADR-030). */
372
+ export const TENANT_ASSERTION_MAX_SECONDS = 60;
373
+ export const TENANT_KEY_MAX_DAYS = 180;
374
+
375
+ export interface TenantKeyParts {
376
+ slug: string;
377
+ kid: string;
378
+ /** PKCS#8 private key, base64url. */
379
+ pkcs8: string;
380
+ }
381
+
382
+ /** `atk.<slug>.<key id>.<private key>` — the one environment variable a Tenant's server holds. */
383
+ export function parseTenantKey(value: string): TenantKeyParts {
384
+ const m = /^atk\.([a-z0-9][a-z0-9-]*)\.([0-9a-f]{16})\.([A-Za-z0-9_-]{40,})$/.exec((value ?? "").trim());
385
+ if (!m) throw new Error("not a Tenant key: expected atk.<slug>.<key id>.<private key>");
386
+ return { slug: m[1], kid: m[2], pkcs8: m[3] };
387
+ }
388
+
389
+ export function formatTenantKey(parts: TenantKeyParts): string {
390
+ return `${TENANT_KEY_PREFIX}.${parts.slug}.${parts.kid}.${parts.pkcs8}`;
391
+ }
392
+
393
+ /**
394
+ * A new Ed25519 key pair, made where this runs (the administrator's browser in
395
+ * the Admin UI): the private half as PKCS#8 for the Tenant to keep, the public
396
+ * half as the raw 32 bytes for Asturiu to store. Both base64url.
397
+ */
398
+ export async function generateTenantKeyPair(): Promise<{ privatePkcs8: string; publicRaw: string }> {
399
+ const pair = (await subtle().generateKey({ name: "Ed25519" }, true, ["sign", "verify"])) as { privateKey: Parameters<ReturnType<typeof subtle>["exportKey"]>[1]; publicKey: Parameters<ReturnType<typeof subtle>["exportKey"]>[1] };
400
+ const [priv, pub] = await Promise.all([subtle().exportKey("pkcs8", pair.privateKey), subtle().exportKey("raw", pair.publicKey)]);
401
+ return { privatePkcs8: b64url(new Uint8Array(priv)), publicRaw: b64url(new Uint8Array(pub)) };
402
+ }
403
+
404
+ function refuseBrowser(what: string): void {
405
+ const g = globalThis as { window?: unknown; document?: unknown };
406
+ if (g.window !== undefined && g.document !== undefined) throw new Error(`${what} runs on a server only: a Tenant key must never reach a browser`);
407
+ }
408
+
409
+ /**
410
+ * The signed assertion for one request (ADR-030, decision 3): valid for one
411
+ * minute, for this method, path and body, once. The key itself is never sent.
412
+ */
413
+ export async function signTenantAssertion(opts: { key: string; method: string; path: string; body?: string | Uint8Array; now?: number }): Promise<string> {
414
+ refuseBrowser("signTenantAssertion");
415
+ const k = parseTenantKey(opts.key);
416
+ const now = Math.floor((opts.now ?? Date.now()) / 1000);
417
+ const bytes = typeof opts.body === "string" ? new TextEncoder().encode(opts.body) : (opts.body ?? new Uint8Array());
418
+ const bdh = b64url(new Uint8Array(await subtle().digest("SHA-256", bytes)));
419
+ const header = { alg: "EdDSA", typ: "JWT", kid: k.kid };
420
+ const claims = { iss: k.slug, sub: k.kid, aud: TENANT_ASSERTION_AUDIENCE, htm: opts.method.toUpperCase(), htu: opts.path, bdh, iat: now, exp: now + TENANT_ASSERTION_MAX_SECONDS, jti: randomId() };
421
+ const input = `${b64urlText(JSON.stringify(header))}.${b64urlText(JSON.stringify(claims))}`;
422
+ const key = await subtle().importKey("pkcs8", fromB64url(k.pkcs8), { name: "Ed25519" }, false, ["sign"]);
423
+ const sig = await subtle().sign({ name: "Ed25519" }, key, new TextEncoder().encode(input));
424
+ return `${input}.${b64url(new Uint8Array(sig))}`;
425
+ }
426
+
427
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
428
+ type Logger = { info: (msg: string, detail?: Record<string, unknown>) => void; warn: (msg: string, detail?: Record<string, unknown>) => void; error: (msg: string, detail?: Record<string, unknown>) => void };
429
+ const consoleLog: Logger = {
430
+ info: (m, d) => console.info(`[asturiu] ${m}`, d ?? ""),
431
+ warn: (m, d) => console.warn(`[asturiu] ${m}`, d ?? ""),
432
+ error: (m, d) => console.error(`[asturiu] ${m}`, d ?? ""),
433
+ };
434
+
435
+ /**
436
+ * What every Tenant API call throws when the Engine answers with an error
437
+ * (ADR-074 §2): `status` tells a route this Engine lacks (`404`) from a
438
+ * refusal (`401`, `403`, `409`, `422`). `type` and `detail` are the
439
+ * problem document's. A network failure is not one: `fetch` throws its own.
440
+ */
441
+ export class TenantApiError extends Error {
442
+ readonly status: number;
443
+ readonly type: string | null;
444
+ readonly detail: string | null;
445
+ /** The whole problem document, with `issues` when the Engine names them. */
446
+ readonly problem: Record<string, unknown> | null;
447
+ constructor(status: number, problem: unknown) {
448
+ const p = problem && typeof problem === "object" ? (problem as Record<string, unknown>) : null;
449
+ const detail = typeof p?.detail === "string" ? p.detail : null;
450
+ const title = typeof p?.title === "string" ? p.title : null;
451
+ super(detail ?? title ?? `HTTP ${status}`);
452
+ this.name = "TenantApiError";
453
+ this.status = status;
454
+ this.type = typeof p?.type === "string" ? p.type : null;
455
+ this.detail = detail;
456
+ this.problem = p;
457
+ }
458
+ }
459
+
460
+ const fail = (r: { status: number; json: unknown }) => new TenantApiError(r.status, r.json);
461
+
462
+ /** The assertion signs the path alone; a query rides beside it (`?since=`), as the Operations API checks the route's path. */
463
+ async function tenantCall(opts: { key: string; baseUrl: string; method: string; path: string; query?: Record<string, string | number | undefined>; body?: unknown; idempotencyKey?: string; fetch?: FetchLike; log?: Logger }): Promise<{ status: number; json: any; keyExpires: string | null }> {
464
+ const text = opts.body === undefined ? "" : JSON.stringify(opts.body);
465
+ const assertion = await signTenantAssertion({ key: opts.key, method: opts.method, path: opts.path, body: text });
466
+ const f: FetchLike = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
467
+ // The SDK names its version, which the Engine records on the key as `last_sdk_version` (ADR-074 §4): how an administrator sees what a Tenant runs.
468
+ const headers: Record<string, string> = { authorization: `Asturiu-Assertion ${assertion}`, accept: "application/json", "user-agent": SDK_USER_AGENT };
469
+ if (text) headers["content-type"] = "application/json";
470
+ if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
471
+ const q = Object.entries(opts.query ?? {}).filter((e): e is [string, string | number] => e[1] !== undefined && e[1] !== "");
472
+ const search = q.length ? `?${new URLSearchParams(q.map(([k, v]): [string, string] => [k, String(v)])).toString()}` : "";
473
+ const res = await f(`${opts.baseUrl.replace(/\/+$/, "")}${opts.path}${search}`, { method: opts.method, headers, body: text || undefined });
474
+ const keyExpires = res.headers.get("asturiu-key-expires");
475
+ if (keyExpires) (opts.log ?? consoleLog).warn("the Tenant key expires soon; create its successor in the Admin UI and replace the environment variable", { expires: keyExpires });
476
+ const json = await res.json().catch(() => null);
477
+ return { status: res.status, json, keyExpires };
478
+ }
479
+
480
+ /**
481
+ * Send the application's Definition set when it starts (ADR-029, the
482
+ * repository flow). Never throws and never blocks start-up: the outcome is
483
+ * logged — applied, unchanged, waiting for a tenant administrator, or refused
484
+ * with the reasons — and returned.
485
+ */
486
+ export async function applyDefinitions(opts: { set: DefinitionSet | string; key: string; baseUrl: string; fetch?: FetchLike; log?: Logger }): Promise<DefinitionSubmitResult | { status: "error"; message: string }> {
487
+ const log = opts.log ?? consoleLog;
488
+ try {
489
+ refuseBrowser("applyDefinitions");
490
+ const set = typeof opts.set === "string" ? (JSON.parse(opts.set) as DefinitionSet) : opts.set;
491
+ const hash = await definitionSetHash(set);
492
+ // A fresh key per start: the submission is idempotent by its hash already, and a replayed answer would
493
+ // report "waiting" for a plan an administrator has since approved.
494
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "POST", path: "/tenant/v0/definitions", body: set, idempotencyKey: `definitions-${hash.slice(0, 32)}-${randomId()}`, fetch: opts.fetch, log });
495
+ if (r.status >= 400 && r.json?.status !== "refused") {
496
+ const message = r.json?.detail ?? r.json?.title ?? `HTTP ${r.status}`;
497
+ log.error("Definition set not submitted", { status: r.status, message });
498
+ return { status: "error", message };
499
+ }
500
+ const out = r.json as DefinitionSubmitResult;
501
+ if (out.status === "applied") log.info("Definition set applied", { revision: out.revision, hash: out.hash, changes: out.summary?.changes });
502
+ else if (out.status === "unchanged") log.info("Definition set unchanged", { revision: out.revision });
503
+ else if (out.status === "waiting" && out.reverted_in) log.warn(`Definition set waits for a tenant administrator: an administrator reverted it in revision ${out.reverted_in}; correct the repository's file, or have it approved again`, { plan: out.plan_id, approve: out.approve_url });
504
+ else if (out.status === "waiting") log.warn("Definition set waits for a tenant administrator: it changes existing data", { plan: out.plan_id, approve: out.approve_url, unsafe: out.summary?.unsafe });
505
+ else log.error("Definition set refused", { reasons: out.reasons });
506
+ return out;
507
+ } catch (err) {
508
+ const message = (err as Error).message;
509
+ log.error("Definition set not submitted", { message });
510
+ return { status: "error", message };
511
+ }
512
+ }
513
+
514
+ /** What `PUT /tenant/v0/branding` and `PUT /tenant/v0/portal` answer (ADR-038). `kept`, on the portal's answer: redirected pages the body named, left as an administrator set them (ADR-039). */
515
+ export interface TenantRepositoryResult {
516
+ status: "unchanged" | "applied";
517
+ kept?: PortalPage[];
518
+ }
519
+
520
+ async function applyWhole(what: "branding" | "portal" | "tiers", opts: { body: Record<string, unknown> | string; key: string; baseUrl: string; fetch?: FetchLike; log?: Logger }): Promise<TenantRepositoryResult | { status: "error"; message: string; problems?: unknown }> {
521
+ const log = opts.log ?? consoleLog;
522
+ const label = what === "branding" ? "Branding" : what === "portal" ? "Portal options" : "Tiers";
523
+ try {
524
+ refuseBrowser(what === "branding" ? "applyBranding" : what === "portal" ? "applyPortal" : "applyTiers");
525
+ const body = typeof opts.body === "string" ? (JSON.parse(opts.body) as Record<string, unknown>) : opts.body;
526
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path: `/tenant/v0/${what}`, body, idempotencyKey: `${what}-${randomId()}`, fetch: opts.fetch, log });
527
+ if (r.status >= 400) {
528
+ const message = r.json?.detail ?? r.json?.title ?? `HTTP ${r.status}`;
529
+ log.error(`${label} not applied`, { status: r.status, message, ...(r.json?.issues ? { issues: r.json.issues } : {}) });
530
+ return { status: "error", message, ...(r.json?.issues ? { problems: r.json.issues } : {}) };
531
+ }
532
+ const out = r.json as TenantRepositoryResult;
533
+ log.info(out.status === "applied" ? `${label} applied` : `${label} unchanged`);
534
+ if (out.kept?.length) log.warn("these portal pages stay where an administrator moved them (the Tenant's application); leave them out, or ask an administrator to bring them back", { pages: out.kept });
535
+ return out;
536
+ } catch (err) {
537
+ const message = (err as Error).message;
538
+ log.error(`${label} not applied`, { message });
539
+ return { status: "error", message };
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Send the branding the repository keeps when the application starts
545
+ * (ADR-038): the theme, `primary_color` and the images, inline
546
+ * (`inlineImage(bytes, type)`) — everything the Admin UI's Branding tab sets
547
+ * except addresses. The whole desired state of those keys: one left out is
548
+ * cleared, so send the whole theme. A string is read as JSON (a
549
+ * `branding.json` kept in the repository, as the Admin UI exports it).
550
+ * Applies at once; never throws, never blocks start-up.
551
+ */
552
+ export async function applyBranding(opts: { branding: RepositoryBranding | string; key: string; baseUrl: string; fetch?: FetchLike; log?: Logger }) {
553
+ return applyWhole("branding", { ...opts, body: opts.branding as Record<string, unknown> | string });
554
+ }
555
+
556
+ /**
557
+ * Send the portal options the repository keeps when the application starts
558
+ * (ADR-038, ADR-039): the switches and which pages show. A switch left out is
559
+ * on, a page left out is in the portal; a page an administrator moved to the
560
+ * application keeps its redirect and comes back in `kept`. Like `applyBranding`.
561
+ */
562
+ export async function applyPortal(opts: { portal: RepositoryPortal | string; key: string; baseUrl: string; fetch?: FetchLike; log?: Logger }) {
563
+ return applyWhole("portal", { ...opts, body: opts.portal as Record<string, unknown> | string });
564
+ }
565
+
566
+ /** The block a repository keeps (ADR-061 §10): the tiers, `default_tier` and `tier_rules`. */
567
+ export type RepositoryTiers = Pick<TenantTierSet, "tiers" | "default_tier"> & { tier_rules?: Partial<Omit<TierRules, "changed_at">> };
568
+
569
+ /**
570
+ * Send the tiers the repository keeps when the application starts (ADR-061
571
+ * §10). Run it after `applyDefinitions`, as tiers name Wallet types by key.
572
+ * Like `applyBranding`, it never blocks start-up.
573
+ */
574
+ export async function applyTiers(opts: { tiers: RepositoryTiers | string; key: string; baseUrl: string; fetch?: FetchLike; log?: Logger }) {
575
+ return applyWhole("tiers", { ...opts, body: opts.tiers as Record<string, unknown> | string });
576
+ }
577
+
578
+ /** What `GET /tenant/v0/tiers` answers: who edits the tab, and the block a `PUT` would send back unchanged. */
579
+ export interface RepositoryTiersState extends RepositoryTiers {
580
+ source: TenantSource;
581
+ }
582
+
583
+ /** The tiers as stored, in the shape `applyTiers` takes. Like `readBranding`. */
584
+ export async function readTiers(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<RepositoryTiersState> {
585
+ refuseBrowser("readTiers");
586
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/tiers", fetch: opts.fetch });
587
+ if (r.status >= 400) throw fail(r);
588
+ return r.json;
589
+ }
590
+
591
+ /** What one Customer may do now, for a server-side check or a background job (ADR-061 §9), with a key of scope `entitlements`. */
592
+ export async function readEntitlements(opts: { customer: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<CustomerEntitlements> {
593
+ refuseBrowser("readEntitlements");
594
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/entitlements`, fetch: opts.fetch });
595
+ if (r.status >= 400) throw fail(r);
596
+ return r.json;
597
+ }
598
+
599
+ /** What `GET /tenant/v0/branding` answers (ADR-039): who edits the tab, and the branding a `PUT` would send back unchanged. */
600
+ export interface RepositoryBrandingState {
601
+ source: TenantSource;
602
+ branding: RepositoryBranding;
603
+ }
604
+
605
+ /** What `GET /tenant/v0/portal` answers (ADR-039): who edits the tab, the options a `PUT` would send back unchanged, and the pages an administrator moved to the Tenant's application. */
606
+ export interface RepositoryPortalState {
607
+ source: TenantSource;
608
+ portal: Record<PortalSwitch, boolean> & { pages: RepositoryPortal["pages"] };
609
+ redirected: PortalPage[];
610
+ }
611
+
612
+ /**
613
+ * The branding as stored, in the shape `applyBranding` takes: to take the
614
+ * look designed in the Admin UI into the repository before the tab is handed
615
+ * over, or to compare. Readable whatever the tab's source. Throws on an error.
616
+ */
617
+ export async function readBranding(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<RepositoryBrandingState> {
618
+ refuseBrowser("readBranding");
619
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/branding", fetch: opts.fetch });
620
+ if (r.status >= 400) throw fail(r);
621
+ return r.json;
622
+ }
623
+
624
+ /** The portal options as stored, in the shape `applyPortal` takes. Like `readBranding`. */
625
+ export async function readPortal(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<RepositoryPortalState> {
626
+ refuseBrowser("readPortal");
627
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/portal", fetch: opts.fetch });
628
+ if (r.status >= 400) throw fail(r);
629
+ return r.json;
630
+ }
631
+
632
+ /** Report a Customer's tier (ADR-023, ADR-061), signed with a key of scope `entitlements`. */
633
+ export async function reportEntitlement(opts: { customer: string; tier: string; status?: "active" | "past_due" | "canceled"; period_end?: string | null; members?: string[]; key: string; baseUrl: string; idempotencyKey?: string; fetch?: FetchLike }): Promise<{ customer: string; tier: string; billing: Record<string, unknown> }> {
634
+ refuseBrowser("reportEntitlement");
635
+ const path = `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/entitlement`;
636
+ const body = { tier: opts.tier, status: opts.status ?? "active", ...(opts.period_end !== undefined ? { period_end: opts.period_end } : {}), ...(opts.members ? { members: opts.members } : {}) };
637
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path, body, idempotencyKey: opts.idempotencyKey ?? `entitlement-${opts.customer}-${opts.tier}-${Date.now()}`, fetch: opts.fetch });
638
+ if (r.status >= 400) throw fail(r);
639
+ return r.json;
640
+ }
641
+
642
+ /** The Tenant's own tier and use (ADR-064 §11), for its server or its agent. Read-only: paying stays with people. */
643
+ export async function readTenantSubscription(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<TenantSubscriptionView> {
644
+ refuseBrowser("readTenantSubscription");
645
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/subscription", fetch: opts.fetch });
646
+ if (r.status >= 400) throw fail(r);
647
+ return r.json;
648
+ }
649
+
650
+ /** The current revision and hash, and what waits for approval. */
651
+ export async function definitionStatus(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<DefinitionStatus> {
652
+ refuseBrowser("definitionStatus");
653
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/definitions/status", fetch: opts.fetch });
654
+ if (r.status >= 400) throw fail(r);
655
+ return r.json;
656
+ }
657
+
658
+ // ------------------------------------------------------------- engine, changesets, feedback (ADR-054 §7, ADR-059 §5)
659
+
660
+ /** What `GET /tenant/v0/engine` answers. `sdk` (ADR-074 §4): the SDK version the Engine was built with, and the oldest it still serves without a break. */
661
+ export interface EngineInfo {
662
+ version: string;
663
+ released_at: string | null;
664
+ latest_changeset: number;
665
+ site: { docs: string | null; changesets: string | null };
666
+ /** Absent on an Engine from before ADR-074. */
667
+ sdk?: { built_with: string; minimum: string };
668
+ }
669
+
670
+ /** The Engine's version, its latest changeset, the Engine site's addresses and the SDK versions it serves. Any live key of the Tenant. */
671
+ export async function readEngine(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<EngineInfo> {
672
+ refuseBrowser("readEngine");
673
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/engine", fetch: opts.fetch });
674
+ if (r.status >= 400) throw fail(r);
675
+ return r.json;
676
+ }
677
+
678
+ /** Every changeset after `since`, each with this Tenant's ticket and status when it has one. */
679
+ export async function readChangesets(opts: { key: string; baseUrl: string; since?: number; fetch?: FetchLike }): Promise<{ latest: number; changesets: TenantChangeset[] }> {
680
+ refuseBrowser("readChangesets");
681
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/changesets", query: { since: opts.since }, fetch: opts.fetch });
682
+ if (r.status >= 400) throw fail(r);
683
+ return r.json;
684
+ }
685
+
686
+ /** Where this Tenant stands with a changeset: `in_progress` while applying it, `resolved` with the SDK version installed, `canceled` with the reason. A note becomes a post. */
687
+ export async function setChangesetStatus(opts: { key: string; baseUrl: string; number: number; status: Exclude<ChangesetStatus, "new">; applied_version?: string; reason?: string; note?: string; fetch?: FetchLike }): Promise<TenantChangeset> {
688
+ refuseBrowser("setChangesetStatus");
689
+ const body = { status: opts.status, ...(opts.applied_version ? { applied_version: opts.applied_version } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(opts.note ? { note: opts.note } : {}) };
690
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path: `/tenant/v0/changesets/${opts.number}/status`, body, idempotencyKey: `changeset-${opts.number}-${await digest(JSON.stringify(body))}`, fetch: opts.fetch });
691
+ if (r.status >= 400) throw fail(r);
692
+ return r.json;
693
+ }
694
+
695
+ /** Report what the Engine cannot do, or does otherwise than documented. The same report sent twice is one ticket: the key is the body's hash. */
696
+ export async function sendFeedback(opts: { key: string; baseUrl: string; report: FeedbackReport; fetch?: FetchLike }): Promise<{ id: string; received_at: string }> {
697
+ refuseBrowser("sendFeedback");
698
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "POST", path: "/tenant/v0/feedback", body: opts.report, idempotencyKey: `feedback-${await digest(JSON.stringify(opts.report))}`, fetch: opts.fetch });
699
+ if (r.status >= 400) throw fail(r);
700
+ return r.json;
701
+ }
702
+
703
+ /** The Tenant's feedback tickets that changed after the cursor, with the replies it may read; keep `next` for the next call. */
704
+ export async function readFeedback(opts: { key: string; baseUrl: string; since?: string; fetch?: FetchLike }): Promise<{ items: TicketView[]; next: string | null }> {
705
+ refuseBrowser("readFeedback");
706
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/feedback", query: { since: opts.since }, fetch: opts.fetch });
707
+ if (r.status >= 400) throw fail(r);
708
+ return r.json;
709
+ }
710
+
711
+ /** Answer on one of the Tenant's feedback tickets. */
712
+ export async function replyToFeedback(opts: { key: string; baseUrl: string; id: string; body: string; fetch?: FetchLike }): Promise<TicketView> {
713
+ refuseBrowser("replyToFeedback");
714
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "POST", path: `/tenant/v0/feedback/${encodeURIComponent(opts.id)}/posts`, body: { body: opts.body }, idempotencyKey: `feedback-reply-${await digest(`${opts.id}|${opts.body}`)}`, fetch: opts.fetch });
715
+ if (r.status >= 400) throw fail(r);
716
+ return r.json;
717
+ }
718
+
719
+ // ------------------------------------------------------------- the key, tags, tickets, announcements (ADR-074 §2, §8)
720
+
721
+ /** What `GET /tenant/v0/key` answers: the calling key's own record. Never another key, never key material. */
722
+ export interface TenantKeyRecord {
723
+ id: string;
724
+ name: string;
725
+ /** The Tenant's slug. */
726
+ tenant: string;
727
+ scopes: TenantKeyScope[];
728
+ created_at: string;
729
+ expires_at: string;
730
+ }
731
+
732
+ /** The calling key's own record: which Tenant it is for, what it reaches and when it expires. Any live key. */
733
+ export async function readKey(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<TenantKeyRecord> {
734
+ refuseBrowser("readKey");
735
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/key", fetch: opts.fetch });
736
+ if (r.status >= 400) throw fail(r);
737
+ return r.json;
738
+ }
739
+
740
+ /** The Tenant's tags (ADR-057), with a key of scope `tags`. */
741
+ export async function readTags(opts: { key: string; baseUrl: string; fetch?: FetchLike }): Promise<TagView[]> {
742
+ refuseBrowser("readTags");
743
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/tags", fetch: opts.fetch });
744
+ if (r.status >= 400) throw fail(r);
745
+ return r.json?.items ?? [];
746
+ }
747
+
748
+ /** What `setTag` may set on a tag. A part left out keeps its value; a new tag starts as `staff`, `slate`, with no rules and no effects. */
749
+ export interface TagChange {
750
+ visibility?: TagVisibility;
751
+ description?: string;
752
+ colour?: TagColour;
753
+ rules?: TagRule[];
754
+ effects?: TagEffect[];
755
+ }
756
+
757
+ /** Create the tag or change it, by name, so a server keeps its tags in step. `created` says which happened. */
758
+ export async function setTag(opts: { name: string; change?: TagChange; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TagView & { created: boolean }> {
759
+ refuseBrowser("setTag");
760
+ const body = opts.change ?? {};
761
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path: `/tenant/v0/tags/${encodeURIComponent(opts.name)}`, body, idempotencyKey: `tag-${await digest(`${opts.name}|${JSON.stringify(body)}`)}`, fetch: opts.fetch });
762
+ if (r.status >= 400) throw fail(r);
763
+ return r.json;
764
+ }
765
+
766
+ /** Put a tag on one of the Tenant's Customers. `added` is false when the Customer had it already. */
767
+ export async function tagCustomer(opts: { customer: string; tag: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<{ customer: string; tag: string; added: boolean }> {
768
+ refuseBrowser("tagCustomer");
769
+ const path = `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/tags/${encodeURIComponent(opts.tag)}`;
770
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path, idempotencyKey: `tag-on-${await digest(path)}-${randomId()}`, fetch: opts.fetch });
771
+ if (r.status >= 400) throw fail(r);
772
+ return r.json;
773
+ }
774
+
775
+ /** Take a tag off one of the Tenant's Customers. `removed` is false when the Customer did not have it. */
776
+ export async function untagCustomer(opts: { customer: string; tag: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<{ customer: string; tag: string; removed: boolean }> {
777
+ refuseBrowser("untagCustomer");
778
+ const path = `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/tags/${encodeURIComponent(opts.tag)}`;
779
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "DELETE", path, idempotencyKey: `tag-off-${await digest(path)}-${randomId()}`, fetch: opts.fetch });
780
+ if (r.status >= 400) throw fail(r);
781
+ return r.json;
782
+ }
783
+
784
+ /** Which tickets `listTickets` answers with. Every part is optional; none lists the open and recent ones. */
785
+ export interface TicketFilter {
786
+ status?: string;
787
+ /** A primary type (`issue`, `request`, `feedback`) or a secondary one. */
788
+ type?: string;
789
+ archived?: boolean;
790
+ unread?: boolean;
791
+ tags?: TagSelector;
792
+ limit?: number;
793
+ }
794
+
795
+ /** The tickets the Tenant's servers are a party of (ADR-058 §10), with a key of scope `tickets`. */
796
+ export async function listTickets(opts: { filter?: TicketFilter; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketSummary[]> {
797
+ refuseBrowser("listTickets");
798
+ const f = opts.filter ?? {};
799
+ const query = { status: f.status, type: f.type, archived: f.archived ? "true" : undefined, unread: f.unread ? "true" : undefined, tags: f.tags ? encodeSelector(f.tags) : undefined, limit: f.limit };
800
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/tickets", query, fetch: opts.fetch });
801
+ if (r.status >= 400) throw fail(r);
802
+ return r.json?.items ?? [];
803
+ }
804
+
805
+ /** One ticket with its posts. Reading it marks it read for the Tenant's servers. */
806
+ export async function readTicket(opts: { id: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
807
+ refuseBrowser("readTicket");
808
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: `/tenant/v0/tickets/${encodeURIComponent(opts.id)}`, fetch: opts.fetch });
809
+ if (r.status >= 400) throw fail(r);
810
+ return r.json;
811
+ }
812
+
813
+ /** A new ticket: to the platform (`to: "platform"`, `type` one of `TENANT_PLATFORM_TYPES`) or to one of the Tenant's Customers (`to: <cst>`). */
814
+ export interface OpenTicket {
815
+ to: string;
816
+ type?: string;
817
+ subject: string;
818
+ body: string;
819
+ priority?: Priority;
820
+ refs?: string[];
821
+ /** The Customer's name as the Tenant's own pages show it, kept for the lists. */
822
+ to_name?: string;
823
+ }
824
+
825
+ export async function openTicket(opts: { ticket: OpenTicket; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
826
+ refuseBrowser("openTicket");
827
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "POST", path: "/tenant/v0/tickets", body: opts.ticket, idempotencyKey: `ticket-${await digest(JSON.stringify(opts.ticket))}`, fetch: opts.fetch });
828
+ if (r.status >= 400) throw fail(r);
829
+ return r.json;
830
+ }
831
+
832
+ /** A reply on a ticket. `visibility: "side"` keeps it to the Tenant's own side. */
833
+ export async function postToTicket(opts: { id: string; body: string; visibility?: "all" | "side"; refs?: string[]; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
834
+ refuseBrowser("postToTicket");
835
+ const body = { body: opts.body, ...(opts.visibility ? { visibility: opts.visibility } : {}), ...(opts.refs ? { refs: opts.refs } : {}) };
836
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "POST", path: `/tenant/v0/tickets/${encodeURIComponent(opts.id)}/posts`, body, idempotencyKey: `ticket-post-${await digest(`${opts.id}|${JSON.stringify(body)}`)}`, fetch: opts.fetch });
837
+ if (r.status >= 400) throw fail(r);
838
+ return r.json;
839
+ }
840
+
841
+ /** One change to a ticket or an announcement: its status, its priority or archiving it. */
842
+ export interface TicketChange {
843
+ status?: AnyStatus;
844
+ hold_until?: string | null;
845
+ priority?: Priority;
846
+ archived?: boolean;
847
+ audience?: TagSelector | null;
848
+ announcement?: AnnouncementOptions;
849
+ }
850
+
851
+ export async function updateTicket(opts: { id: string; change: TicketChange; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
852
+ refuseBrowser("updateTicket");
853
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PATCH", path: `/tenant/v0/tickets/${encodeURIComponent(opts.id)}`, body: opts.change, idempotencyKey: `ticket-change-${await digest(`${opts.id}|${JSON.stringify(opts.change)}`)}-${randomId()}`, fetch: opts.fetch });
854
+ if (r.status >= 400) throw fail(r);
855
+ return r.json;
856
+ }
857
+
858
+ /** An announcement's own settings (ADR-058 §8): texts per language, when it shows and how. */
859
+ export interface AnnouncementOptions {
860
+ languages?: Record<string, { subject: string; body: string }>;
861
+ default_language?: string;
862
+ publish_at?: string | null;
863
+ expires_at?: string | null;
864
+ pinned?: boolean;
865
+ severity?: "info" | "warning";
866
+ replies?: boolean;
867
+ }
868
+
869
+ /** The Tenant's announcements to its Customers, with every status, and the platform's announcements to the Tenant, read only. */
870
+ export async function listAnnouncements(opts: { lang?: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<{ items: TicketSummary[]; platform: AnnouncementItem[] }> {
871
+ refuseBrowser("listAnnouncements");
872
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/announcements", query: { lang: opts.lang }, fetch: opts.fetch });
873
+ if (r.status >= 400) throw fail(r);
874
+ return { items: r.json?.items ?? [], platform: r.json?.platform ?? [] };
875
+ }
876
+
877
+ export async function readAnnouncement(opts: { id: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
878
+ refuseBrowser("readAnnouncement");
879
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: `/tenant/v0/announcements/${encodeURIComponent(opts.id)}`, fetch: opts.fetch });
880
+ if (r.status >= 400) throw fail(r);
881
+ return r.json;
882
+ }
883
+
884
+ /** A new announcement to the Tenant's Customers: its text, its audience of tags (everyone without one) and its own settings. */
885
+ export interface NewAnnouncement {
886
+ subject: string;
887
+ body?: string;
888
+ type?: string;
889
+ priority?: Priority;
890
+ audience?: TagSelector;
891
+ announcement?: AnnouncementOptions;
892
+ }
893
+
894
+ /** Start an announcement to the Tenant's Customers, to everyone or to an audience of tags. It starts as a draft; `publishAnnouncement` sends it, or `announcement.publish_at` schedules it. */
895
+ export async function draftAnnouncement(opts: { announcement: NewAnnouncement; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
896
+ refuseBrowser("draftAnnouncement");
897
+ const body = { ...opts.announcement, to: "customers" };
898
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "POST", path: "/tenant/v0/announcements", body, idempotencyKey: `announcement-${await digest(JSON.stringify(body))}`, fetch: opts.fetch });
899
+ if (r.status >= 400) throw fail(r);
900
+ return r.json;
901
+ }
902
+
903
+ /** Publish a draft announcement now: its status becomes `published`. */
904
+ export async function publishAnnouncement(opts: { id: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
905
+ return updateAnnouncement({ ...opts, change: { status: "published" } });
906
+ }
907
+
908
+ export async function updateAnnouncement(opts: { id: string; change: TicketChange; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
909
+ refuseBrowser("updateAnnouncement");
910
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PATCH", path: `/tenant/v0/announcements/${encodeURIComponent(opts.id)}`, body: opts.change, idempotencyKey: `announcement-change-${await digest(`${opts.id}|${JSON.stringify(opts.change)}`)}-${randomId()}`, fetch: opts.fetch });
911
+ if (r.status >= 400) throw fail(r);
912
+ return r.json;
913
+ }
914
+
915
+ /** Take a published announcement down: its status becomes `withdrawn`. */
916
+ export async function withdrawAnnouncement(opts: { id: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<TicketView> {
917
+ return updateAnnouncement({ ...opts, change: { status: "withdrawn" } });
918
+ }
919
+
920
+ /** Delete an announcement that is still a draft; a published one is withdrawn instead. */
921
+ export async function deleteAnnouncementDraft(opts: { id: string; key: string; baseUrl: string; fetch?: FetchLike }): Promise<void> {
922
+ refuseBrowser("deleteAnnouncementDraft");
923
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "DELETE", path: `/tenant/v0/announcements/${encodeURIComponent(opts.id)}`, idempotencyKey: `announcement-delete-${await digest(opts.id)}`, fetch: opts.fetch });
924
+ if (r.status >= 400) throw fail(r);
925
+ }
926
+
927
+ async function digest(text: string): Promise<string> {
928
+ const h = await subtle().digest("SHA-256", new TextEncoder().encode(text));
929
+ return Array.from(new Uint8Array(h).slice(0, 16), (b) => b.toString(16).padStart(2, "0")).join("");
930
+ }
931
+
932
+ // ------------------------------------------------------------- helpers
933
+
934
+ function subtle(): typeof globalThis.crypto.subtle {
935
+ const s = globalThis.crypto?.subtle;
936
+ if (!s) throw new Error("Web Crypto is not available in this runtime");
937
+ return s;
938
+ }
939
+
940
+ function randomId(): string {
941
+ const b = new Uint8Array(16);
942
+ globalThis.crypto.getRandomValues(b);
943
+ return b64url(b);
944
+ }
945
+
946
+ function hex(buf: ArrayBuffer): string {
947
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
948
+ }
949
+
950
+ export function b64url(bytes: Uint8Array): string {
951
+ let s = "";
952
+ for (const b of bytes) s += String.fromCharCode(b);
953
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
954
+ }
955
+
956
+ function b64urlText(text: string): string {
957
+ return b64url(new TextEncoder().encode(text));
958
+ }
959
+
960
+ export function fromB64url(value: string): Uint8Array {
961
+ const s = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4));
962
+ const out = new Uint8Array(s.length);
963
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
964
+ return out;
965
+ }