@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,659 @@
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
+ import { SDK_USER_AGENT } from "./version.js";
12
+ import { encodeSelector } from "./tags.js";
13
+ /**
14
+ * Every Entry type of a set by its key, wherever it is declared. When a key is
15
+ * declared twice the first one wins here (shared, then Wallet types in order);
16
+ * the Engine refuses such a set.
17
+ */
18
+ export function entryTypesOf(set) {
19
+ const out = {};
20
+ for (const [ek, e] of Object.entries(set.shared_entries ?? {}))
21
+ out[ek] = { ...e, wallet: null };
22
+ for (const [wk, w] of Object.entries(set.wallets ?? {}))
23
+ for (const [ek, e] of Object.entries(w.entries ?? {}))
24
+ if (!out[ek])
25
+ out[ek] = { ...e, wallet: wk };
26
+ return out;
27
+ }
28
+ /** The Entry type keys a Wallet type accepts: those it owns, then the shared ones it names in `accepts`. */
29
+ export function acceptedBy(wallet) {
30
+ return [...new Set([...Object.keys(wallet.entries ?? {}), ...(wallet.accepts ?? [])])];
31
+ }
32
+ /**
33
+ * A set with Entry types at the top level, as the tree — the same rule as for
34
+ * fields: an Entry type accepted by exactly one Wallet type moves under it;
35
+ * one accepted by several Wallet types, or by none, goes to `shared_entries`
36
+ * and the Wallet types that accept it name it in `accepts`. The Definitions
37
+ * the set describes do not change.
38
+ */
39
+ export function nestDefinitionSet(flat) {
40
+ const users = new Map();
41
+ for (const [wk, w] of Object.entries(flat.wallets ?? {}))
42
+ for (const ek of new Set(w.accepts ?? []))
43
+ users.set(ek, [...(users.get(ek) ?? []), wk]);
44
+ const owned = (ek) => (users.get(ek)?.length ?? 0) === 1 && Object.prototype.hasOwnProperty.call(flat.entries ?? {}, ek);
45
+ const shared_entries = {};
46
+ for (const [ek, e] of Object.entries(flat.entries ?? {}))
47
+ if (!owned(ek))
48
+ shared_entries[ek] = e;
49
+ const wallets = {};
50
+ for (const [wk, w] of Object.entries(flat.wallets ?? {})) {
51
+ const { accepts, ...rest } = w;
52
+ const keys = [...new Set(accepts ?? [])];
53
+ const own = Object.fromEntries(keys.filter(owned).map((ek) => [ek, flat.entries[ek]]));
54
+ const others = keys.filter((ek) => !owned(ek));
55
+ wallets[wk] = { ...rest, ...(Object.keys(own).length ? { entries: own } : {}), ...(others.length ? { accepts: others } : {}) };
56
+ }
57
+ return {
58
+ ...(flat.shared_fields ? { shared_fields: flat.shared_fields } : {}),
59
+ ...(Object.keys(shared_entries).length ? { shared_entries } : {}),
60
+ wallets,
61
+ ...(flat.acknowledge ? { acknowledge: flat.acknowledge } : {}),
62
+ };
63
+ }
64
+ /**
65
+ * JSON with object keys sorted and `undefined` dropped: the same bytes for the
66
+ * same value wherever it is computed, so the kit and the Engine agree on a hash.
67
+ */
68
+ export function canonicalJson(value) {
69
+ if (value === null || typeof value !== "object")
70
+ return JSON.stringify(value ?? null);
71
+ if (Array.isArray(value))
72
+ return `[${value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",")}]`;
73
+ const obj = value;
74
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
75
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
76
+ }
77
+ /** The set without `acknowledge`, canonical: what its hash covers. */
78
+ export function definitionSetHashInput(set) {
79
+ const { acknowledge: _a, ...rest } = set;
80
+ return canonicalJson(rest);
81
+ }
82
+ /** SHA-256 hex of the set's canonical form, as the Engine reports it (`hash`). */
83
+ export async function definitionSetHash(set) {
84
+ return hex(await subtle().digest("SHA-256", new TextEncoder().encode(definitionSetHashInput(set))));
85
+ }
86
+ // ----------------------------------------------------------- Tenant API
87
+ /**
88
+ * The Tenant API's routes (ADR-030, decision 5; ADR-038; ADR-039; ADR-057;
89
+ * ADR-058; ADR-059): the only paths it forwards, and the scope each needs. A
90
+ * route whose scope is `null` is reached by any live key of the Tenant.
91
+ */
92
+ export const TENANT_API_ROUTES = [
93
+ { method: "POST", path: "/tenant/v0/definitions", scope: "definitions" },
94
+ { method: "GET", path: "/tenant/v0/definitions/status", scope: "definitions" },
95
+ { method: "GET", path: "/tenant/v0/branding", scope: "branding" },
96
+ { method: "PUT", path: "/tenant/v0/branding", scope: "branding" },
97
+ { method: "GET", path: "/tenant/v0/portal", scope: "portal" },
98
+ { method: "PUT", path: "/tenant/v0/portal", scope: "portal" },
99
+ { method: "GET", path: "/tenant/v0/tiers", scope: "tiers" },
100
+ { method: "PUT", path: "/tenant/v0/tiers", scope: "tiers" },
101
+ { method: "PUT", path: "/tenant/v0/customers/:cst/entitlement", scope: "entitlements" },
102
+ { method: "GET", path: "/tenant/v0/customers/:cst/entitlements", scope: "entitlements" },
103
+ // A Tenant's agent reads its tier and use (ADR-064 §11). Paying stays with people.
104
+ { method: "GET", path: "/tenant/v0/subscription", scope: null },
105
+ // Tags (ADR-057 §7): the Tenant's own tags, and tagging its Customers, by name.
106
+ { method: "GET", path: "/tenant/v0/tags", scope: "tags" },
107
+ { method: "PUT", path: "/tenant/v0/tags/:name", scope: "tags" },
108
+ { method: "PUT", path: "/tenant/v0/customers/:cst/tags/:name", scope: "tags" },
109
+ { method: "DELETE", path: "/tenant/v0/customers/:cst/tags/:name", scope: "tags" },
110
+ // The Engine, its changesets and the Tenant's feedback (ADR-054 §7, ADR-059 §5): any live key, no scope.
111
+ { method: "GET", path: "/tenant/v0/engine", scope: null },
112
+ // The calling key's own record (ADR-074 §8): any live key, no scope.
113
+ { method: "GET", path: "/tenant/v0/key", scope: null },
114
+ { method: "GET", path: "/tenant/v0/changesets", scope: null },
115
+ { method: "PUT", path: "/tenant/v0/changesets/:n/status", scope: null },
116
+ { method: "POST", path: "/tenant/v0/feedback", scope: null },
117
+ { method: "GET", path: "/tenant/v0/feedback", scope: null },
118
+ { method: "POST", path: "/tenant/v0/feedback/:tkt/posts", scope: null },
119
+ // Tickets and announcements (ADR-058 §10): the Tenant's servers as a party of their own Tenant's tickets.
120
+ { method: "GET", path: "/tenant/v0/tickets", scope: "tickets" },
121
+ { method: "POST", path: "/tenant/v0/tickets", scope: "tickets" },
122
+ { method: "GET", path: "/tenant/v0/tickets/:tkt", scope: "tickets" },
123
+ { method: "PATCH", path: "/tenant/v0/tickets/:tkt", scope: "tickets" },
124
+ { method: "POST", path: "/tenant/v0/tickets/:tkt/posts", scope: "tickets" },
125
+ { method: "GET", path: "/tenant/v0/announcements", scope: "tickets" },
126
+ { method: "POST", path: "/tenant/v0/announcements", scope: "tickets" },
127
+ { method: "GET", path: "/tenant/v0/announcements/:tkt", scope: "tickets" },
128
+ { method: "PATCH", path: "/tenant/v0/announcements/:tkt", scope: "tickets" },
129
+ { method: "DELETE", path: "/tenant/v0/announcements/:tkt", scope: "tickets" },
130
+ ];
131
+ export const TENANT_KEY_SCOPES = ["definitions", "branding", "portal", "tiers", "entitlements", "tags", "tickets"];
132
+ /** What a Tenant keeps in its repository (ADR-038, decision 4): one key may carry any of these together. */
133
+ export const REPOSITORY_SCOPES = ["definitions", "branding", "portal", "tiers"];
134
+ /** 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). */
135
+ export function tenantKeyScopesProblem(scopes) {
136
+ if (!scopes.length)
137
+ return "a key needs at least one scope";
138
+ const unknown = scopes.filter((s) => !TENANT_KEY_SCOPES.includes(s));
139
+ if (unknown.length)
140
+ return `unknown scope: ${unknown.join(", ")}`;
141
+ if (new Set(scopes).size !== scopes.length)
142
+ return "a scope is named twice";
143
+ if (scopes.includes("entitlements") && scopes.length > 1)
144
+ return "entitlements is never combined with another scope: the billing backend keeps its own key";
145
+ return null;
146
+ }
147
+ /** 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). */
148
+ export function tenantKeyScopesOf(record) {
149
+ const raw = Array.isArray(record.scopes) ? record.scopes : typeof record.scope === "string" ? record.scope.split(/\s+/) : [];
150
+ return raw.filter((s) => typeof s === "string" && TENANT_KEY_SCOPES.includes(s));
151
+ }
152
+ /** Whether a key with these scopes reaches a route: it carries the route's scope, or the route names none. */
153
+ export function keyReachesRoute(scopes, route) {
154
+ return route.scope === null || scopes.includes(route.scope);
155
+ }
156
+ function matchPattern(pattern, path) {
157
+ const p = pattern.split("/");
158
+ const a = path.split("/");
159
+ if (p.length !== a.length)
160
+ return null;
161
+ const params = {};
162
+ for (let i = 0; i < p.length; i++) {
163
+ if (p[i].startsWith(":")) {
164
+ if (!a[i])
165
+ return null;
166
+ params[p[i].slice(1)] = decodeURIComponent(a[i]);
167
+ }
168
+ else if (p[i] !== a[i])
169
+ return null;
170
+ }
171
+ return params;
172
+ }
173
+ const fill = (pattern, params) => pattern.replace(/:([a-z_]+)/g, (_, k) => encodeURIComponent(params[k] ?? ""));
174
+ /** The route a Tenant API request names, with its parameters, or null when the Tenant API has no such route. */
175
+ export function matchTenantApiRoute(method, path) {
176
+ for (const route of TENANT_API_ROUTES) {
177
+ if (route.method !== method.toUpperCase())
178
+ continue;
179
+ const params = matchPattern(route.path, path);
180
+ if (params)
181
+ return { route, params };
182
+ }
183
+ return null;
184
+ }
185
+ /** A route pattern's parameters, or null when the path does not fit: shared with the Engine, which matches its forwards the same way. */
186
+ export function matchRoutePattern(pattern, path) {
187
+ return matchPattern(pattern, path);
188
+ }
189
+ /** A route pattern with its parameters filled in, each encoded. */
190
+ export function fillRoutePattern(pattern, params) {
191
+ return fill(pattern, params);
192
+ }
193
+ // ----------------------------------------------------------- Tenant keys
194
+ export const TENANT_KEY_PREFIX = "atk";
195
+ export const TENANT_ASSERTION_AUDIENCE = "asturiu-tenant-api";
196
+ /** Longest an assertion may live, and the most a key may live (ADR-030). */
197
+ export const TENANT_ASSERTION_MAX_SECONDS = 60;
198
+ export const TENANT_KEY_MAX_DAYS = 180;
199
+ /** `atk.<slug>.<key id>.<private key>` — the one environment variable a Tenant's server holds. */
200
+ export function parseTenantKey(value) {
201
+ const m = /^atk\.([a-z0-9][a-z0-9-]*)\.([0-9a-f]{16})\.([A-Za-z0-9_-]{40,})$/.exec((value ?? "").trim());
202
+ if (!m)
203
+ throw new Error("not a Tenant key: expected atk.<slug>.<key id>.<private key>");
204
+ return { slug: m[1], kid: m[2], pkcs8: m[3] };
205
+ }
206
+ export function formatTenantKey(parts) {
207
+ return `${TENANT_KEY_PREFIX}.${parts.slug}.${parts.kid}.${parts.pkcs8}`;
208
+ }
209
+ /**
210
+ * A new Ed25519 key pair, made where this runs (the administrator's browser in
211
+ * the Admin UI): the private half as PKCS#8 for the Tenant to keep, the public
212
+ * half as the raw 32 bytes for Asturiu to store. Both base64url.
213
+ */
214
+ export async function generateTenantKeyPair() {
215
+ const pair = (await subtle().generateKey({ name: "Ed25519" }, true, ["sign", "verify"]));
216
+ const [priv, pub] = await Promise.all([subtle().exportKey("pkcs8", pair.privateKey), subtle().exportKey("raw", pair.publicKey)]);
217
+ return { privatePkcs8: b64url(new Uint8Array(priv)), publicRaw: b64url(new Uint8Array(pub)) };
218
+ }
219
+ function refuseBrowser(what) {
220
+ const g = globalThis;
221
+ if (g.window !== undefined && g.document !== undefined)
222
+ throw new Error(`${what} runs on a server only: a Tenant key must never reach a browser`);
223
+ }
224
+ /**
225
+ * The signed assertion for one request (ADR-030, decision 3): valid for one
226
+ * minute, for this method, path and body, once. The key itself is never sent.
227
+ */
228
+ export async function signTenantAssertion(opts) {
229
+ refuseBrowser("signTenantAssertion");
230
+ const k = parseTenantKey(opts.key);
231
+ const now = Math.floor((opts.now ?? Date.now()) / 1000);
232
+ const bytes = typeof opts.body === "string" ? new TextEncoder().encode(opts.body) : (opts.body ?? new Uint8Array());
233
+ const bdh = b64url(new Uint8Array(await subtle().digest("SHA-256", bytes)));
234
+ const header = { alg: "EdDSA", typ: "JWT", kid: k.kid };
235
+ 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() };
236
+ const input = `${b64urlText(JSON.stringify(header))}.${b64urlText(JSON.stringify(claims))}`;
237
+ const key = await subtle().importKey("pkcs8", fromB64url(k.pkcs8), { name: "Ed25519" }, false, ["sign"]);
238
+ const sig = await subtle().sign({ name: "Ed25519" }, key, new TextEncoder().encode(input));
239
+ return `${input}.${b64url(new Uint8Array(sig))}`;
240
+ }
241
+ const consoleLog = {
242
+ info: (m, d) => console.info(`[asturiu] ${m}`, d ?? ""),
243
+ warn: (m, d) => console.warn(`[asturiu] ${m}`, d ?? ""),
244
+ error: (m, d) => console.error(`[asturiu] ${m}`, d ?? ""),
245
+ };
246
+ /**
247
+ * What every Tenant API call throws when the Engine answers with an error
248
+ * (ADR-074 §2): `status` tells a route this Engine lacks (`404`) from a
249
+ * refusal (`401`, `403`, `409`, `422`). `type` and `detail` are the
250
+ * problem document's. A network failure is not one: `fetch` throws its own.
251
+ */
252
+ export class TenantApiError extends Error {
253
+ status;
254
+ type;
255
+ detail;
256
+ /** The whole problem document, with `issues` when the Engine names them. */
257
+ problem;
258
+ constructor(status, problem) {
259
+ const p = problem && typeof problem === "object" ? problem : null;
260
+ const detail = typeof p?.detail === "string" ? p.detail : null;
261
+ const title = typeof p?.title === "string" ? p.title : null;
262
+ super(detail ?? title ?? `HTTP ${status}`);
263
+ this.name = "TenantApiError";
264
+ this.status = status;
265
+ this.type = typeof p?.type === "string" ? p.type : null;
266
+ this.detail = detail;
267
+ this.problem = p;
268
+ }
269
+ }
270
+ const fail = (r) => new TenantApiError(r.status, r.json);
271
+ /** The assertion signs the path alone; a query rides beside it (`?since=`), as the Operations API checks the route's path. */
272
+ async function tenantCall(opts) {
273
+ const text = opts.body === undefined ? "" : JSON.stringify(opts.body);
274
+ const assertion = await signTenantAssertion({ key: opts.key, method: opts.method, path: opts.path, body: text });
275
+ const f = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
276
+ // 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.
277
+ const headers = { authorization: `Asturiu-Assertion ${assertion}`, accept: "application/json", "user-agent": SDK_USER_AGENT };
278
+ if (text)
279
+ headers["content-type"] = "application/json";
280
+ if (opts.idempotencyKey)
281
+ headers["idempotency-key"] = opts.idempotencyKey;
282
+ const q = Object.entries(opts.query ?? {}).filter((e) => e[1] !== undefined && e[1] !== "");
283
+ const search = q.length ? `?${new URLSearchParams(q.map(([k, v]) => [k, String(v)])).toString()}` : "";
284
+ const res = await f(`${opts.baseUrl.replace(/\/+$/, "")}${opts.path}${search}`, { method: opts.method, headers, body: text || undefined });
285
+ const keyExpires = res.headers.get("asturiu-key-expires");
286
+ if (keyExpires)
287
+ (opts.log ?? consoleLog).warn("the Tenant key expires soon; create its successor in the Admin UI and replace the environment variable", { expires: keyExpires });
288
+ const json = await res.json().catch(() => null);
289
+ return { status: res.status, json, keyExpires };
290
+ }
291
+ /**
292
+ * Send the application's Definition set when it starts (ADR-029, the
293
+ * repository flow). Never throws and never blocks start-up: the outcome is
294
+ * logged — applied, unchanged, waiting for a tenant administrator, or refused
295
+ * with the reasons — and returned.
296
+ */
297
+ export async function applyDefinitions(opts) {
298
+ const log = opts.log ?? consoleLog;
299
+ try {
300
+ refuseBrowser("applyDefinitions");
301
+ const set = typeof opts.set === "string" ? JSON.parse(opts.set) : opts.set;
302
+ const hash = await definitionSetHash(set);
303
+ // A fresh key per start: the submission is idempotent by its hash already, and a replayed answer would
304
+ // report "waiting" for a plan an administrator has since approved.
305
+ 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 });
306
+ if (r.status >= 400 && r.json?.status !== "refused") {
307
+ const message = r.json?.detail ?? r.json?.title ?? `HTTP ${r.status}`;
308
+ log.error("Definition set not submitted", { status: r.status, message });
309
+ return { status: "error", message };
310
+ }
311
+ const out = r.json;
312
+ if (out.status === "applied")
313
+ log.info("Definition set applied", { revision: out.revision, hash: out.hash, changes: out.summary?.changes });
314
+ else if (out.status === "unchanged")
315
+ log.info("Definition set unchanged", { revision: out.revision });
316
+ else if (out.status === "waiting" && out.reverted_in)
317
+ 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 });
318
+ else if (out.status === "waiting")
319
+ 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 });
320
+ else
321
+ log.error("Definition set refused", { reasons: out.reasons });
322
+ return out;
323
+ }
324
+ catch (err) {
325
+ const message = err.message;
326
+ log.error("Definition set not submitted", { message });
327
+ return { status: "error", message };
328
+ }
329
+ }
330
+ async function applyWhole(what, opts) {
331
+ const log = opts.log ?? consoleLog;
332
+ const label = what === "branding" ? "Branding" : what === "portal" ? "Portal options" : "Tiers";
333
+ try {
334
+ refuseBrowser(what === "branding" ? "applyBranding" : what === "portal" ? "applyPortal" : "applyTiers");
335
+ const body = typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body;
336
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path: `/tenant/v0/${what}`, body, idempotencyKey: `${what}-${randomId()}`, fetch: opts.fetch, log });
337
+ if (r.status >= 400) {
338
+ const message = r.json?.detail ?? r.json?.title ?? `HTTP ${r.status}`;
339
+ log.error(`${label} not applied`, { status: r.status, message, ...(r.json?.issues ? { issues: r.json.issues } : {}) });
340
+ return { status: "error", message, ...(r.json?.issues ? { problems: r.json.issues } : {}) };
341
+ }
342
+ const out = r.json;
343
+ log.info(out.status === "applied" ? `${label} applied` : `${label} unchanged`);
344
+ if (out.kept?.length)
345
+ 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 });
346
+ return out;
347
+ }
348
+ catch (err) {
349
+ const message = err.message;
350
+ log.error(`${label} not applied`, { message });
351
+ return { status: "error", message };
352
+ }
353
+ }
354
+ /**
355
+ * Send the branding the repository keeps when the application starts
356
+ * (ADR-038): the theme, `primary_color` and the images, inline
357
+ * (`inlineImage(bytes, type)`) — everything the Admin UI's Branding tab sets
358
+ * except addresses. The whole desired state of those keys: one left out is
359
+ * cleared, so send the whole theme. A string is read as JSON (a
360
+ * `branding.json` kept in the repository, as the Admin UI exports it).
361
+ * Applies at once; never throws, never blocks start-up.
362
+ */
363
+ export async function applyBranding(opts) {
364
+ return applyWhole("branding", { ...opts, body: opts.branding });
365
+ }
366
+ /**
367
+ * Send the portal options the repository keeps when the application starts
368
+ * (ADR-038, ADR-039): the switches and which pages show. A switch left out is
369
+ * on, a page left out is in the portal; a page an administrator moved to the
370
+ * application keeps its redirect and comes back in `kept`. Like `applyBranding`.
371
+ */
372
+ export async function applyPortal(opts) {
373
+ return applyWhole("portal", { ...opts, body: opts.portal });
374
+ }
375
+ /**
376
+ * Send the tiers the repository keeps when the application starts (ADR-061
377
+ * §10). Run it after `applyDefinitions`, as tiers name Wallet types by key.
378
+ * Like `applyBranding`, it never blocks start-up.
379
+ */
380
+ export async function applyTiers(opts) {
381
+ return applyWhole("tiers", { ...opts, body: opts.tiers });
382
+ }
383
+ /** The tiers as stored, in the shape `applyTiers` takes. Like `readBranding`. */
384
+ export async function readTiers(opts) {
385
+ refuseBrowser("readTiers");
386
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/tiers", fetch: opts.fetch });
387
+ if (r.status >= 400)
388
+ throw fail(r);
389
+ return r.json;
390
+ }
391
+ /** What one Customer may do now, for a server-side check or a background job (ADR-061 §9), with a key of scope `entitlements`. */
392
+ export async function readEntitlements(opts) {
393
+ refuseBrowser("readEntitlements");
394
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/entitlements`, fetch: opts.fetch });
395
+ if (r.status >= 400)
396
+ throw fail(r);
397
+ return r.json;
398
+ }
399
+ /**
400
+ * The branding as stored, in the shape `applyBranding` takes: to take the
401
+ * look designed in the Admin UI into the repository before the tab is handed
402
+ * over, or to compare. Readable whatever the tab's source. Throws on an error.
403
+ */
404
+ export async function readBranding(opts) {
405
+ refuseBrowser("readBranding");
406
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/branding", fetch: opts.fetch });
407
+ if (r.status >= 400)
408
+ throw fail(r);
409
+ return r.json;
410
+ }
411
+ /** The portal options as stored, in the shape `applyPortal` takes. Like `readBranding`. */
412
+ export async function readPortal(opts) {
413
+ refuseBrowser("readPortal");
414
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/portal", fetch: opts.fetch });
415
+ if (r.status >= 400)
416
+ throw fail(r);
417
+ return r.json;
418
+ }
419
+ /** Report a Customer's tier (ADR-023, ADR-061), signed with a key of scope `entitlements`. */
420
+ export async function reportEntitlement(opts) {
421
+ refuseBrowser("reportEntitlement");
422
+ const path = `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/entitlement`;
423
+ const body = { tier: opts.tier, status: opts.status ?? "active", ...(opts.period_end !== undefined ? { period_end: opts.period_end } : {}), ...(opts.members ? { members: opts.members } : {}) };
424
+ 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 });
425
+ if (r.status >= 400)
426
+ throw fail(r);
427
+ return r.json;
428
+ }
429
+ /** The Tenant's own tier and use (ADR-064 §11), for its server or its agent. Read-only: paying stays with people. */
430
+ export async function readTenantSubscription(opts) {
431
+ refuseBrowser("readTenantSubscription");
432
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/subscription", fetch: opts.fetch });
433
+ if (r.status >= 400)
434
+ throw fail(r);
435
+ return r.json;
436
+ }
437
+ /** The current revision and hash, and what waits for approval. */
438
+ export async function definitionStatus(opts) {
439
+ refuseBrowser("definitionStatus");
440
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/definitions/status", fetch: opts.fetch });
441
+ if (r.status >= 400)
442
+ throw fail(r);
443
+ return r.json;
444
+ }
445
+ /** The Engine's version, its latest changeset, the Engine site's addresses and the SDK versions it serves. Any live key of the Tenant. */
446
+ export async function readEngine(opts) {
447
+ refuseBrowser("readEngine");
448
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/engine", fetch: opts.fetch });
449
+ if (r.status >= 400)
450
+ throw fail(r);
451
+ return r.json;
452
+ }
453
+ /** Every changeset after `since`, each with this Tenant's ticket and status when it has one. */
454
+ export async function readChangesets(opts) {
455
+ refuseBrowser("readChangesets");
456
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/changesets", query: { since: opts.since }, fetch: opts.fetch });
457
+ if (r.status >= 400)
458
+ throw fail(r);
459
+ return r.json;
460
+ }
461
+ /** 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. */
462
+ export async function setChangesetStatus(opts) {
463
+ refuseBrowser("setChangesetStatus");
464
+ const body = { status: opts.status, ...(opts.applied_version ? { applied_version: opts.applied_version } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(opts.note ? { note: opts.note } : {}) };
465
+ 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 });
466
+ if (r.status >= 400)
467
+ throw fail(r);
468
+ return r.json;
469
+ }
470
+ /** 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. */
471
+ export async function sendFeedback(opts) {
472
+ refuseBrowser("sendFeedback");
473
+ 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 });
474
+ if (r.status >= 400)
475
+ throw fail(r);
476
+ return r.json;
477
+ }
478
+ /** The Tenant's feedback tickets that changed after the cursor, with the replies it may read; keep `next` for the next call. */
479
+ export async function readFeedback(opts) {
480
+ refuseBrowser("readFeedback");
481
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/feedback", query: { since: opts.since }, fetch: opts.fetch });
482
+ if (r.status >= 400)
483
+ throw fail(r);
484
+ return r.json;
485
+ }
486
+ /** Answer on one of the Tenant's feedback tickets. */
487
+ export async function replyToFeedback(opts) {
488
+ refuseBrowser("replyToFeedback");
489
+ 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 });
490
+ if (r.status >= 400)
491
+ throw fail(r);
492
+ return r.json;
493
+ }
494
+ /** The calling key's own record: which Tenant it is for, what it reaches and when it expires. Any live key. */
495
+ export async function readKey(opts) {
496
+ refuseBrowser("readKey");
497
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/key", fetch: opts.fetch });
498
+ if (r.status >= 400)
499
+ throw fail(r);
500
+ return r.json;
501
+ }
502
+ /** The Tenant's tags (ADR-057), with a key of scope `tags`. */
503
+ export async function readTags(opts) {
504
+ refuseBrowser("readTags");
505
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/tags", fetch: opts.fetch });
506
+ if (r.status >= 400)
507
+ throw fail(r);
508
+ return r.json?.items ?? [];
509
+ }
510
+ /** Create the tag or change it, by name, so a server keeps its tags in step. `created` says which happened. */
511
+ export async function setTag(opts) {
512
+ refuseBrowser("setTag");
513
+ const body = opts.change ?? {};
514
+ 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 });
515
+ if (r.status >= 400)
516
+ throw fail(r);
517
+ return r.json;
518
+ }
519
+ /** Put a tag on one of the Tenant's Customers. `added` is false when the Customer had it already. */
520
+ export async function tagCustomer(opts) {
521
+ refuseBrowser("tagCustomer");
522
+ const path = `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/tags/${encodeURIComponent(opts.tag)}`;
523
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "PUT", path, idempotencyKey: `tag-on-${await digest(path)}-${randomId()}`, fetch: opts.fetch });
524
+ if (r.status >= 400)
525
+ throw fail(r);
526
+ return r.json;
527
+ }
528
+ /** Take a tag off one of the Tenant's Customers. `removed` is false when the Customer did not have it. */
529
+ export async function untagCustomer(opts) {
530
+ refuseBrowser("untagCustomer");
531
+ const path = `/tenant/v0/customers/${encodeURIComponent(opts.customer)}/tags/${encodeURIComponent(opts.tag)}`;
532
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "DELETE", path, idempotencyKey: `tag-off-${await digest(path)}-${randomId()}`, fetch: opts.fetch });
533
+ if (r.status >= 400)
534
+ throw fail(r);
535
+ return r.json;
536
+ }
537
+ /** The tickets the Tenant's servers are a party of (ADR-058 §10), with a key of scope `tickets`. */
538
+ export async function listTickets(opts) {
539
+ refuseBrowser("listTickets");
540
+ const f = opts.filter ?? {};
541
+ 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 };
542
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/tickets", query, fetch: opts.fetch });
543
+ if (r.status >= 400)
544
+ throw fail(r);
545
+ return r.json?.items ?? [];
546
+ }
547
+ /** One ticket with its posts. Reading it marks it read for the Tenant's servers. */
548
+ export async function readTicket(opts) {
549
+ refuseBrowser("readTicket");
550
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: `/tenant/v0/tickets/${encodeURIComponent(opts.id)}`, fetch: opts.fetch });
551
+ if (r.status >= 400)
552
+ throw fail(r);
553
+ return r.json;
554
+ }
555
+ export async function openTicket(opts) {
556
+ refuseBrowser("openTicket");
557
+ 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 });
558
+ if (r.status >= 400)
559
+ throw fail(r);
560
+ return r.json;
561
+ }
562
+ /** A reply on a ticket. `visibility: "side"` keeps it to the Tenant's own side. */
563
+ export async function postToTicket(opts) {
564
+ refuseBrowser("postToTicket");
565
+ const body = { body: opts.body, ...(opts.visibility ? { visibility: opts.visibility } : {}), ...(opts.refs ? { refs: opts.refs } : {}) };
566
+ 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 });
567
+ if (r.status >= 400)
568
+ throw fail(r);
569
+ return r.json;
570
+ }
571
+ export async function updateTicket(opts) {
572
+ refuseBrowser("updateTicket");
573
+ 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 });
574
+ if (r.status >= 400)
575
+ throw fail(r);
576
+ return r.json;
577
+ }
578
+ /** The Tenant's announcements to its Customers, with every status, and the platform's announcements to the Tenant, read only. */
579
+ export async function listAnnouncements(opts) {
580
+ refuseBrowser("listAnnouncements");
581
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: "/tenant/v0/announcements", query: { lang: opts.lang }, fetch: opts.fetch });
582
+ if (r.status >= 400)
583
+ throw fail(r);
584
+ return { items: r.json?.items ?? [], platform: r.json?.platform ?? [] };
585
+ }
586
+ export async function readAnnouncement(opts) {
587
+ refuseBrowser("readAnnouncement");
588
+ const r = await tenantCall({ key: opts.key, baseUrl: opts.baseUrl, method: "GET", path: `/tenant/v0/announcements/${encodeURIComponent(opts.id)}`, fetch: opts.fetch });
589
+ if (r.status >= 400)
590
+ throw fail(r);
591
+ return r.json;
592
+ }
593
+ /** 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. */
594
+ export async function draftAnnouncement(opts) {
595
+ refuseBrowser("draftAnnouncement");
596
+ const body = { ...opts.announcement, to: "customers" };
597
+ 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 });
598
+ if (r.status >= 400)
599
+ throw fail(r);
600
+ return r.json;
601
+ }
602
+ /** Publish a draft announcement now: its status becomes `published`. */
603
+ export async function publishAnnouncement(opts) {
604
+ return updateAnnouncement({ ...opts, change: { status: "published" } });
605
+ }
606
+ export async function updateAnnouncement(opts) {
607
+ refuseBrowser("updateAnnouncement");
608
+ 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 });
609
+ if (r.status >= 400)
610
+ throw fail(r);
611
+ return r.json;
612
+ }
613
+ /** Take a published announcement down: its status becomes `withdrawn`. */
614
+ export async function withdrawAnnouncement(opts) {
615
+ return updateAnnouncement({ ...opts, change: { status: "withdrawn" } });
616
+ }
617
+ /** Delete an announcement that is still a draft; a published one is withdrawn instead. */
618
+ export async function deleteAnnouncementDraft(opts) {
619
+ refuseBrowser("deleteAnnouncementDraft");
620
+ 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 });
621
+ if (r.status >= 400)
622
+ throw fail(r);
623
+ }
624
+ async function digest(text) {
625
+ const h = await subtle().digest("SHA-256", new TextEncoder().encode(text));
626
+ return Array.from(new Uint8Array(h).slice(0, 16), (b) => b.toString(16).padStart(2, "0")).join("");
627
+ }
628
+ // ------------------------------------------------------------- helpers
629
+ function subtle() {
630
+ const s = globalThis.crypto?.subtle;
631
+ if (!s)
632
+ throw new Error("Web Crypto is not available in this runtime");
633
+ return s;
634
+ }
635
+ function randomId() {
636
+ const b = new Uint8Array(16);
637
+ globalThis.crypto.getRandomValues(b);
638
+ return b64url(b);
639
+ }
640
+ function hex(buf) {
641
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
642
+ }
643
+ export function b64url(bytes) {
644
+ let s = "";
645
+ for (const b of bytes)
646
+ s += String.fromCharCode(b);
647
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
648
+ }
649
+ function b64urlText(text) {
650
+ return b64url(new TextEncoder().encode(text));
651
+ }
652
+ export function fromB64url(value) {
653
+ const s = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4));
654
+ const out = new Uint8Array(s.length);
655
+ for (let i = 0; i < s.length; i++)
656
+ out[i] = s.charCodeAt(i);
657
+ return out;
658
+ }
659
+ //# sourceMappingURL=definition-set.js.map