@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,289 @@
|
|
|
1
|
+
import { err, isRecord, ok, type Result } from "@hraness/result";
|
|
2
|
+
import { deepFreeze } from "../immutable.js";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
featuresForSuitePlan,
|
|
6
|
+
parseAnySuitePlanId,
|
|
7
|
+
parseSuiteCatalogRevision,
|
|
8
|
+
parseSuiteFeatureId,
|
|
9
|
+
parseSuitePlanId,
|
|
10
|
+
SUITE_CATALOG_REVISION,
|
|
11
|
+
SUITE_FEATURE_IDS,
|
|
12
|
+
type AnySuitePlanId,
|
|
13
|
+
type CurrentSuiteCatalogRevision,
|
|
14
|
+
type SuiteCatalogRevision,
|
|
15
|
+
type SuiteFeatureId,
|
|
16
|
+
} from "./catalog.js";
|
|
17
|
+
import {
|
|
18
|
+
parseSuiteAccountId,
|
|
19
|
+
parseSuiteInvoiceRef,
|
|
20
|
+
type SuiteAccountId,
|
|
21
|
+
type SuiteInvoiceRef,
|
|
22
|
+
} from "./identifiers.js";
|
|
23
|
+
import {
|
|
24
|
+
parseSuiteUsername,
|
|
25
|
+
type SuiteUsername,
|
|
26
|
+
} from "./usernames.js";
|
|
27
|
+
|
|
28
|
+
export const SUITE_SUBSCRIPTION_STATUSES = deepFreeze([
|
|
29
|
+
"incomplete",
|
|
30
|
+
"trialing",
|
|
31
|
+
"active",
|
|
32
|
+
"past_due",
|
|
33
|
+
"paused",
|
|
34
|
+
"canceled",
|
|
35
|
+
"unpaid",
|
|
36
|
+
] as const);
|
|
37
|
+
export const SUITE_INVOICE_STATUSES = deepFreeze([
|
|
38
|
+
"draft",
|
|
39
|
+
"open",
|
|
40
|
+
"paid",
|
|
41
|
+
"void",
|
|
42
|
+
"uncollectible",
|
|
43
|
+
] as const);
|
|
44
|
+
|
|
45
|
+
export type SuiteSubscriptionStatus =
|
|
46
|
+
(typeof SUITE_SUBSCRIPTION_STATUSES)[number];
|
|
47
|
+
export type SuiteInvoiceStatus = (typeof SUITE_INVOICE_STATUSES)[number];
|
|
48
|
+
|
|
49
|
+
export type SuiteSubscriptionView = {
|
|
50
|
+
readonly cancelAtPeriodEnd: boolean;
|
|
51
|
+
readonly catalogRevision: SuiteCatalogRevision;
|
|
52
|
+
readonly currentPeriodEndMs: number | null;
|
|
53
|
+
readonly plan: AnySuitePlanId;
|
|
54
|
+
readonly status: SuiteSubscriptionStatus;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type SuiteInvoiceView = {
|
|
58
|
+
readonly amountDueCents: number;
|
|
59
|
+
readonly amountPaidCents: number;
|
|
60
|
+
readonly createdAtMs: number;
|
|
61
|
+
readonly currency: "usd";
|
|
62
|
+
readonly invoiceRef: SuiteInvoiceRef | null;
|
|
63
|
+
readonly number: string | null;
|
|
64
|
+
readonly status: SuiteInvoiceStatus;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SuiteAccountView = {
|
|
68
|
+
readonly accountId: SuiteAccountId;
|
|
69
|
+
readonly catalogRevision: CurrentSuiteCatalogRevision;
|
|
70
|
+
readonly email: string;
|
|
71
|
+
readonly features: SuiteFeatureId[];
|
|
72
|
+
readonly invoices: SuiteInvoiceView[];
|
|
73
|
+
readonly name: string | null;
|
|
74
|
+
readonly plan: AnySuitePlanId | null;
|
|
75
|
+
readonly subscription: SuiteSubscriptionView | null;
|
|
76
|
+
readonly username: SuiteUsername | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type SuiteViewIssue =
|
|
80
|
+
| "invalid-account-view"
|
|
81
|
+
| "invalid-invoice-view"
|
|
82
|
+
| "invalid-subscription-view";
|
|
83
|
+
|
|
84
|
+
function containsAsciiControl(value: string): boolean {
|
|
85
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
86
|
+
const code = value.charCodeAt(index);
|
|
87
|
+
if (code <= 31 || code === 127) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseEmail(value: unknown): string | null {
|
|
93
|
+
return typeof value === "string" &&
|
|
94
|
+
value.length <= 320 &&
|
|
95
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value)
|
|
96
|
+
? value
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseOptionalName(value: unknown): string | null | undefined {
|
|
101
|
+
if (value === null) return null;
|
|
102
|
+
return typeof value === "string" &&
|
|
103
|
+
value.length >= 1 &&
|
|
104
|
+
value.length <= 160 &&
|
|
105
|
+
value.trim() === value &&
|
|
106
|
+
!containsAsciiControl(value)
|
|
107
|
+
? value
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseNonnegativeInteger(value: unknown): number | null {
|
|
112
|
+
return typeof value === "number" &&
|
|
113
|
+
Number.isSafeInteger(value) &&
|
|
114
|
+
value >= 0
|
|
115
|
+
? value
|
|
116
|
+
: null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseOptionalTimestamp(value: unknown): number | null | undefined {
|
|
120
|
+
if (value === null) return null;
|
|
121
|
+
return parseNonnegativeInteger(value) ?? undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isOneOf<const Values extends readonly string[]>(
|
|
125
|
+
values: Values,
|
|
126
|
+
value: unknown,
|
|
127
|
+
): value is Values[number] {
|
|
128
|
+
return typeof value === "string" &&
|
|
129
|
+
(values as readonly string[]).includes(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function parseSuiteSubscriptionView(
|
|
133
|
+
value: unknown,
|
|
134
|
+
): Result<SuiteSubscriptionView, "invalid-subscription-view"> {
|
|
135
|
+
if (!isRecord(value)) return err("invalid-subscription-view");
|
|
136
|
+
const catalogRevision = parseSuiteCatalogRevision(value["catalogRevision"]);
|
|
137
|
+
const plan = catalogRevision.ok
|
|
138
|
+
? parseSuitePlanId(value["plan"], catalogRevision.value)
|
|
139
|
+
: catalogRevision;
|
|
140
|
+
const currentPeriodEndMs = parseOptionalTimestamp(value["currentPeriodEndMs"]);
|
|
141
|
+
if (
|
|
142
|
+
!plan.ok ||
|
|
143
|
+
!catalogRevision.ok ||
|
|
144
|
+
!isOneOf(SUITE_SUBSCRIPTION_STATUSES, value["status"]) ||
|
|
145
|
+
typeof value["cancelAtPeriodEnd"] !== "boolean" ||
|
|
146
|
+
currentPeriodEndMs === undefined
|
|
147
|
+
) {
|
|
148
|
+
return err("invalid-subscription-view");
|
|
149
|
+
}
|
|
150
|
+
return ok({
|
|
151
|
+
cancelAtPeriodEnd: value["cancelAtPeriodEnd"],
|
|
152
|
+
catalogRevision: catalogRevision.value,
|
|
153
|
+
currentPeriodEndMs,
|
|
154
|
+
plan: plan.value,
|
|
155
|
+
status: value["status"],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseSuiteInvoiceView(
|
|
160
|
+
value: unknown,
|
|
161
|
+
): Result<SuiteInvoiceView, "invalid-invoice-view"> {
|
|
162
|
+
if (
|
|
163
|
+
!isRecord(value) ||
|
|
164
|
+
!isOneOf(SUITE_INVOICE_STATUSES, value["status"]) ||
|
|
165
|
+
value["currency"] !== "usd"
|
|
166
|
+
) {
|
|
167
|
+
return err("invalid-invoice-view");
|
|
168
|
+
}
|
|
169
|
+
const amountDueCents = parseNonnegativeInteger(value["amountDueCents"]);
|
|
170
|
+
const amountPaidCents = parseNonnegativeInteger(value["amountPaidCents"]);
|
|
171
|
+
const createdAtMs = parseNonnegativeInteger(value["createdAtMs"]);
|
|
172
|
+
const number =
|
|
173
|
+
value["number"] === null
|
|
174
|
+
? null
|
|
175
|
+
: typeof value["number"] === "string" &&
|
|
176
|
+
value["number"].length >= 1 &&
|
|
177
|
+
value["number"].length <= 80 &&
|
|
178
|
+
value["number"].trim() === value["number"]
|
|
179
|
+
? value["number"]
|
|
180
|
+
: undefined;
|
|
181
|
+
const invoiceRef =
|
|
182
|
+
value["invoiceRef"] === null
|
|
183
|
+
? ok(null)
|
|
184
|
+
: parseSuiteInvoiceRef(value["invoiceRef"]);
|
|
185
|
+
if (
|
|
186
|
+
amountDueCents === null ||
|
|
187
|
+
amountPaidCents === null ||
|
|
188
|
+
createdAtMs === null ||
|
|
189
|
+
number === undefined ||
|
|
190
|
+
!invoiceRef.ok
|
|
191
|
+
) {
|
|
192
|
+
return err("invalid-invoice-view");
|
|
193
|
+
}
|
|
194
|
+
return ok({
|
|
195
|
+
amountDueCents,
|
|
196
|
+
amountPaidCents,
|
|
197
|
+
createdAtMs,
|
|
198
|
+
currency: "usd",
|
|
199
|
+
invoiceRef: invoiceRef.value,
|
|
200
|
+
number,
|
|
201
|
+
status: value["status"],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseFeatures(value: unknown): SuiteFeatureId[] | null {
|
|
206
|
+
if (!Array.isArray(value) || value.length > SUITE_FEATURE_IDS.length) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const parsed: SuiteFeatureId[] = [];
|
|
210
|
+
for (const entry of value) {
|
|
211
|
+
const feature = parseSuiteFeatureId(entry);
|
|
212
|
+
if (!feature.ok || parsed.includes(feature.value)) return null;
|
|
213
|
+
parsed.push(feature.value);
|
|
214
|
+
}
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function parseSuiteAccountView(
|
|
219
|
+
value: unknown,
|
|
220
|
+
): Result<SuiteAccountView, "invalid-account-view"> {
|
|
221
|
+
if (!isRecord(value)) return err("invalid-account-view");
|
|
222
|
+
const accountId = parseSuiteAccountId(value["accountId"]);
|
|
223
|
+
const email = parseEmail(value["email"]);
|
|
224
|
+
const name = parseOptionalName(value["name"]);
|
|
225
|
+
const username = value["username"] === null || value["username"] === undefined
|
|
226
|
+
? ok(null)
|
|
227
|
+
: parseSuiteUsername(value["username"]);
|
|
228
|
+
const subscription =
|
|
229
|
+
value["subscription"] === null
|
|
230
|
+
? ok(null)
|
|
231
|
+
: parseSuiteSubscriptionView(value["subscription"]);
|
|
232
|
+
const plan =
|
|
233
|
+
value["plan"] === null ? ok(null) : parseAnySuitePlanId(value["plan"]);
|
|
234
|
+
const features = parseFeatures(value["features"]);
|
|
235
|
+
if (
|
|
236
|
+
!accountId.ok ||
|
|
237
|
+
value["catalogRevision"] !== SUITE_CATALOG_REVISION ||
|
|
238
|
+
email === null ||
|
|
239
|
+
name === undefined ||
|
|
240
|
+
!username.ok ||
|
|
241
|
+
!subscription.ok ||
|
|
242
|
+
!plan.ok ||
|
|
243
|
+
features === null ||
|
|
244
|
+
!Array.isArray(value["invoices"]) ||
|
|
245
|
+
value["invoices"].length > 100 ||
|
|
246
|
+
plan.value !== (subscription.value?.plan ?? null)
|
|
247
|
+
) {
|
|
248
|
+
return err("invalid-account-view");
|
|
249
|
+
}
|
|
250
|
+
const statusCanGrant =
|
|
251
|
+
subscription.value !== null &&
|
|
252
|
+
(subscription.value.status === "active" ||
|
|
253
|
+
subscription.value.status === "trialing");
|
|
254
|
+
const planFeatures =
|
|
255
|
+
subscription.value === null
|
|
256
|
+
? []
|
|
257
|
+
: featuresForSuitePlan(
|
|
258
|
+
subscription.value.plan,
|
|
259
|
+
subscription.value.catalogRevision,
|
|
260
|
+
);
|
|
261
|
+
const exactPositiveGrant =
|
|
262
|
+
statusCanGrant &&
|
|
263
|
+
features.length === planFeatures.length &&
|
|
264
|
+
features.every((feature, index) => feature === planFeatures[index]);
|
|
265
|
+
// A backend may remove an otherwise eligible grant at an additional
|
|
266
|
+
// fail-closed boundary such as current-period expiry. Positive grants still
|
|
267
|
+
// have to match the checked catalog exactly; only the empty projection is a
|
|
268
|
+
// valid conservative subset.
|
|
269
|
+
if (features.length > 0 && !exactPositiveGrant) {
|
|
270
|
+
return err("invalid-account-view");
|
|
271
|
+
}
|
|
272
|
+
const invoices: SuiteInvoiceView[] = [];
|
|
273
|
+
for (const entry of value["invoices"]) {
|
|
274
|
+
const invoice = parseSuiteInvoiceView(entry);
|
|
275
|
+
if (!invoice.ok) return err("invalid-account-view");
|
|
276
|
+
invoices.push(invoice.value);
|
|
277
|
+
}
|
|
278
|
+
return ok({
|
|
279
|
+
accountId: accountId.value,
|
|
280
|
+
catalogRevision: SUITE_CATALOG_REVISION,
|
|
281
|
+
email,
|
|
282
|
+
features,
|
|
283
|
+
invoices,
|
|
284
|
+
name,
|
|
285
|
+
plan: plan.value,
|
|
286
|
+
subscription: subscription.value,
|
|
287
|
+
username: username.value,
|
|
288
|
+
});
|
|
289
|
+
}
|
package/src/immutable.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freeze an owned JSON-like value recursively before it crosses a public
|
|
3
|
+
* security boundary. This helper intentionally does not traverse functions or
|
|
4
|
+
* collection classes. Registry and configuration values use only plain
|
|
5
|
+
* records and arrays.
|
|
6
|
+
*/
|
|
7
|
+
export function deepFreeze<const Value>(value: Value): Value {
|
|
8
|
+
const visited = new WeakSet<object>();
|
|
9
|
+
|
|
10
|
+
function freezeOwned(current: unknown): void {
|
|
11
|
+
if (current === null || typeof current !== "object") return;
|
|
12
|
+
if (visited.has(current)) return;
|
|
13
|
+
visited.add(current);
|
|
14
|
+
for (const key of Reflect.ownKeys(current)) {
|
|
15
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
16
|
+
if (descriptor !== undefined && "value" in descriptor) {
|
|
17
|
+
freezeOwned(descriptor.value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
Object.freeze(current);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
freezeOwned(value);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./client-configuration.js";
|
|
2
|
+
export * from "./convex-browser-auth.js";
|
|
3
|
+
export * from "./oidc-device-code.js";
|
|
4
|
+
export * from "./oidc-session-policy.js";
|
|
5
|
+
export * from "./public-config.js";
|
|
6
|
+
export * from "./registry.js";
|
|
7
|
+
export * from "./urls.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function htmlAttribute(value: string): string {
|
|
2
|
+
return value
|
|
3
|
+
.replaceAll("&", "&")
|
|
4
|
+
.replaceAll('"', """)
|
|
5
|
+
.replaceAll("'", "'")
|
|
6
|
+
.replaceAll("<", "<")
|
|
7
|
+
.replaceAll(">", ">");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function inlineJson(value: string): string {
|
|
11
|
+
return JSON.stringify(value)
|
|
12
|
+
.replaceAll("&", "\\u0026")
|
|
13
|
+
.replaceAll("<", "\\u003c")
|
|
14
|
+
.replaceAll(">", "\\u003e")
|
|
15
|
+
.replaceAll("\u2028", "\\u2028")
|
|
16
|
+
.replaceAll("\u2029", "\\u2029");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const SUITE_OIDC_JUST_SIGNED_IN_STORAGE_KEY =
|
|
20
|
+
"hraness:suite:signed-in:v1" as const;
|
|
21
|
+
|
|
22
|
+
export function createOidcContinuationResponse(
|
|
23
|
+
returnTo: string,
|
|
24
|
+
nonce: string,
|
|
25
|
+
cookies: readonly string[],
|
|
26
|
+
): Response {
|
|
27
|
+
const headers = new Headers({
|
|
28
|
+
"cache-control": "no-store",
|
|
29
|
+
"content-security-policy": [
|
|
30
|
+
"default-src 'none'",
|
|
31
|
+
"base-uri 'none'",
|
|
32
|
+
"form-action 'none'",
|
|
33
|
+
"frame-ancestors 'none'",
|
|
34
|
+
`script-src 'nonce-${nonce}'`,
|
|
35
|
+
].join("; "),
|
|
36
|
+
"content-type": "text/html; charset=utf-8",
|
|
37
|
+
pragma: "no-cache",
|
|
38
|
+
"referrer-policy": "no-referrer",
|
|
39
|
+
"x-content-type-options": "nosniff",
|
|
40
|
+
"x-frame-options": "DENY",
|
|
41
|
+
});
|
|
42
|
+
for (const cookie of cookies) headers.append("set-cookie", cookie);
|
|
43
|
+
const body = [
|
|
44
|
+
"<!doctype html>",
|
|
45
|
+
'<html lang="en">',
|
|
46
|
+
"<head>",
|
|
47
|
+
'<meta charset="utf-8">',
|
|
48
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
49
|
+
"<title>Signed in</title>",
|
|
50
|
+
"</head>",
|
|
51
|
+
"<body>",
|
|
52
|
+
"<p>Signed in.</p>",
|
|
53
|
+
`<p><a href="${htmlAttribute(returnTo)}">Continue</a></p>`,
|
|
54
|
+
`<script nonce="${nonce}">try{sessionStorage.setItem(${inlineJson(SUITE_OIDC_JUST_SIGNED_IN_STORAGE_KEY)},"1")}catch{}location.replace(${inlineJson(returnTo)});</script>`,
|
|
55
|
+
"</body>",
|
|
56
|
+
"</html>",
|
|
57
|
+
].join("");
|
|
58
|
+
return new Response(body, { headers, status: 200 });
|
|
59
|
+
}
|