@hraness/suite-accounts 0.9.20

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +496 -0
  3. package/compiler-foundation.css +1 -0
  4. package/dist/auth-client.js +16 -0
  5. package/dist/auth-proxy.js +757 -0
  6. package/dist/bearer-verifier.js +1119 -0
  7. package/dist/browser-session.js +208 -0
  8. package/dist/client-configuration.js +470 -0
  9. package/dist/convex-browser-auth-browser.js +923 -0
  10. package/dist/convex-browser-auth-server.js +846 -0
  11. package/dist/convex-browser-auth.js +597 -0
  12. package/dist/entitlements.js +502 -0
  13. package/dist/identity/functions.js +44 -0
  14. package/dist/identity/index.js +1716 -0
  15. package/dist/identity/return-targets.js +33 -0
  16. package/dist/index.js +1133 -0
  17. package/dist/oidc-device-code.js +253 -0
  18. package/dist/oidc-rp.js +2259 -0
  19. package/dist/oidc-session-policy.js +5 -0
  20. package/dist/oidc-surface-server.js +2332 -0
  21. package/dist/profile-form.js +1934 -0
  22. package/dist/profile.js +17 -0
  23. package/dist/public-config.js +462 -0
  24. package/dist/react.js +137 -0
  25. package/dist/receipt-verifier.js +619 -0
  26. package/dist/registry.js +327 -0
  27. package/dist/stylex-manifest.json +1 -0
  28. package/dist/stylex.css +435 -0
  29. package/dist/urls.js +391 -0
  30. package/package.json +263 -0
  31. package/portfolio-inventory.json +37 -0
  32. package/src/auth-client.ts +37 -0
  33. package/src/auth-proxy.ts +473 -0
  34. package/src/bearer-verifier.ts +478 -0
  35. package/src/browser-session.ts +260 -0
  36. package/src/client-configuration.ts +172 -0
  37. package/src/convex-browser-auth-browser.ts +238 -0
  38. package/src/convex-browser-auth-server.ts +492 -0
  39. package/src/convex-browser-auth.ts +208 -0
  40. package/src/convex-url.ts +74 -0
  41. package/src/entitlements.ts +317 -0
  42. package/src/identity/catalog.ts +227 -0
  43. package/src/identity/consumers.ts +53 -0
  44. package/src/identity/functions.ts +70 -0
  45. package/src/identity/identifiers.ts +82 -0
  46. package/src/identity/index.ts +10 -0
  47. package/src/identity/links.ts +280 -0
  48. package/src/identity/membership.ts +156 -0
  49. package/src/identity/principals.ts +301 -0
  50. package/src/identity/profiles-v2.ts +330 -0
  51. package/src/identity/profiles.ts +524 -0
  52. package/src/identity/return-targets.ts +20 -0
  53. package/src/identity/usernames.ts +101 -0
  54. package/src/identity/views.ts +289 -0
  55. package/src/immutable.ts +25 -0
  56. package/src/index.ts +7 -0
  57. package/src/oidc-continuation.ts +59 -0
  58. package/src/oidc-device-code.ts +401 -0
  59. package/src/oidc-rp.ts +2217 -0
  60. package/src/oidc-session-policy.ts +6 -0
  61. package/src/oidc-surface-server.ts +186 -0
  62. package/src/profile-form.css +1 -0
  63. package/src/profile-form.stylex.ts +226 -0
  64. package/src/profile-form.tsx +314 -0
  65. package/src/profile.ts +33 -0
  66. package/src/public-config.ts +294 -0
  67. package/src/public-profile-form-result.ts +82 -0
  68. package/src/public-profile-form.tsx +319 -0
  69. package/src/public-profile-links.ts +100 -0
  70. package/src/react.tsx +196 -0
  71. package/src/receipt-verifier.ts +273 -0
  72. package/src/registry.ts +600 -0
  73. package/src/urls.ts +234 -0
@@ -0,0 +1,74 @@
1
+ export type ConvexDeployment =
2
+ | Readonly<{ kind: "missing" }>
3
+ | Readonly<{
4
+ input: string;
5
+ kind: "invalid";
6
+ message: string;
7
+ reason:
8
+ | "credentials"
9
+ | "insecure-remote"
10
+ | "not-a-url"
11
+ | "not-an-origin";
12
+ }>
13
+ | Readonly<{
14
+ kind: "ready";
15
+ origin: string;
16
+ transport: "cloud" | "local";
17
+ url: string;
18
+ }>;
19
+
20
+ const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "[::1]", "localhost"]);
21
+
22
+ function invalid(
23
+ input: string,
24
+ reason: Extract<ConvexDeployment, { kind: "invalid" }>["reason"],
25
+ message: string,
26
+ ): ConvexDeployment {
27
+ return deepFreeze({ input, kind: "invalid", message, reason });
28
+ }
29
+
30
+ /** Parse untrusted public configuration into one safe Convex origin. */
31
+ export function parseConvexDeployment(value: unknown): ConvexDeployment {
32
+ if (typeof value !== "string" || value.trim() === "") {
33
+ return deepFreeze({ kind: "missing" });
34
+ }
35
+
36
+ const input = value.trim();
37
+ let parsed: URL;
38
+ try {
39
+ parsed = new URL(input);
40
+ } catch {
41
+ return invalid(input, "not-a-url", "Use a complete Convex deployment URL.");
42
+ }
43
+
44
+ if (parsed.username !== "" || parsed.password !== "") {
45
+ return invalid(
46
+ input,
47
+ "credentials",
48
+ "Deployment URLs cannot contain credentials.",
49
+ );
50
+ }
51
+ if (parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "") {
52
+ return invalid(
53
+ input,
54
+ "not-an-origin",
55
+ "Use the deployment origin without a path or query.",
56
+ );
57
+ }
58
+
59
+ const local = LOCAL_HOSTNAMES.has(parsed.hostname);
60
+ if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:")) {
61
+ return invalid(
62
+ input,
63
+ "insecure-remote",
64
+ "Remote Convex deployments must use HTTPS.",
65
+ );
66
+ }
67
+ return deepFreeze({
68
+ kind: "ready",
69
+ origin: parsed.origin,
70
+ transport: local ? "local" : "cloud",
71
+ url: parsed.origin,
72
+ });
73
+ }
74
+ import { deepFreeze } from "./immutable.js";
@@ -0,0 +1,317 @@
1
+ import {
2
+ grantedSuiteFeatureSets,
3
+ parseSuiteCatalogRevision,
4
+ parseSuiteFeatureId,
5
+ SUITE_FEATURE_IDS,
6
+ type SuiteCatalogRevision,
7
+ type SuiteFeatureId,
8
+ } from "./identity/catalog.js";
9
+ import {
10
+ parseSuiteJwtClaims,
11
+ type SuiteJwtClaims,
12
+ } from "./identity/principals.js";
13
+
14
+ export const SUITE_ENTITLEMENTS_CLAIM_VERSION =
15
+ "suite-entitlements-v1" as const;
16
+ /** Slightly exceeds the authority's canonical 24-hour refresh cadence. */
17
+ export const SUITE_ENTITLEMENT_MAX_PROVIDER_AGE_MS = 26 * 60 * 60_000;
18
+
19
+ export type SuiteEntitlementsClaim = Readonly<{
20
+ catalogRevision: SuiteCatalogRevision;
21
+ expiresAtMs: number;
22
+ features: readonly SuiteFeatureId[];
23
+ observedAtMs: number;
24
+ projectionRevision: number;
25
+ version: typeof SUITE_ENTITLEMENTS_CLAIM_VERSION;
26
+ }>;
27
+
28
+ export type VerifiedSuiteEntitlements =
29
+ | Readonly<{
30
+ claim: null;
31
+ features: readonly [];
32
+ kind: "legacy";
33
+ }>
34
+ | Readonly<{
35
+ claim: SuiteEntitlementsClaim;
36
+ features: readonly SuiteFeatureId[];
37
+ kind: "fresh";
38
+ }>
39
+ | Readonly<{
40
+ claim: SuiteEntitlementsClaim;
41
+ features: readonly [];
42
+ kind: "stale";
43
+ }>;
44
+
45
+ export type SuiteEntitlementTokenResult =
46
+ | Readonly<{
47
+ claims: SuiteJwtClaims;
48
+ entitlements: VerifiedSuiteEntitlements;
49
+ kind: "verified";
50
+ }>
51
+ | Readonly<{
52
+ kind: "invalid";
53
+ reason:
54
+ | "claims"
55
+ | "entitlements"
56
+ | "issuer"
57
+ | "audience"
58
+ | "signature"
59
+ | "time";
60
+ }>;
61
+
62
+ export type SuiteJwtCryptographicVerifier =
63
+ (compactToken: string) => Promise<unknown>;
64
+
65
+ export type SuiteEntitlementReceiptProjection = Readonly<{
66
+ catalogRevision: SuiteCatalogRevision;
67
+ expiresAtMs: number;
68
+ features: readonly SuiteFeatureId[];
69
+ observedAtMs: number;
70
+ projectionRevision: number;
71
+ receiptDigest?: string;
72
+ receiptIssuedAtMs: number;
73
+ suiteAccountId: string;
74
+ }>;
75
+
76
+ export type SuiteEntitlementReceiptProjectionDecision =
77
+ | "conflict"
78
+ | "insert"
79
+ | "replace"
80
+ | "replay";
81
+
82
+ export type VerifySuiteEntitlementTokenOptions = Readonly<{
83
+ clockSkewMs?: number;
84
+ expectedAudience: string;
85
+ expectedIssuer: string;
86
+ maxProjectionAgeMs?: number;
87
+ maxTokenLifetimeMs?: number;
88
+ nowMs?: number;
89
+ verify: SuiteJwtCryptographicVerifier;
90
+ }>;
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ function nonNegativeInteger(value: unknown): value is number {
97
+ return Number.isSafeInteger(value) && typeof value === "number" && value >= 0;
98
+ }
99
+
100
+ function canonicalFeatures(
101
+ revision: SuiteCatalogRevision,
102
+ features: readonly SuiteFeatureId[],
103
+ ): boolean {
104
+ return grantedSuiteFeatureSets(revision).some(set =>
105
+ set.length === features.length
106
+ && set.every((feature, index) => feature === features[index])
107
+ );
108
+ }
109
+
110
+ function validReceiptProjection(
111
+ projection: SuiteEntitlementReceiptProjection,
112
+ ): boolean {
113
+ const revision = parseSuiteCatalogRevision(projection.catalogRevision);
114
+ return revision.ok
115
+ && projection.suiteAccountId.length >= 1
116
+ && projection.suiteAccountId.length <= 128
117
+ && canonicalFeatures(revision.value, projection.features)
118
+ && nonNegativeInteger(projection.observedAtMs)
119
+ && nonNegativeInteger(projection.expiresAtMs)
120
+ && projection.expiresAtMs > projection.observedAtMs
121
+ && nonNegativeInteger(projection.projectionRevision)
122
+ && nonNegativeInteger(projection.receiptIssuedAtMs)
123
+ && projection.receiptIssuedAtMs < projection.expiresAtMs
124
+ && (
125
+ projection.receiptDigest === undefined
126
+ || /^[a-f0-9]{64}$/u.test(projection.receiptDigest)
127
+ );
128
+ }
129
+
130
+ function sameFeatures(
131
+ left: readonly SuiteFeatureId[],
132
+ right: readonly SuiteFeatureId[],
133
+ ): boolean {
134
+ return left.length === right.length
135
+ && left.every((feature, index) => feature === right[index]);
136
+ }
137
+
138
+ /**
139
+ * Orders signed entitlement receipts independently from the provider snapshot.
140
+ *
141
+ * A token refresh may reissue the same provider projection with a later
142
+ * token-bound expiry, or may turn an aged grant into an empty claim without a
143
+ * new provider event. The outer signed receipt issuance is therefore the
144
+ * secondary clock for one projection revision.
145
+ */
146
+ export function decideSuiteEntitlementReceiptProjection(
147
+ current: SuiteEntitlementReceiptProjection | null,
148
+ incoming: SuiteEntitlementReceiptProjection,
149
+ ): SuiteEntitlementReceiptProjectionDecision {
150
+ if (!validReceiptProjection(incoming)) return "conflict";
151
+ if (current === null) return "insert";
152
+ if (
153
+ !validReceiptProjection(current)
154
+ || current.suiteAccountId !== incoming.suiteAccountId
155
+ || incoming.projectionRevision < current.projectionRevision
156
+ || incoming.observedAtMs < current.observedAtMs
157
+ || incoming.receiptIssuedAtMs < current.receiptIssuedAtMs
158
+ ) {
159
+ return "conflict";
160
+ }
161
+ if (
162
+ incoming.projectionRevision > current.projectionRevision
163
+ || incoming.receiptIssuedAtMs > current.receiptIssuedAtMs
164
+ ) {
165
+ return "replace";
166
+ }
167
+ return (
168
+ incoming.catalogRevision === current.catalogRevision
169
+ && incoming.expiresAtMs === current.expiresAtMs
170
+ && incoming.observedAtMs === current.observedAtMs
171
+ && sameFeatures(incoming.features, current.features)
172
+ && incoming.receiptDigest === current.receiptDigest
173
+ )
174
+ ? "replay"
175
+ : "conflict";
176
+ }
177
+
178
+ function parseEntitlements(
179
+ value: unknown,
180
+ ): SuiteEntitlementsClaim | null {
181
+ if (!isRecord(value)) return null;
182
+ const catalogRevision = parseSuiteCatalogRevision(value["catalogRevision"]);
183
+ if (
184
+ value["version"] !== SUITE_ENTITLEMENTS_CLAIM_VERSION
185
+ || !catalogRevision.ok
186
+ || !nonNegativeInteger(value["observedAtMs"])
187
+ || !nonNegativeInteger(value["expiresAtMs"])
188
+ || !nonNegativeInteger(value["projectionRevision"])
189
+ || !Array.isArray(value["features"])
190
+ || value["features"].length > SUITE_FEATURE_IDS.length
191
+ ) {
192
+ return null;
193
+ }
194
+ const features: SuiteFeatureId[] = [];
195
+ for (const valueFeature of value["features"]) {
196
+ const feature = parseSuiteFeatureId(valueFeature);
197
+ if (!feature.ok || features.includes(feature.value)) return null;
198
+ features.push(feature.value);
199
+ }
200
+ if (
201
+ value["expiresAtMs"] <= value["observedAtMs"]
202
+ || !canonicalFeatures(catalogRevision.value, features)
203
+ ) return null;
204
+ return {
205
+ catalogRevision: catalogRevision.value,
206
+ expiresAtMs: value["expiresAtMs"],
207
+ features,
208
+ observedAtMs: value["observedAtMs"],
209
+ projectionRevision: value["projectionRevision"],
210
+ version: SUITE_ENTITLEMENTS_CLAIM_VERSION,
211
+ };
212
+ }
213
+
214
+ function validExpectedString(value: string): boolean {
215
+ let hasControl = false;
216
+ for (let index = 0; index < value.length; index += 1) {
217
+ const code = value.charCodeAt(index);
218
+ if (code <= 31 || code === 127) {
219
+ hasControl = true;
220
+ break;
221
+ }
222
+ }
223
+ return value.length >= 1
224
+ && value.length <= 2_048
225
+ && value.trim() === value
226
+ && !hasControl;
227
+ }
228
+
229
+ export async function verifySuiteEntitlementToken(
230
+ compactToken: string,
231
+ options: VerifySuiteEntitlementTokenOptions,
232
+ ): Promise<SuiteEntitlementTokenResult> {
233
+ if (
234
+ compactToken.length < 1
235
+ || compactToken.length > 16_384
236
+ || !validExpectedString(options.expectedAudience)
237
+ || !validExpectedString(options.expectedIssuer)
238
+ ) {
239
+ return { kind: "invalid", reason: "claims" };
240
+ }
241
+ let payload: unknown;
242
+ try {
243
+ payload = await options.verify(compactToken);
244
+ } catch {
245
+ return { kind: "invalid", reason: "signature" };
246
+ }
247
+ const parsed = parseSuiteJwtClaims(payload);
248
+ if (!parsed.ok) return { kind: "invalid", reason: "claims" };
249
+ const claims = parsed.value;
250
+ if (claims.principal.issuer !== options.expectedIssuer) {
251
+ return { kind: "invalid", reason: "issuer" };
252
+ }
253
+ if (!claims.audience.includes(options.expectedAudience)) {
254
+ return { kind: "invalid", reason: "audience" };
255
+ }
256
+
257
+ const nowMs = options.nowMs ?? Date.now();
258
+ const clockSkewMs = options.clockSkewMs ?? 30_000;
259
+ const maxTokenLifetimeMs = options.maxTokenLifetimeMs ?? 20 * 60_000;
260
+ const maxProjectionAgeMs =
261
+ options.maxProjectionAgeMs ?? SUITE_ENTITLEMENT_MAX_PROVIDER_AGE_MS;
262
+ if (
263
+ !nonNegativeInteger(nowMs)
264
+ || !nonNegativeInteger(clockSkewMs)
265
+ || !nonNegativeInteger(maxTokenLifetimeMs)
266
+ || !nonNegativeInteger(maxProjectionAgeMs)
267
+ ) {
268
+ return { kind: "invalid", reason: "time" };
269
+ }
270
+ const issuedAtMs = claims.issuedAtSeconds * 1_000;
271
+ const expiresAtMs = claims.expiresAtSeconds * 1_000;
272
+ const notBeforeMs = claims.notBeforeSeconds === undefined
273
+ ? issuedAtMs
274
+ : claims.notBeforeSeconds * 1_000;
275
+ if (
276
+ expiresAtMs - issuedAtMs > maxTokenLifetimeMs
277
+ || issuedAtMs > nowMs + clockSkewMs
278
+ || notBeforeMs > nowMs + clockSkewMs
279
+ || expiresAtMs <= nowMs - clockSkewMs
280
+ ) {
281
+ return { kind: "invalid", reason: "time" };
282
+ }
283
+ if (!isRecord(payload)) return { kind: "invalid", reason: "claims" };
284
+ const rawEntitlements = payload["suite_entitlements"];
285
+ if (rawEntitlements === undefined) {
286
+ return {
287
+ claims,
288
+ entitlements: { claim: null, features: [], kind: "legacy" },
289
+ kind: "verified",
290
+ };
291
+ }
292
+ const claim = parseEntitlements(rawEntitlements);
293
+ if (claim === null || claim.expiresAtMs > expiresAtMs) {
294
+ return { kind: "invalid", reason: "entitlements" };
295
+ }
296
+ const fresh = (
297
+ claim.observedAtMs <= nowMs + clockSkewMs
298
+ && claim.expiresAtMs > nowMs - clockSkewMs
299
+ && nowMs - claim.observedAtMs <= maxProjectionAgeMs
300
+ );
301
+ return {
302
+ claims,
303
+ entitlements: fresh
304
+ ? { claim, features: claim.features, kind: "fresh" }
305
+ : { claim, features: [], kind: "stale" },
306
+ kind: "verified",
307
+ };
308
+ }
309
+
310
+ export function suiteTokenGrantsFeature(
311
+ result: SuiteEntitlementTokenResult,
312
+ feature: SuiteFeatureId,
313
+ ): boolean {
314
+ return result.kind === "verified"
315
+ && result.entitlements.kind === "fresh"
316
+ && result.entitlements.features.includes(feature);
317
+ }
@@ -0,0 +1,227 @@
1
+ import { err, ok, type Result } from "@hraness/result";
2
+ import { deepFreeze } from "../immutable.js";
3
+
4
+ /**
5
+ * Protocol revisions accepted by identity and entitlement readers.
6
+ *
7
+ * Pricing, provider lookup keys, and customer-facing plan copy belong to the
8
+ * Accounts service. This module keeps only the finite values needed to parse
9
+ * signed protocol evidence and to fail closed on feature grants.
10
+ */
11
+ export const SUITE_CATALOG_REVISION = "hraness-suite-v4" as const;
12
+ export const PREVIOUS_SUITE_CATALOG_REVISION = "cclrte-suite-v3" as const;
13
+ export const LEGACY_SUITE_CATALOG_REVISION = "cclrte-suite-v2" as const;
14
+ export const ARCHIVED_SUITE_CATALOG_REVISION = "cclrte-suite-v1" as const;
15
+ export const SUITE_CATALOG_REVISIONS = deepFreeze([
16
+ ARCHIVED_SUITE_CATALOG_REVISION,
17
+ LEGACY_SUITE_CATALOG_REVISION,
18
+ PREVIOUS_SUITE_CATALOG_REVISION,
19
+ SUITE_CATALOG_REVISION,
20
+ ] as const);
21
+
22
+ export const SUITE_PLAN_IDS = deepFreeze(["community", "pro"] as const);
23
+ export const SUITE_HISTORICAL_PLAN_IDS = deepFreeze([
24
+ "individual",
25
+ "business",
26
+ ] as const);
27
+ export const SUITE_CURRENT_FEATURE_IDS = deepFreeze([
28
+ "suite.paid",
29
+ "suite.community",
30
+ "suite.pro",
31
+ ] as const);
32
+ export const SUITE_PREVIOUS_FEATURE_IDS = deepFreeze([
33
+ "suite.paid",
34
+ "suite.believer",
35
+ ] as const);
36
+ export const SUITE_LEGACY_FEATURE_IDS = deepFreeze([
37
+ "suite.paid",
38
+ "suite.business",
39
+ ] as const);
40
+ export const SUITE_FEATURE_IDS = deepFreeze([
41
+ "suite.paid",
42
+ "suite.community",
43
+ "suite.pro",
44
+ "suite.believer",
45
+ "suite.business",
46
+ ] as const);
47
+
48
+ export type CurrentSuiteCatalogRevision = typeof SUITE_CATALOG_REVISION;
49
+ export type PreviousSuiteCatalogRevision =
50
+ typeof PREVIOUS_SUITE_CATALOG_REVISION;
51
+ export type LegacySuiteCatalogRevision =
52
+ typeof LEGACY_SUITE_CATALOG_REVISION;
53
+ export type ArchivedSuiteCatalogRevision =
54
+ typeof ARCHIVED_SUITE_CATALOG_REVISION;
55
+ export type HistoricalSuiteCatalogRevision =
56
+ | ArchivedSuiteCatalogRevision
57
+ | LegacySuiteCatalogRevision
58
+ | PreviousSuiteCatalogRevision;
59
+ export type SuiteCatalogRevision = (typeof SUITE_CATALOG_REVISIONS)[number];
60
+
61
+ export type SuitePlanId = (typeof SUITE_PLAN_IDS)[number];
62
+ export type HistoricalSuitePlanId = (typeof SUITE_HISTORICAL_PLAN_IDS)[number];
63
+ export type AnySuitePlanId = HistoricalSuitePlanId | SuitePlanId;
64
+
65
+ export type CurrentSuiteFeatureId =
66
+ (typeof SUITE_CURRENT_FEATURE_IDS)[number];
67
+ export type PreviousSuiteFeatureId =
68
+ (typeof SUITE_PREVIOUS_FEATURE_IDS)[number];
69
+ export type LegacySuiteFeatureId =
70
+ (typeof SUITE_LEGACY_FEATURE_IDS)[number];
71
+ export type SuiteFeatureId = (typeof SUITE_FEATURE_IDS)[number];
72
+
73
+ export type SuiteCatalogIssue =
74
+ | "invalid-catalog-revision"
75
+ | "invalid-feature"
76
+ | "invalid-plan";
77
+
78
+ const CURRENT_PLAN_FEATURES = deepFreeze({
79
+ community: ["suite.paid", "suite.community"],
80
+ pro: ["suite.paid", "suite.community", "suite.pro"],
81
+ } as const satisfies Readonly<
82
+ Record<SuitePlanId, readonly CurrentSuiteFeatureId[]>
83
+ >);
84
+
85
+ const PREVIOUS_PLAN_FEATURES = deepFreeze({
86
+ business: ["suite.paid", "suite.believer"],
87
+ individual: ["suite.paid"],
88
+ } as const satisfies Readonly<
89
+ Record<HistoricalSuitePlanId, readonly PreviousSuiteFeatureId[]>
90
+ >);
91
+
92
+ const LEGACY_PLAN_FEATURES = deepFreeze({
93
+ business: ["suite.paid", "suite.business"],
94
+ individual: ["suite.paid"],
95
+ } as const satisfies Readonly<
96
+ Record<HistoricalSuitePlanId, readonly LegacySuiteFeatureId[]>
97
+ >);
98
+
99
+ export function parseSuitePlanId(
100
+ value: unknown,
101
+ ): Result<SuitePlanId, "invalid-plan">;
102
+ export function parseSuitePlanId(
103
+ value: unknown,
104
+ revision: CurrentSuiteCatalogRevision,
105
+ ): Result<SuitePlanId, "invalid-plan">;
106
+ export function parseSuitePlanId(
107
+ value: unknown,
108
+ revision: HistoricalSuiteCatalogRevision,
109
+ ): Result<HistoricalSuitePlanId, "invalid-plan">;
110
+ export function parseSuitePlanId(
111
+ value: unknown,
112
+ revision: SuiteCatalogRevision,
113
+ ): Result<AnySuitePlanId, "invalid-plan">;
114
+ export function parseSuitePlanId(
115
+ value: unknown,
116
+ revision: SuiteCatalogRevision = SUITE_CATALOG_REVISION,
117
+ ): Result<AnySuitePlanId, "invalid-plan"> {
118
+ const ids: readonly string[] = revision === SUITE_CATALOG_REVISION
119
+ ? SUITE_PLAN_IDS
120
+ : SUITE_HISTORICAL_PLAN_IDS;
121
+ return typeof value === "string" && ids.includes(value)
122
+ ? ok(value as AnySuitePlanId)
123
+ : err("invalid-plan");
124
+ }
125
+
126
+ export function parseAnySuitePlanId(
127
+ value: unknown,
128
+ ): Result<AnySuitePlanId, "invalid-plan"> {
129
+ return typeof value === "string" &&
130
+ ([...SUITE_PLAN_IDS, ...SUITE_HISTORICAL_PLAN_IDS] as readonly string[])
131
+ .includes(value)
132
+ ? ok(value as AnySuitePlanId)
133
+ : err("invalid-plan");
134
+ }
135
+
136
+ export function parseCurrentSuiteFeatureId(
137
+ value: unknown,
138
+ ): Result<CurrentSuiteFeatureId, "invalid-feature"> {
139
+ return value === "suite.paid" || value === "suite.community"
140
+ || value === "suite.pro"
141
+ ? ok(value)
142
+ : err("invalid-feature");
143
+ }
144
+
145
+ export function parseSuiteFeatureId(
146
+ value: unknown,
147
+ ): Result<SuiteFeatureId, "invalid-feature"> {
148
+ return typeof value === "string"
149
+ && (SUITE_FEATURE_IDS as readonly string[]).includes(value)
150
+ ? ok(value as SuiteFeatureId)
151
+ : err("invalid-feature");
152
+ }
153
+
154
+ export function parseSuiteCatalogRevision(
155
+ value: unknown,
156
+ ): Result<SuiteCatalogRevision, "invalid-catalog-revision"> {
157
+ return typeof value === "string"
158
+ && (SUITE_CATALOG_REVISIONS as readonly string[]).includes(value)
159
+ ? ok(value as SuiteCatalogRevision)
160
+ : err("invalid-catalog-revision");
161
+ }
162
+
163
+ /** Return the exact feature order required by a signed protocol revision. */
164
+ export function featuresForSuitePlan(
165
+ plan: SuitePlanId,
166
+ ): CurrentSuiteFeatureId[];
167
+ export function featuresForSuitePlan(
168
+ plan: SuitePlanId,
169
+ revision: CurrentSuiteCatalogRevision,
170
+ ): CurrentSuiteFeatureId[];
171
+ export function featuresForSuitePlan(
172
+ plan: HistoricalSuitePlanId,
173
+ revision: LegacySuiteCatalogRevision | PreviousSuiteCatalogRevision,
174
+ ): PreviousSuiteFeatureId[];
175
+ export function featuresForSuitePlan(
176
+ plan: HistoricalSuitePlanId,
177
+ revision: ArchivedSuiteCatalogRevision,
178
+ ): LegacySuiteFeatureId[];
179
+ export function featuresForSuitePlan(
180
+ plan: AnySuitePlanId,
181
+ revision: SuiteCatalogRevision,
182
+ ): SuiteFeatureId[];
183
+ export function featuresForSuitePlan(
184
+ plan: AnySuitePlanId,
185
+ revision: SuiteCatalogRevision = SUITE_CATALOG_REVISION,
186
+ ): SuiteFeatureId[] {
187
+ const table: Readonly<Record<string, readonly SuiteFeatureId[]>> =
188
+ revision === SUITE_CATALOG_REVISION
189
+ ? CURRENT_PLAN_FEATURES
190
+ : revision === ARCHIVED_SUITE_CATALOG_REVISION
191
+ ? LEGACY_PLAN_FEATURES
192
+ : PREVIOUS_PLAN_FEATURES;
193
+ const features = table[plan];
194
+ return features === undefined ? [] : [...features];
195
+ }
196
+
197
+ /** Return every finite feature set a revision can grant, including the empty set. */
198
+ export function grantedSuiteFeatureSets(
199
+ revision: SuiteCatalogRevision,
200
+ ): readonly (readonly SuiteFeatureId[])[] {
201
+ if (revision === SUITE_CATALOG_REVISION) {
202
+ return deepFreeze([
203
+ [],
204
+ CURRENT_PLAN_FEATURES.community,
205
+ CURRENT_PLAN_FEATURES.pro,
206
+ ] as const);
207
+ }
208
+ const table = revision === ARCHIVED_SUITE_CATALOG_REVISION
209
+ ? LEGACY_PLAN_FEATURES
210
+ : PREVIOUS_PLAN_FEATURES;
211
+ return deepFreeze([
212
+ [],
213
+ table.individual,
214
+ table.business,
215
+ ] as const);
216
+ }
217
+
218
+ /**
219
+ * Check the current entitlement protocol, not a user's authorization state.
220
+ * Authorization must consume a verified account or entitlement projection.
221
+ */
222
+ export function suitePlanIncludesFeature(
223
+ plan: SuitePlanId | null,
224
+ feature: CurrentSuiteFeatureId,
225
+ ): boolean {
226
+ return plan !== null && featuresForSuitePlan(plan).includes(feature);
227
+ }
@@ -0,0 +1,53 @@
1
+ import { err, ok, type Result } from "@hraness/result";
2
+ import { deepFreeze } from "../immutable.js";
3
+
4
+ /**
5
+ * Consumer identities accepted by the released v1 protocol.
6
+ *
7
+ * This is intentionally distinct from both `SUITE_PRODUCTS` and the client
8
+ * registration authority. Accounts is a consumer but not a product principal,
9
+ * some product identities have no interactive authentication surface, and a
10
+ * retired consumer identity may remain parseable without retaining trust.
11
+ */
12
+ export const SUITE_CONSUMER_IDS = deepFreeze([
13
+ "accounts",
14
+ "act60",
15
+ "elders",
16
+ "soundfish",
17
+ "oh-computer",
18
+ "draw-money",
19
+ "oprte",
20
+ "sponge",
21
+ ] as const);
22
+ /**
23
+ * Earlier client identities accepted only while parsing bounded historical
24
+ * evidence. Registration authority is defined separately and may reject both
25
+ * these aliases and retired canonical identities.
26
+ */
27
+ export const LEGACY_SUITE_CONSUMER_IDS = deepFreeze([
28
+ "kitchen",
29
+ ] as const);
30
+
31
+ export type SuiteConsumerId = (typeof SUITE_CONSUMER_IDS)[number];
32
+ export type LegacySuiteConsumerId =
33
+ (typeof LEGACY_SUITE_CONSUMER_IDS)[number];
34
+
35
+ export function parseSuiteConsumerId(
36
+ value: unknown,
37
+ ): Result<SuiteConsumerId, "invalid-consumer"> {
38
+ switch (value) {
39
+ case "accounts":
40
+ case "act60":
41
+ case "elders":
42
+ case "soundfish":
43
+ case "oh-computer":
44
+ case "draw-money":
45
+ case "oprte":
46
+ case "sponge":
47
+ return ok(value);
48
+ case "kitchen":
49
+ return ok("oprte");
50
+ default:
51
+ return err("invalid-consumer");
52
+ }
53
+ }