@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.
- package/LICENSE +21 -0
- package/README.md +496 -0
- package/compiler-foundation.css +1 -0
- package/dist/auth-client.js +16 -0
- package/dist/auth-proxy.js +757 -0
- package/dist/bearer-verifier.js +1119 -0
- package/dist/browser-session.js +208 -0
- package/dist/client-configuration.js +470 -0
- package/dist/convex-browser-auth-browser.js +923 -0
- package/dist/convex-browser-auth-server.js +846 -0
- package/dist/convex-browser-auth.js +597 -0
- package/dist/entitlements.js +502 -0
- package/dist/identity/functions.js +44 -0
- package/dist/identity/index.js +1716 -0
- package/dist/identity/return-targets.js +33 -0
- package/dist/index.js +1133 -0
- package/dist/oidc-device-code.js +253 -0
- package/dist/oidc-rp.js +2259 -0
- package/dist/oidc-session-policy.js +5 -0
- package/dist/oidc-surface-server.js +2332 -0
- package/dist/profile-form.js +1934 -0
- package/dist/profile.js +17 -0
- package/dist/public-config.js +462 -0
- package/dist/react.js +137 -0
- package/dist/receipt-verifier.js +619 -0
- package/dist/registry.js +327 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +435 -0
- package/dist/urls.js +391 -0
- package/package.json +263 -0
- package/portfolio-inventory.json +37 -0
- package/src/auth-client.ts +37 -0
- package/src/auth-proxy.ts +473 -0
- package/src/bearer-verifier.ts +478 -0
- package/src/browser-session.ts +260 -0
- package/src/client-configuration.ts +172 -0
- package/src/convex-browser-auth-browser.ts +238 -0
- package/src/convex-browser-auth-server.ts +492 -0
- package/src/convex-browser-auth.ts +208 -0
- package/src/convex-url.ts +74 -0
- package/src/entitlements.ts +317 -0
- package/src/identity/catalog.ts +227 -0
- package/src/identity/consumers.ts +53 -0
- package/src/identity/functions.ts +70 -0
- package/src/identity/identifiers.ts +82 -0
- package/src/identity/index.ts +10 -0
- package/src/identity/links.ts +280 -0
- package/src/identity/membership.ts +156 -0
- package/src/identity/principals.ts +301 -0
- package/src/identity/profiles-v2.ts +330 -0
- package/src/identity/profiles.ts +524 -0
- package/src/identity/return-targets.ts +20 -0
- package/src/identity/usernames.ts +101 -0
- package/src/identity/views.ts +289 -0
- package/src/immutable.ts +25 -0
- package/src/index.ts +7 -0
- package/src/oidc-continuation.ts +59 -0
- package/src/oidc-device-code.ts +401 -0
- package/src/oidc-rp.ts +2217 -0
- package/src/oidc-session-policy.ts +6 -0
- package/src/oidc-surface-server.ts +186 -0
- package/src/profile-form.css +1 -0
- package/src/profile-form.stylex.ts +226 -0
- package/src/profile-form.tsx +314 -0
- package/src/profile.ts +33 -0
- package/src/public-config.ts +294 -0
- package/src/public-profile-form-result.ts +82 -0
- package/src/public-profile-form.tsx +319 -0
- package/src/public-profile-links.ts +100 -0
- package/src/react.tsx +196 -0
- package/src/receipt-verifier.ts +273 -0
- package/src/registry.ts +600 -0
- 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
|
+
}
|