@asturiu/v0-sdk 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +82 -2
  5. package/contract/assertion-vectors.json +90 -0
  6. package/contract/schemas/branding.schema.json +46 -0
  7. package/contract/schemas/definitions.schema.json +376 -0
  8. package/contract/schemas/portal.schema.json +56 -0
  9. package/contract/tenant-api-routes.json +165 -0
  10. package/dist/branding.d.ts +131 -0
  11. package/dist/branding.d.ts.map +1 -0
  12. package/dist/branding.js +206 -0
  13. package/dist/branding.js.map +1 -0
  14. package/dist/changesets.d.ts +58 -0
  15. package/dist/changesets.d.ts.map +1 -0
  16. package/dist/changesets.js +18 -0
  17. package/dist/changesets.js.map +1 -0
  18. package/dist/cli/bin.d.ts +3 -0
  19. package/dist/cli/bin.d.ts.map +1 -0
  20. package/dist/cli/bin.js +12 -0
  21. package/dist/cli/bin.js.map +1 -0
  22. package/dist/cli/changesets.d.ts +14 -0
  23. package/dist/cli/changesets.d.ts.map +1 -0
  24. package/dist/cli/changesets.js +252 -0
  25. package/dist/cli/changesets.js.map +1 -0
  26. package/dist/cli/cli.d.ts +37 -0
  27. package/dist/cli/cli.d.ts.map +1 -0
  28. package/dist/cli/cli.js +415 -0
  29. package/dist/cli/cli.js.map +1 -0
  30. package/dist/cli/context.d.ts +147 -0
  31. package/dist/cli/context.d.ts.map +1 -0
  32. package/dist/cli/context.js +258 -0
  33. package/dist/cli/context.js.map +1 -0
  34. package/dist/cli/engine.d.ts +59 -0
  35. package/dist/cli/engine.d.ts.map +1 -0
  36. package/dist/cli/engine.js +85 -0
  37. package/dist/cli/engine.js.map +1 -0
  38. package/dist/cli/feedback.d.ts +40 -0
  39. package/dist/cli/feedback.d.ts.map +1 -0
  40. package/dist/cli/feedback.js +420 -0
  41. package/dist/cli/feedback.js.map +1 -0
  42. package/dist/cli/files.d.ts +83 -0
  43. package/dist/cli/files.d.ts.map +1 -0
  44. package/dist/cli/files.js +389 -0
  45. package/dist/cli/files.js.map +1 -0
  46. package/dist/cli/index.d.ts +8 -0
  47. package/dist/cli/index.d.ts.map +1 -0
  48. package/dist/cli/index.js +8 -0
  49. package/dist/cli/index.js.map +1 -0
  50. package/dist/cli/overview.d.ts +6 -0
  51. package/dist/cli/overview.d.ts.map +1 -0
  52. package/dist/cli/overview.js +379 -0
  53. package/dist/cli/overview.js.map +1 -0
  54. package/dist/cli/parts.d.ts +5 -0
  55. package/dist/cli/parts.d.ts.map +1 -0
  56. package/dist/cli/parts.js +414 -0
  57. package/dist/cli/parts.js.map +1 -0
  58. package/dist/cli/sources.d.ts +37 -0
  59. package/dist/cli/sources.d.ts.map +1 -0
  60. package/dist/cli/sources.js +91 -0
  61. package/dist/cli/sources.js.map +1 -0
  62. package/dist/cli/terminal.d.ts +135 -0
  63. package/dist/cli/terminal.d.ts.map +1 -0
  64. package/dist/cli/terminal.js +353 -0
  65. package/dist/cli/terminal.js.map +1 -0
  66. package/dist/cli/ui-page.d.ts +8 -0
  67. package/dist/cli/ui-page.d.ts.map +1 -0
  68. package/dist/cli/ui-page.js +216 -0
  69. package/dist/cli/ui-page.js.map +1 -0
  70. package/dist/cli/ui.d.ts +54 -0
  71. package/dist/cli/ui.d.ts.map +1 -0
  72. package/dist/cli/ui.js +251 -0
  73. package/dist/cli/ui.js.map +1 -0
  74. package/dist/client.d.ts +88 -0
  75. package/dist/client.d.ts.map +1 -0
  76. package/dist/client.js +165 -0
  77. package/dist/client.js.map +1 -0
  78. package/dist/contacts.d.ts +43 -0
  79. package/dist/contacts.d.ts.map +1 -0
  80. package/dist/contacts.js +46 -0
  81. package/dist/contacts.js.map +1 -0
  82. package/dist/definition-set.d.ts +731 -0
  83. package/dist/definition-set.d.ts.map +1 -0
  84. package/dist/definition-set.js +659 -0
  85. package/dist/definition-set.js.map +1 -0
  86. package/dist/definitions.d.ts +42 -0
  87. package/dist/definitions.d.ts.map +1 -0
  88. package/dist/definitions.js +154 -0
  89. package/dist/definitions.js.map +1 -0
  90. package/dist/enrolment.d.ts +9 -0
  91. package/dist/enrolment.d.ts.map +1 -0
  92. package/dist/enrolment.js +8 -0
  93. package/dist/enrolment.js.map +1 -0
  94. package/dist/fill.d.ts +73 -0
  95. package/dist/fill.d.ts.map +1 -0
  96. package/dist/fill.js +46 -0
  97. package/dist/fill.js.map +1 -0
  98. package/dist/index.d.ts +39 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +39 -0
  101. package/dist/index.js.map +1 -0
  102. package/dist/notifications.d.ts +33 -0
  103. package/dist/notifications.d.ts.map +1 -0
  104. package/dist/notifications.js +36 -0
  105. package/dist/notifications.js.map +1 -0
  106. package/dist/oidc.d.ts +122 -0
  107. package/dist/oidc.d.ts.map +1 -0
  108. package/dist/oidc.js +150 -0
  109. package/dist/oidc.js.map +1 -0
  110. package/dist/overlay.d.ts +109 -0
  111. package/dist/overlay.d.ts.map +1 -0
  112. package/dist/overlay.js +58 -0
  113. package/dist/overlay.js.map +1 -0
  114. package/dist/passes.d.ts +95 -0
  115. package/dist/passes.d.ts.map +1 -0
  116. package/dist/passes.js +140 -0
  117. package/dist/passes.js.map +1 -0
  118. package/dist/portal-options.d.ts +65 -0
  119. package/dist/portal-options.d.ts.map +1 -0
  120. package/dist/portal-options.js +120 -0
  121. package/dist/portal-options.js.map +1 -0
  122. package/dist/reads.d.ts +41 -0
  123. package/dist/reads.d.ts.map +1 -0
  124. package/dist/reads.js +44 -0
  125. package/dist/reads.js.map +1 -0
  126. package/dist/recovery-words.d.ts +8 -0
  127. package/dist/recovery-words.d.ts.map +1 -0
  128. package/dist/recovery-words.js +136 -0
  129. package/dist/recovery-words.js.map +1 -0
  130. package/dist/recovery.d.ts +198 -0
  131. package/dist/recovery.d.ts.map +1 -0
  132. package/dist/recovery.js +112 -0
  133. package/dist/recovery.js.map +1 -0
  134. package/dist/reveal.d.ts +35 -0
  135. package/dist/reveal.d.ts.map +1 -0
  136. package/dist/reveal.js +66 -0
  137. package/dist/reveal.js.map +1 -0
  138. package/dist/seal.d.ts +15 -0
  139. package/dist/seal.d.ts.map +1 -0
  140. package/dist/seal.js +0 -0
  141. package/dist/seal.js.map +1 -0
  142. package/dist/session.d.ts +66 -0
  143. package/dist/session.d.ts.map +1 -0
  144. package/dist/session.js +61 -0
  145. package/dist/session.js.map +1 -0
  146. package/dist/signing-in-mark.d.ts +14 -0
  147. package/dist/signing-in-mark.d.ts.map +1 -0
  148. package/dist/signing-in-mark.js +24 -0
  149. package/dist/signing-in-mark.js.map +1 -0
  150. package/dist/signing-in.d.ts +22 -0
  151. package/dist/signing-in.d.ts.map +1 -0
  152. package/dist/signing-in.js +203 -0
  153. package/dist/signing-in.js.map +1 -0
  154. package/dist/tags.d.ts +96 -0
  155. package/dist/tags.d.ts.map +1 -0
  156. package/dist/tags.js +88 -0
  157. package/dist/tags.js.map +1 -0
  158. package/dist/tenant-subscription.d.ts +47 -0
  159. package/dist/tenant-subscription.d.ts.map +1 -0
  160. package/dist/tenant-subscription.js +7 -0
  161. package/dist/tenant-subscription.js.map +1 -0
  162. package/dist/testing/index.d.ts +125 -0
  163. package/dist/testing/index.d.ts.map +1 -0
  164. package/dist/testing/index.js +320 -0
  165. package/dist/testing/index.js.map +1 -0
  166. package/dist/theme.d.ts +237 -0
  167. package/dist/theme.d.ts.map +1 -0
  168. package/dist/theme.js +504 -0
  169. package/dist/theme.js.map +1 -0
  170. package/dist/tickets.d.ts +152 -0
  171. package/dist/tickets.d.ts.map +1 -0
  172. package/dist/tickets.js +65 -0
  173. package/dist/tickets.js.map +1 -0
  174. package/dist/tiers.d.ts +168 -0
  175. package/dist/tiers.d.ts.map +1 -0
  176. package/dist/tiers.js +341 -0
  177. package/dist/tiers.js.map +1 -0
  178. package/dist/types.d.ts +885 -0
  179. package/dist/types.d.ts.map +1 -0
  180. package/dist/types.js +12 -0
  181. package/dist/types.js.map +1 -0
  182. package/dist/urlid.d.ts +50 -0
  183. package/dist/urlid.d.ts.map +1 -0
  184. package/dist/urlid.js +78 -0
  185. package/dist/urlid.js.map +1 -0
  186. package/dist/version.d.ts +15 -0
  187. package/dist/version.d.ts.map +1 -0
  188. package/dist/version.js +15 -0
  189. package/dist/version.js.map +1 -0
  190. package/make/asturiu.mk +43 -0
  191. package/package.json +63 -4
  192. package/src/branding.ts +221 -0
  193. package/src/changesets.ts +61 -0
  194. package/src/cli/bin.ts +12 -0
  195. package/src/cli/changesets.ts +233 -0
  196. package/src/cli/cli.ts +425 -0
  197. package/src/cli/context.ts +299 -0
  198. package/src/cli/engine.ts +112 -0
  199. package/src/cli/feedback.ts +406 -0
  200. package/src/cli/files.ts +371 -0
  201. package/src/cli/index.ts +7 -0
  202. package/src/cli/overview.ts +343 -0
  203. package/src/cli/parts.ts +371 -0
  204. package/src/cli/sources.ts +111 -0
  205. package/src/cli/terminal.ts +384 -0
  206. package/src/cli/ui-page.ts +215 -0
  207. package/src/cli/ui.ts +266 -0
  208. package/src/client.ts +202 -0
  209. package/src/contacts.ts +61 -0
  210. package/src/definition-set.ts +965 -0
  211. package/src/definitions.ts +156 -0
  212. package/src/enrolment.ts +10 -0
  213. package/src/fill.ts +83 -0
  214. package/src/index.ts +38 -0
  215. package/src/notifications.ts +55 -0
  216. package/src/oidc.ts +199 -0
  217. package/src/overlay.ts +137 -0
  218. package/src/passes.ts +168 -0
  219. package/src/portal-options.ts +125 -0
  220. package/src/reads.ts +71 -0
  221. package/src/recovery-words.ts +137 -0
  222. package/src/recovery.ts +269 -0
  223. package/src/reveal.ts +72 -0
  224. package/src/seal.ts +0 -0
  225. package/src/session.ts +93 -0
  226. package/src/signing-in-mark.ts +23 -0
  227. package/src/signing-in.ts +213 -0
  228. package/src/tags.ts +146 -0
  229. package/src/tenant-subscription.ts +44 -0
  230. package/src/testing/index.ts +337 -0
  231. package/src/theme.ts +628 -0
  232. package/src/tickets.ts +195 -0
  233. package/src/tiers.ts +397 -0
  234. package/src/types.ts +847 -0
  235. package/src/urlid.ts +77 -0
  236. package/src/version.ts +15 -0
  237. package/templates/asturiu/branding.json +4 -0
  238. package/templates/asturiu/definitions.json +23 -0
  239. package/templates/asturiu/portal.json +16 -0
package/src/tickets.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tickets and announcements (ADR-058). A Ticket (`tkt`) is a conversation
3
+ * with a status between a Customer and its Tenant, a Tenant and the platform,
4
+ * or a User and the platform on the account page. An announcement is a ticket
5
+ * of its own primary type: one-to-many, to an audience of tags, with a
6
+ * lifecycle of its own. Pure and dependency-free: the Operations API decides,
7
+ * every surface reads the same words.
8
+ */
9
+
10
+ import type { TagSelector } from "./tags.js";
11
+
12
+ export const PRIMARY_TYPES = ["issue", "request", "feedback", "announcement"] as const;
13
+
14
+ export type PrimaryType = (typeof PRIMARY_TYPES)[number];
15
+
16
+ /** Who opened it and who answers (§2). Announcements go one way, to an audience. */
17
+ export const DIRECTIONS = ["customer_tenant", "tenant_customer", "tenant_platform", "platform_tenant", "user_platform", "platform_user", "tenant_customers", "platform_tenants", "platform_users"] as const;
18
+
19
+ export type Direction = (typeof DIRECTIONS)[number];
20
+
21
+ export const TICKET_STATUSES = ["new", "acknowledged", "in_progress", "waiting", "on_hold", "resolved", "canceled"] as const;
22
+
23
+ export type TicketStatus = (typeof TICKET_STATUSES)[number];
24
+
25
+ export const ANNOUNCEMENT_STATUSES = ["draft", "scheduled", "published", "expired", "withdrawn"] as const;
26
+
27
+ export type AnnouncementStatus = (typeof ANNOUNCEMENT_STATUSES)[number];
28
+
29
+ export type AnyStatus = TicketStatus | AnnouncementStatus;
30
+
31
+ export const PRIORITIES = ["low", "normal", "high", "urgent"] as const;
32
+
33
+ export type Priority = (typeof PRIORITIES)[number];
34
+
35
+ export type Side = "requester" | "handling";
36
+
37
+ /** The moves a ticket's status makes (§4, the state diagram). A requester's reply to `waiting` and a reopen are moves too. */
38
+ const MOVES: Record<TicketStatus, readonly TicketStatus[]> = {
39
+ new: ["acknowledged", "in_progress", "canceled"],
40
+ acknowledged: ["in_progress", "resolved", "canceled"],
41
+ in_progress: ["waiting", "on_hold", "resolved", "canceled"],
42
+ waiting: ["in_progress", "canceled"],
43
+ on_hold: ["in_progress", "canceled"],
44
+ resolved: ["in_progress"],
45
+ canceled: [],
46
+ };
47
+
48
+ /** Feedback keeps a subset (§4): it is seen, answered and closed, never worked on. */
49
+ const FEEDBACK_STATUSES: readonly TicketStatus[] = ["new", "acknowledged", "resolved", "canceled"];
50
+
51
+ export function statusesOf(primary: PrimaryType): readonly AnyStatus[] {
52
+ if (primary === "announcement") return ANNOUNCEMENT_STATUSES;
53
+ return primary === "feedback" ? FEEDBACK_STATUSES : TICKET_STATUSES;
54
+ }
55
+
56
+ /** Where a ticket may go from here, for the handling side; the requester may only cancel, resolve or reopen its own. */
57
+ export function nextStatuses(primary: PrimaryType, from: TicketStatus, who: Side): TicketStatus[] {
58
+ const allowed = statusesOf(primary) as readonly TicketStatus[];
59
+ const moves = MOVES[from].filter((s) => allowed.includes(s));
60
+ if (who === "handling") return moves.filter((s) => s !== "in_progress" || from !== "resolved");
61
+ return moves.filter((s) => s === "canceled" || s === "resolved" || (from === "resolved" && s === "in_progress"));
62
+ }
63
+
64
+ /** A resolved ticket reopens within this many days; later, or canceled, a new ticket links the old one (§4). */
65
+ export const REOPEN_DAYS = 14;
66
+
67
+ /** The Operator archives a closed ticket this many days after it closed (§4). */
68
+ export const ARCHIVE_DAYS = 30;
69
+
70
+ /** The words every surface shows for a status and a priority. */
71
+ export const STATUS_LABELS: Record<AnyStatus, string> = {
72
+ new: "New",
73
+ acknowledged: "Seen",
74
+ in_progress: "In progress",
75
+ waiting: "Waiting for you",
76
+ on_hold: "On hold",
77
+ resolved: "Resolved",
78
+ canceled: "Canceled",
79
+ draft: "Draft",
80
+ scheduled: "Scheduled",
81
+ published: "Published",
82
+ expired: "Expired",
83
+ withdrawn: "Withdrawn",
84
+ };
85
+
86
+ export const PRIORITY_LABELS: Record<Priority, string> = { low: "Low", normal: "Normal", high: "High", urgent: "Urgent" };
87
+
88
+ /** A Tenant's own type of ticket for its Customers (§3): a permanent key, the name a Customer reads, its primary type, the role that answers. */
89
+ export interface TenantTicketType {
90
+ key: string;
91
+ name: string;
92
+ primary: "issue" | "request" | "feedback";
93
+ handled_by: string;
94
+ }
95
+
96
+ /** Open: still work for someone. Closed tickets count toward nobody's open limit. */
97
+ export function isOpenStatus(s: AnyStatus): boolean {
98
+ return s !== "resolved" && s !== "canceled" && s !== "draft" && s !== "expired" && s !== "withdrawn";
99
+ }
100
+
101
+ export const TENANT_PLATFORM_TYPES = ["engine_defect", "engine_limitation", "engine_question", "changeset", "billing", "onboarding", "security", "notice", "other"] as const;
102
+
103
+ /** A party (§5): a person, a role binding, the Tenant's servers. */
104
+ export interface PartyView {
105
+ /** `customer:<cst>`, `user:<usr>`, `role:<group>` or `tenant:<tnt>`. */
106
+ ref: string;
107
+ kind: "person" | "role" | "tenant";
108
+ side: Side;
109
+ access: "ro" | "rw";
110
+ place: "requester" | "assignee" | "watcher";
111
+ /** How the reader sees it: a name for someone of their own side, "<Tenant> · Support" or "Asturiu · Support" for the other (§7). */
112
+ label: string;
113
+ }
114
+
115
+ /** A post as a reader sees it (§6, §7). */
116
+ export interface PostView {
117
+ id: string;
118
+ at: string;
119
+ side: Side;
120
+ visibility: "all" | "side";
121
+ author: { label: string; mine: boolean };
122
+ body: string;
123
+ refs: string[];
124
+ }
125
+
126
+ export interface TicketLink {
127
+ ticket: string;
128
+ relation: "escalated_from" | "escalated_to" | "duplicate_of" | "relates_to";
129
+ }
130
+
131
+ /** A ticket in a list. */
132
+ export interface TicketSummary {
133
+ arid: string;
134
+ scope: "platform" | "tenant";
135
+ tenant?: string;
136
+ direction: Direction;
137
+ primary: PrimaryType;
138
+ secondary: string | null;
139
+ /** The type's name, as the reader reads it. */
140
+ type_label: string;
141
+ subject: string;
142
+ status: AnyStatus;
143
+ priority: Priority;
144
+ archived: boolean;
145
+ created_at: string;
146
+ updated_at: string;
147
+ /** Which side the reader is on, or `audience` when they read it as one of many. */
148
+ side: Side | "audience";
149
+ unread: boolean;
150
+ /** Who it is with, from the reader's side: "<Tenant> · Support", a Customer's name, "Asturiu". */
151
+ with_label: string;
152
+ }
153
+
154
+ /** One ticket, whole, as a reader may see it. */
155
+ export interface TicketView extends TicketSummary {
156
+ parties: PartyView[];
157
+ posts: PostView[];
158
+ links: TicketLink[];
159
+ refs: string[];
160
+ audience: TagSelector | null;
161
+ hold_until: string | null;
162
+ closed_at: string | null;
163
+ /** What the reader may do now. */
164
+ can: { post: boolean; note: boolean; triage: boolean; statuses: TicketStatus[]; reopen: boolean };
165
+ /** Announcements: languages and the rest; absent for a ticket. */
166
+ announcement?: AnnouncementFields;
167
+ /** Changesets and feedback (ADR-059): what the ticket is about beyond its text. */
168
+ changeset?: { number: number; title: string; date: string; deadline: string | null; url: string | null; applied_version: string | null; reason: string | null };
169
+ feedback?: { area: string | null };
170
+ }
171
+
172
+ export interface AnnouncementFields {
173
+ languages: Record<string, { subject: string; body: string }>;
174
+ default_language: string;
175
+ publish_at: string | null;
176
+ expires_at: string | null;
177
+ pinned: boolean;
178
+ severity: "info" | "warning";
179
+ replies: boolean;
180
+ }
181
+
182
+ /** An announcement in a reader's inbox, in the reader's language or the default. */
183
+ export interface AnnouncementItem {
184
+ arid: string;
185
+ subject: string;
186
+ body: string;
187
+ published_at: string;
188
+ expires_at: string | null;
189
+ pinned: boolean;
190
+ severity: "info" | "warning";
191
+ replies: boolean;
192
+ read: boolean;
193
+ /** Who it is from: the Tenant's name, or the platform's. */
194
+ from: string;
195
+ }
package/src/tiers.ts ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Tiers (ADR-061). A tier is a named set of features. Engine features come
3
+ * from the closed catalogue below and the Engine enforces them; Tenant
4
+ * features are the Tenant's own, and the Engine stores and returns them
5
+ * without reading them. Dependency-free, so the Admin UI, the Customer Portal
6
+ * and a Tenant's application draw from the same list the Interface API
7
+ * enforces.
8
+ */
9
+
10
+ import { PASS_LIMITS } from "./passes.js";
11
+
12
+ export const TIER_LIMITS = {
13
+ tiers: 32,
14
+ label: 120,
15
+ /** Tenant features on one tier. */
16
+ tenantFeatures: 64,
17
+ tenantFeatureString: 200,
18
+ graceDays: 365,
19
+ /** A file removal gives a person time to act (§6). */
20
+ storageGraceDaysMin: 30,
21
+ } as const;
22
+
23
+ export const TIER_KEY_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
24
+ export const TENANT_FEATURE_KEY_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
25
+
26
+ /** `false` or `0`: off. `true`: on, no limit. A positive number: on, up to that number (§2). */
27
+ export type FeatureValue = boolean | number;
28
+
29
+ export type EngineFeatureKind = "toggle" | "stock" | "monthly" | "window" | "size";
30
+
31
+ export interface EngineFeature {
32
+ key: string;
33
+ kind: EngineFeatureKind;
34
+ /** What a number counts; `null` for a toggle. */
35
+ unit: "count" | "bytes" | "days" | "people" | null;
36
+ /** What a tier that does not name it gets. These keep the behaviour from before tiers. */
37
+ default: FeatureValue;
38
+ /** What a limit is counted over. */
39
+ scope: "vault" | "customer" | "wallet" | "upload" | null;
40
+ /** On a group plan the number is a total for the group (ADR-063 §6). */
41
+ pooled: boolean;
42
+ /** The highest number a tier may set; absent: no ceiling here. */
43
+ max?: number;
44
+ label: string;
45
+ }
46
+
47
+ /** ADR-061 §4. A feature joins this list when the Engine can enforce it. */
48
+ export const ENGINE_FEATURES: readonly EngineFeature[] = [
49
+ { key: "wallets", kind: "stock", unit: "count", default: true, scope: "vault", pooled: true, label: "Wallets" },
50
+ { key: "entries", kind: "stock", unit: "count", default: true, scope: "vault", pooled: true, label: "Entries" },
51
+ { key: "storage", kind: "stock", unit: "bytes", default: true, scope: "vault", pooled: true, label: "Storage" },
52
+ { key: "file_size", kind: "size", unit: "bytes", default: true, scope: "upload", pooled: false, label: "Largest file" },
53
+ { key: "sharing", kind: "stock", unit: "people", default: true, scope: "vault", pooled: false, label: "People shared with" },
54
+ { key: "conditional_sharing", kind: "toggle", unit: null, default: true, scope: null, pooled: false, label: "Conditional sharing" },
55
+ { key: "groups", kind: "stock", unit: "count", default: true, scope: "vault", pooled: true, label: "Groups" },
56
+ { key: "contacts", kind: "stock", unit: "count", default: true, scope: "customer", pooled: true, label: "Contacts" },
57
+ { key: "messages", kind: "monthly", unit: "count", default: true, scope: "customer", pooled: true, label: "Messages a month" },
58
+ { key: "transfers", kind: "monthly", unit: "count", default: true, scope: "customer", pooled: true, label: "Transfers a month" },
59
+ { key: "passes", kind: "stock", unit: "count", default: 0, scope: "vault", pooled: false, max: PASS_LIMITS.passesPerLevelMax, label: "Live guest passes" },
60
+ { key: "triggers", kind: "stock", unit: "count", default: true, scope: "vault", pooled: true, label: "Triggers" },
61
+ { key: "recovery", kind: "toggle", unit: null, default: true, scope: null, pooled: false, label: "Vault recovery" },
62
+ { key: "tickets", kind: "toggle", unit: null, default: true, scope: null, pooled: false, label: "Support tickets" },
63
+ { key: "sms", kind: "toggle", unit: null, default: true, scope: null, pooled: false, label: "SMS" },
64
+ { key: "trash_days", kind: "window", unit: "days", default: true, scope: null, pooled: false, label: "Days to restore" },
65
+ { key: "portfolios", kind: "stock", unit: "count", default: true, scope: "vault", pooled: true, label: "Portfolios" },
66
+ { key: "rename", kind: "toggle", unit: null, default: false, scope: null, pooled: false, label: "Rename fields" },
67
+ { key: "arrange", kind: "toggle", unit: null, default: false, scope: null, pooled: false, label: "Arrange fields" },
68
+ { key: "custom_fields", kind: "stock", unit: "count", default: 0, scope: "wallet", pooled: false, max: 200, label: "Custom fields per Wallet" },
69
+ { key: "secret_custom_fields", kind: "toggle", unit: null, default: false, scope: null, pooled: false, label: "Secret custom fields" },
70
+ { key: "searchable_custom_fields", kind: "stock", unit: "count", default: 0, scope: "wallet", pooled: false, max: 50, label: "Searchable custom fields per Wallet" },
71
+ ];
72
+
73
+ const CATALOGUE = new Map(ENGINE_FEATURES.map((f) => [f.key, f]));
74
+
75
+ export function engineFeature(key: string): EngineFeature | undefined {
76
+ return CATALOGUE.get(key);
77
+ }
78
+
79
+ export type TenantFeatureValue = boolean | number | string;
80
+
81
+ export interface TierDefinition {
82
+ label?: string;
83
+ /** Wallet type keys (ADR-029) or `"*"`. A key not in the Definition set is skipped when read. */
84
+ wallet_types: string[];
85
+ engine_features?: Record<string, FeatureValue>;
86
+ tenant_features?: Record<string, TenantFeatureValue>;
87
+ }
88
+
89
+ export type Tiers = Record<string, TierDefinition>;
90
+
91
+ export const TRIGGER_RULE_MODES = ["freeze", "pause"] as const;
92
+ export type TriggerRuleMode = (typeof TRIGGER_RULE_MODES)[number];
93
+ export const STORAGE_RULE_MODES = ["keep", "remove"] as const;
94
+ export type StorageRuleMode = (typeof STORAGE_RULE_MODES)[number];
95
+
96
+ /** What happens to what a Customer has above a lower tier (ADR-061 §6). */
97
+ export interface TierRules {
98
+ triggers: { mode: TriggerRuleMode; grace_days: number };
99
+ storage: { mode: StorageRuleMode; grace_days: number };
100
+ /** When a Tenant last lowered a limit these rules act on; set by the Operations API. */
101
+ changed_at?: string;
102
+ }
103
+
104
+ export interface TenantTierSet {
105
+ tiers: Tiers;
106
+ default_tier: string;
107
+ tier_rules: TierRules;
108
+ }
109
+
110
+ export function defaultTierRules(): TierRules {
111
+ return { triggers: { mode: "freeze", grace_days: 0 }, storage: { mode: "keep", grace_days: 90 } };
112
+ }
113
+
114
+ /** What a Tenant record with no tiers reads as (§3): one tier that creates from every Wallet type. */
115
+ export function defaultTierSet(): TenantTierSet {
116
+ return { tiers: { free: { wallet_types: ["*"] } }, default_tier: "free", tier_rules: defaultTierRules() };
117
+ }
118
+
119
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
120
+
121
+ /** The fields tiers replace, as records written before ADR-061 keep them. */
122
+ export interface LegacyTierFields {
123
+ scoping?: unknown;
124
+ customization?: unknown;
125
+ passes?: unknown;
126
+ billing?: unknown;
127
+ definitions?: unknown;
128
+ }
129
+
130
+ /** One tier's overlay limits from `Tenant.customization` (ADR-028), as Engine features. */
131
+ function featuresFromCustomization(raw: unknown): Record<string, FeatureValue> {
132
+ if (!isObject(raw)) return {};
133
+ const out: Record<string, FeatureValue> = {};
134
+ if (raw.rename === true) out.rename = true;
135
+ if (raw.arrange === true) out.arrange = true;
136
+ if (typeof raw.extra_fields === "number" && raw.extra_fields > 0) out.custom_fields = raw.extra_fields;
137
+ if (raw.secret_fields === true) out.secret_custom_fields = true;
138
+ if (typeof raw.searchable_fields === "number" && raw.searchable_fields > 0) out.searchable_custom_fields = raw.searchable_fields;
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Tiers from the four fields they replace (§11). Each default is the one the
144
+ * old field had, so nothing changes for a Customer. ARIDs in `scoping` become
145
+ * Wallet type keys through the Tenant's Definition map; one the map does not
146
+ * know stays an ARID, which `walletTypeArids` still honours.
147
+ */
148
+ export function tiersFromLegacy(raw: LegacyTierFields): TenantTierSet {
149
+ const scoping = isObject(raw.scoping) ? raw.scoping : {};
150
+ const customization = isObject(raw.customization) ? raw.customization : {};
151
+ const passes = isObject(raw.passes) && isObject(raw.passes.levels) ? raw.passes.levels : {};
152
+ const map = isObject(raw.definitions) && isObject(raw.definitions.map) ? (raw.definitions.map as Record<string, string>) : {};
153
+ const keyOf = new Map(Object.entries(map).filter(([k]) => k.startsWith("wallet:")).map(([k, arid]) => [arid, k.slice("wallet:".length)]));
154
+ const levels = [...new Set([...Object.keys(scoping), ...Object.keys(customization), ...Object.keys(passes)])].filter((l) => TIER_KEY_PATTERN.test(l));
155
+ const free = isObject(raw.billing) && typeof raw.billing.free_level === "string" ? raw.billing.free_level : "free";
156
+ if (!levels.length) return { ...defaultTierSet(), default_tier: "free" };
157
+ const tiers: Tiers = {};
158
+ for (const level of levels) {
159
+ const list = Array.isArray(scoping[level]) ? (scoping[level] as unknown[]).filter((a): a is string => typeof a === "string") : [];
160
+ const engine_features = featuresFromCustomization(customization[level]);
161
+ const allowed = isObject(passes[level]) ? passes[level].passes : undefined;
162
+ if (typeof allowed === "number" && allowed > 0) engine_features.passes = allowed;
163
+ tiers[level] = { wallet_types: list.map((a) => (a === "*" ? a : (keyOf.get(a) ?? a))), ...(Object.keys(engine_features).length ? { engine_features } : {}) };
164
+ }
165
+ // The old fall-back read the key `free`, then nothing: a level that was named nowhere stays empty.
166
+ const default_tier = free in tiers ? free : "free" in tiers ? "free" : free;
167
+ if (!(default_tier in tiers)) tiers[default_tier] = { wallet_types: [] };
168
+ return { tiers, default_tier, tier_rules: defaultTierRules() };
169
+ }
170
+
171
+ function readRules(raw: unknown): TierRules {
172
+ const out = defaultTierRules();
173
+ if (!isObject(raw)) return out;
174
+ const t = raw.triggers;
175
+ if (isObject(t)) {
176
+ if ((TRIGGER_RULE_MODES as readonly unknown[]).includes(t.mode)) out.triggers.mode = t.mode as TriggerRuleMode;
177
+ if (Number.isInteger(t.grace_days) && (t.grace_days as number) >= 0 && (t.grace_days as number) <= TIER_LIMITS.graceDays) out.triggers.grace_days = t.grace_days as number;
178
+ }
179
+ const s = raw.storage;
180
+ if (isObject(s)) {
181
+ if ((STORAGE_RULE_MODES as readonly unknown[]).includes(s.mode)) out.storage.mode = s.mode as StorageRuleMode;
182
+ if (Number.isInteger(s.grace_days) && (s.grace_days as number) >= TIER_LIMITS.storageGraceDaysMin && (s.grace_days as number) <= TIER_LIMITS.graceDays) out.storage.grace_days = s.grace_days as number;
183
+ }
184
+ if (typeof raw.changed_at === "string") out.changed_at = raw.changed_at;
185
+ return out;
186
+ }
187
+
188
+ /** A stored `tiers` block, read leniently: what cannot be read is left out, never a broken page. */
189
+ function readTiers(raw: unknown): Tiers {
190
+ const out: Tiers = {};
191
+ if (!isObject(raw)) return out;
192
+ for (const [key, t] of Object.entries(raw)) {
193
+ if (!TIER_KEY_PATTERN.test(key) || !isObject(t)) continue;
194
+ const tier: TierDefinition = { wallet_types: Array.isArray(t.wallet_types) ? t.wallet_types.filter((w): w is string => typeof w === "string") : [] };
195
+ if (typeof t.label === "string") tier.label = t.label;
196
+ if (isObject(t.engine_features)) {
197
+ const features: Record<string, FeatureValue> = {};
198
+ for (const [k, v] of Object.entries(t.engine_features)) if (CATALOGUE.has(k) && (typeof v === "boolean" || typeof v === "number")) features[k] = v;
199
+ if (Object.keys(features).length) tier.engine_features = features;
200
+ }
201
+ if (isObject(t.tenant_features)) tier.tenant_features = t.tenant_features as Record<string, TenantFeatureValue>;
202
+ out[key] = tier;
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** A Tenant's tiers, from `tiers` when the record has them, otherwise from the fields they replace. */
208
+ export function tierSetOf(raw: LegacyTierFields & { tiers?: unknown; default_tier?: unknown; tier_rules?: unknown }): TenantTierSet {
209
+ const tiers = readTiers(raw.tiers);
210
+ const keys = Object.keys(tiers);
211
+ if (!keys.length) return tiersFromLegacy(raw);
212
+ const stored = typeof raw.default_tier === "string" ? raw.default_tier : "";
213
+ return { tiers, default_tier: stored in tiers ? stored : (keys[0] as string), tier_rules: readRules(raw.tier_rules) };
214
+ }
215
+
216
+ /** The tier in force: the stored key, or `default_tier` when there is none or it no longer exists (§3). */
217
+ export function tierKeyOf(set: TenantTierSet, stored: string | null | undefined): string {
218
+ return stored && stored in set.tiers ? stored : set.default_tier;
219
+ }
220
+
221
+ export function tierOf(set: TenantTierSet, stored: string | null | undefined): TierDefinition {
222
+ return set.tiers[tierKeyOf(set, stored)] ?? { wallet_types: [] };
223
+ }
224
+
225
+ /** A feature's value on a tier: as written, or the catalogue's default. */
226
+ export function featureValue(tier: TierDefinition, key: string): FeatureValue {
227
+ const stored = tier.engine_features?.[key];
228
+ if (stored !== undefined) return stored;
229
+ return CATALOGUE.get(key)?.default ?? false;
230
+ }
231
+
232
+ /** `null`: no limit. `0`: off. Otherwise the number (§2). */
233
+ export function featureLimit(value: FeatureValue): number | null {
234
+ if (value === true) return null;
235
+ if (value === false) return 0;
236
+ return value;
237
+ }
238
+
239
+ /** Whether a Customer may use a feature at all. */
240
+ export function featureIsOn(value: FeatureValue): boolean {
241
+ return value === true || (typeof value === "number" && value > 0);
242
+ }
243
+
244
+ /** The Wallet type ARIDs a tier may create from. `map` is the Tenant's Definition map; a key not in it is skipped (§3). */
245
+ export function walletTypeArids(tier: TierDefinition, map: Record<string, string>, all: readonly string[]): Set<string> {
246
+ if (tier.wallet_types.includes("*")) return new Set(all);
247
+ const out = new Set<string>();
248
+ for (const w of tier.wallet_types) {
249
+ const arid = map[`wallet:${w}`] ?? (all.includes(w) ? w : undefined);
250
+ if (arid) out.add(arid);
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /** Whether any tier switches a Wallet overlay feature on: the Tenant has customisation at all (ADR-028). */
256
+ export function overlaysOn(set: TenantTierSet): boolean {
257
+ return Object.values(set.tiers).some((t) => ["rename", "arrange", "custom_fields", "secret_custom_fields", "searchable_custom_fields"].some((k) => featureIsOn(featureValue(t, k))));
258
+ }
259
+
260
+ /** What a tier may add to a Wallet overlay, in the shape the overlay code checks against. */
261
+ export function overlayLimitsOf(tier: TierDefinition): { rename: boolean; arrange: boolean; extra_fields: number; secret_fields: boolean; searchable_fields: number } {
262
+ const count = (key: string) => {
263
+ const limit = featureLimit(featureValue(tier, key));
264
+ return limit === null ? (CATALOGUE.get(key)?.max ?? 0) : limit;
265
+ };
266
+ return { rename: featureIsOn(featureValue(tier, "rename")), arrange: featureIsOn(featureValue(tier, "arrange")), extra_fields: count("custom_fields"), secret_fields: featureIsOn(featureValue(tier, "secret_custom_fields")), searchable_fields: count("searchable_custom_fields") };
267
+ }
268
+
269
+ export interface TierProblem {
270
+ path: (string | number)[];
271
+ message: string;
272
+ }
273
+
274
+ /** Strict checks on a block being written (§3). One entry per problem, with its path. */
275
+ export function tierProblems(raw: { tiers?: unknown; default_tier?: unknown; tier_rules?: unknown }): TierProblem[] {
276
+ const out: TierProblem[] = [];
277
+ if (!isObject(raw.tiers)) return [{ path: ["tiers"], message: "tiers is an object of tier keys" }];
278
+ const keys = Object.keys(raw.tiers);
279
+ if (!keys.length) out.push({ path: ["tiers"], message: "a Tenant needs at least one tier" });
280
+ if (keys.length > TIER_LIMITS.tiers) out.push({ path: ["tiers"], message: `at most ${TIER_LIMITS.tiers} tiers` });
281
+ if (typeof raw.default_tier !== "string" || !keys.includes(raw.default_tier)) out.push({ path: ["default_tier"], message: "default_tier must be a tier key" });
282
+ for (const key of keys) {
283
+ const at = (...rest: (string | number)[]) => ["tiers", key, ...rest];
284
+ if (!TIER_KEY_PATTERN.test(key)) out.push({ path: at(), message: "a tier key is lowercase letters, digits, - and _, starting with a letter, at most 64" });
285
+ const t = raw.tiers[key];
286
+ if (!isObject(t)) {
287
+ out.push({ path: at(), message: "a tier is an object" });
288
+ continue;
289
+ }
290
+ for (const k of Object.keys(t)) if (!["label", "wallet_types", "engine_features", "tenant_features"].includes(k)) out.push({ path: at(k), message: "unknown key" });
291
+ if (t.label !== undefined && (typeof t.label !== "string" || t.label.length > TIER_LIMITS.label)) out.push({ path: at("label"), message: `a label is text of at most ${TIER_LIMITS.label} characters` });
292
+ if (!Array.isArray(t.wallet_types) || t.wallet_types.some((w) => typeof w !== "string")) out.push({ path: at("wallet_types"), message: "wallet_types is a list of Wallet type keys, or \"*\"" });
293
+ if (t.engine_features !== undefined) {
294
+ if (!isObject(t.engine_features)) out.push({ path: at("engine_features"), message: "engine_features is an object" });
295
+ else
296
+ for (const [k, v] of Object.entries(t.engine_features)) {
297
+ const f = CATALOGUE.get(k);
298
+ if (!f) {
299
+ out.push({ path: at("engine_features", k), message: "not an Engine feature" });
300
+ continue;
301
+ }
302
+ if (f.kind === "toggle") {
303
+ if (typeof v !== "boolean") out.push({ path: at("engine_features", k), message: "this feature is on or off: true or false" });
304
+ continue;
305
+ }
306
+ const okNumber = typeof v === "number" && Number.isInteger(v) && v >= 0 && (f.max === undefined || v <= f.max);
307
+ if (typeof v !== "boolean" && !okNumber) out.push({ path: at("engine_features", k), message: f.max === undefined ? "true, false or a whole number" : `true, false or a whole number up to ${f.max}` });
308
+ }
309
+ }
310
+ if (t.tenant_features !== undefined) {
311
+ if (!isObject(t.tenant_features)) out.push({ path: at("tenant_features"), message: "tenant_features is an object" });
312
+ else {
313
+ const entries = Object.entries(t.tenant_features);
314
+ if (entries.length > TIER_LIMITS.tenantFeatures) out.push({ path: at("tenant_features"), message: `at most ${TIER_LIMITS.tenantFeatures} keys` });
315
+ for (const [k, v] of entries) {
316
+ if (!TENANT_FEATURE_KEY_PATTERN.test(k)) out.push({ path: at("tenant_features", k), message: "a key is lowercase letters, digits and _, starting with a letter, at most 64" });
317
+ const okValue = typeof v === "boolean" || (typeof v === "number" && Number.isInteger(v)) || (typeof v === "string" && v.length <= TIER_LIMITS.tenantFeatureString);
318
+ if (!okValue) out.push({ path: at("tenant_features", k), message: `a boolean, a whole number, or text of at most ${TIER_LIMITS.tenantFeatureString} characters` });
319
+ }
320
+ }
321
+ }
322
+ }
323
+ const rules = raw.tier_rules;
324
+ if (rules !== undefined) {
325
+ if (!isObject(rules)) out.push({ path: ["tier_rules"], message: "tier_rules is an object" });
326
+ else {
327
+ for (const k of Object.keys(rules)) if (!["triggers", "storage", "changed_at"].includes(k)) out.push({ path: ["tier_rules", k], message: "unknown key" });
328
+ const t = rules.triggers;
329
+ if (t !== undefined) {
330
+ if (!isObject(t) || !(TRIGGER_RULE_MODES as readonly unknown[]).includes(t.mode)) out.push({ path: ["tier_rules", "triggers", "mode"], message: `one of ${TRIGGER_RULE_MODES.join(", ")}` });
331
+ else if (t.grace_days !== undefined && !(Number.isInteger(t.grace_days) && (t.grace_days as number) >= 0 && (t.grace_days as number) <= TIER_LIMITS.graceDays)) out.push({ path: ["tier_rules", "triggers", "grace_days"], message: `0 to ${TIER_LIMITS.graceDays} days` });
332
+ }
333
+ const s = rules.storage;
334
+ if (s !== undefined) {
335
+ if (!isObject(s) || !(STORAGE_RULE_MODES as readonly unknown[]).includes(s.mode)) out.push({ path: ["tier_rules", "storage", "mode"], message: `one of ${STORAGE_RULE_MODES.join(", ")}` });
336
+ else if (s.grace_days !== undefined && !(Number.isInteger(s.grace_days) && (s.grace_days as number) >= TIER_LIMITS.storageGraceDaysMin && (s.grace_days as number) <= TIER_LIMITS.graceDays)) out.push({ path: ["tier_rules", "storage", "grace_days"], message: `${TIER_LIMITS.storageGraceDaysMin} to ${TIER_LIMITS.graceDays} days` });
337
+ }
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /** A value as a number to compare: no limit counts as more than any number. */
344
+ const rank = (v: FeatureValue) => (v === true ? Number.POSITIVE_INFINITY : v === false ? 0 : v);
345
+
346
+ /** Features whose value falls between two tier sets, on tiers that exist in both: what the Admin UI warns about (§12), and what starts a grace period (§6). */
347
+ export function loweredFeatures(before: Tiers, after: Tiers): { tier: string; feature: string; from: FeatureValue; to: FeatureValue }[] {
348
+ const out: { tier: string; feature: string; from: FeatureValue; to: FeatureValue }[] = [];
349
+ for (const [key, was] of Object.entries(before)) {
350
+ const now = after[key];
351
+ if (!now) continue;
352
+ for (const f of ENGINE_FEATURES) {
353
+ const from = featureValue(was, f.key);
354
+ const to = featureValue(now, f.key);
355
+ if (rank(to) < rank(from)) out.push({ tier: key, feature: f.key, from, to });
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /** One Engine feature as a Customer has it (§9). */
362
+ export interface EntitledFeature {
363
+ value: FeatureValue;
364
+ /** Present for stock and monthly limits. On a group plan, the group's total. */
365
+ used?: number;
366
+ /** On a group plan, the caller's share. */
367
+ own?: number;
368
+ }
369
+
370
+ /** `GET /v0/customer/entitlements`, and the Tenant API's read of one Customer (§9). */
371
+ export interface CustomerEntitlements {
372
+ tier: string;
373
+ label: string;
374
+ /** Wallet type keys or `"*"`. */
375
+ wallet_types: string[];
376
+ engine_features: Record<string, EntitledFeature>;
377
+ tenant_features: Record<string, TenantFeatureValue>;
378
+ /** While files are due to be removed (§6). */
379
+ storage_review?: { at: string; over_bytes: number };
380
+ }
381
+
382
+ export function entitlementsOf(set: TenantTierSet, stored: string | null | undefined, used: Record<string, { used?: number; own?: number }> = {}): CustomerEntitlements {
383
+ const key = tierKeyOf(set, stored);
384
+ const tier = tierOf(set, key);
385
+ const engine_features: Record<string, EntitledFeature> = {};
386
+ for (const f of ENGINE_FEATURES) engine_features[f.key] = { value: featureValue(tier, f.key), ...(used[f.key] ?? {}) };
387
+ return { tier: key, label: tier.label ?? key, wallet_types: tier.wallet_types, engine_features, tenant_features: tier.tenant_features ?? {} };
388
+ }
389
+
390
+ /** A feature read for showing: whether it is on, its limit, what is used and what is left. */
391
+ export function featureOf(entitlements: CustomerEntitlements, key: string): { on: boolean; limit: number | null; used: number | null; left: number | null } {
392
+ const f = entitlements.engine_features[key];
393
+ if (!f) return { on: false, limit: 0, used: null, left: null };
394
+ const limit = featureLimit(f.value);
395
+ const used = f.used ?? null;
396
+ return { on: featureIsOn(f.value), limit, used, left: limit === null || used === null ? null : Math.max(0, limit - used) };
397
+ }