@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,524 @@
|
|
|
1
|
+
import { err, isRecord, ok, type Result } from "@hraness/result";
|
|
2
|
+
import { deepFreeze } from "../immutable.js";
|
|
3
|
+
|
|
4
|
+
export const SUITE_PROFILE_NAME_MAX_LENGTH = 120;
|
|
5
|
+
export const SUITE_PROFILE_BIO_MAX_LENGTH = 1_000;
|
|
6
|
+
export const SUITE_PROFILE_URL_MAX_LENGTH = 2_048;
|
|
7
|
+
|
|
8
|
+
export const SUITE_COMMUNITY_APPLICATION_STATUSES = deepFreeze([
|
|
9
|
+
"submitted",
|
|
10
|
+
"accepted",
|
|
11
|
+
"declined",
|
|
12
|
+
"withdrawn",
|
|
13
|
+
] as const);
|
|
14
|
+
|
|
15
|
+
export type SuiteCommunityApplicationStatus =
|
|
16
|
+
(typeof SUITE_COMMUNITY_APPLICATION_STATUSES)[number];
|
|
17
|
+
|
|
18
|
+
export type SuiteProfileLinkKey =
|
|
19
|
+
| "x"
|
|
20
|
+
| "linkedin"
|
|
21
|
+
| "bluesky"
|
|
22
|
+
| "instagram"
|
|
23
|
+
| "telegram"
|
|
24
|
+
| "website";
|
|
25
|
+
|
|
26
|
+
export type SuiteProfileLinks = Readonly<
|
|
27
|
+
Record<SuiteProfileLinkKey, string | null>
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
export type SuiteProfileUpdateRequest = Readonly<{
|
|
31
|
+
bio: string;
|
|
32
|
+
expectedRevision: number;
|
|
33
|
+
links: SuiteProfileLinks;
|
|
34
|
+
name: string;
|
|
35
|
+
}>;
|
|
36
|
+
|
|
37
|
+
export type SuiteProfileView = Readonly<{
|
|
38
|
+
bio: string;
|
|
39
|
+
email: string;
|
|
40
|
+
links: SuiteProfileLinks;
|
|
41
|
+
name: string;
|
|
42
|
+
revision: number;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
export type SuiteCommunityApplicationView = Readonly<{
|
|
46
|
+
community: "oh-computer";
|
|
47
|
+
status: SuiteCommunityApplicationStatus;
|
|
48
|
+
submittedAtMs: number;
|
|
49
|
+
updatedAtMs: number;
|
|
50
|
+
}>;
|
|
51
|
+
|
|
52
|
+
export type SuiteCommunityProfileView = Readonly<{
|
|
53
|
+
application: SuiteCommunityApplicationView | null;
|
|
54
|
+
profile: SuiteProfileView;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
export type SuiteProfileIssue = Readonly<{
|
|
58
|
+
field:
|
|
59
|
+
| SuiteProfileLinkKey
|
|
60
|
+
| "application"
|
|
61
|
+
| "bio"
|
|
62
|
+
| "email"
|
|
63
|
+
| "expectedRevision"
|
|
64
|
+
| "name"
|
|
65
|
+
| "profile";
|
|
66
|
+
reason: "invalid" | "required" | "too_long";
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
const PROFILE_LINK_KEYS = [
|
|
70
|
+
"bluesky",
|
|
71
|
+
"instagram",
|
|
72
|
+
"linkedin",
|
|
73
|
+
"telegram",
|
|
74
|
+
"website",
|
|
75
|
+
"x",
|
|
76
|
+
] as const satisfies readonly SuiteProfileLinkKey[];
|
|
77
|
+
|
|
78
|
+
function exactKeys(
|
|
79
|
+
value: Record<PropertyKey, unknown>,
|
|
80
|
+
expected: readonly string[],
|
|
81
|
+
): boolean {
|
|
82
|
+
const actual = Object.keys(value).sort();
|
|
83
|
+
const sortedExpected = [...expected].sort();
|
|
84
|
+
return actual.length === sortedExpected.length
|
|
85
|
+
&& actual.every((key, index) => key === sortedExpected[index]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasInvalidSingleLineControl(value: string): boolean {
|
|
89
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
90
|
+
const code = value.charCodeAt(index);
|
|
91
|
+
if (code <= 31 || (code >= 127 && code <= 159)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasInvalidBioControl(value: string): boolean {
|
|
97
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
98
|
+
const code = value.charCodeAt(index);
|
|
99
|
+
if (
|
|
100
|
+
(code <= 31 && code !== 10)
|
|
101
|
+
|| (code >= 127 && code <= 159)
|
|
102
|
+
) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizedName(value: unknown): Result<string, SuiteProfileIssue> {
|
|
110
|
+
if (typeof value !== "string") {
|
|
111
|
+
return err({ field: "name", reason: "required" });
|
|
112
|
+
}
|
|
113
|
+
if (hasInvalidSingleLineControl(value)) {
|
|
114
|
+
return err({ field: "name", reason: "invalid" });
|
|
115
|
+
}
|
|
116
|
+
const name = value.trim().replace(/\s+/gu, " ");
|
|
117
|
+
if (name.length === 0) {
|
|
118
|
+
return err({ field: "name", reason: "required" });
|
|
119
|
+
}
|
|
120
|
+
return name.length <= SUITE_PROFILE_NAME_MAX_LENGTH
|
|
121
|
+
? ok(name)
|
|
122
|
+
: err({ field: "name", reason: "too_long" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizedProfileViewName(
|
|
126
|
+
value: unknown,
|
|
127
|
+
): Result<string, SuiteProfileIssue> {
|
|
128
|
+
if (typeof value !== "string") {
|
|
129
|
+
return err({ field: "name", reason: "invalid" });
|
|
130
|
+
}
|
|
131
|
+
if (hasInvalidSingleLineControl(value)) {
|
|
132
|
+
return err({ field: "name", reason: "invalid" });
|
|
133
|
+
}
|
|
134
|
+
const name = value.trim().replace(/\s+/gu, " ");
|
|
135
|
+
return name.length <= SUITE_PROFILE_NAME_MAX_LENGTH
|
|
136
|
+
? ok(name)
|
|
137
|
+
: err({ field: "name", reason: "too_long" });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizedBio(value: unknown): Result<string, SuiteProfileIssue> {
|
|
141
|
+
if (typeof value !== "string") {
|
|
142
|
+
return err({ field: "bio", reason: "invalid" });
|
|
143
|
+
}
|
|
144
|
+
const normalizedNewlines = value
|
|
145
|
+
.replaceAll("\r\n", "\n")
|
|
146
|
+
.replaceAll("\r", "\n");
|
|
147
|
+
if (hasInvalidBioControl(normalizedNewlines)) {
|
|
148
|
+
return err({ field: "bio", reason: "invalid" });
|
|
149
|
+
}
|
|
150
|
+
const bio = normalizedNewlines.trim();
|
|
151
|
+
return bio.length <= SUITE_PROFILE_BIO_MAX_LENGTH
|
|
152
|
+
? ok(bio)
|
|
153
|
+
: err({ field: "bio", reason: "too_long" });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parsedHttpsUrl(
|
|
157
|
+
value: string,
|
|
158
|
+
options: Readonly<{
|
|
159
|
+
allowQuery: boolean;
|
|
160
|
+
hosts?: ReadonlySet<string>;
|
|
161
|
+
}>,
|
|
162
|
+
): URL | null {
|
|
163
|
+
if (
|
|
164
|
+
value.length === 0
|
|
165
|
+
|| value.length > SUITE_PROFILE_URL_MAX_LENGTH
|
|
166
|
+
|| hasInvalidSingleLineControl(value)
|
|
167
|
+
|| value.trim() !== value
|
|
168
|
+
) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
let url: URL;
|
|
172
|
+
try {
|
|
173
|
+
url = new URL(value);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
url.protocol !== "https:"
|
|
179
|
+
|| url.username !== ""
|
|
180
|
+
|| url.password !== ""
|
|
181
|
+
|| url.hash !== ""
|
|
182
|
+
|| url.port !== ""
|
|
183
|
+
|| (!options.allowQuery && url.search !== "")
|
|
184
|
+
|| (
|
|
185
|
+
options.hosts !== undefined
|
|
186
|
+
&& !options.hosts.has(url.hostname.toLowerCase())
|
|
187
|
+
)
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return url;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function simpleHandle(
|
|
195
|
+
value: string,
|
|
196
|
+
pattern: RegExp,
|
|
197
|
+
): string | null {
|
|
198
|
+
const withoutAt = value.startsWith("@") ? value.slice(1) : value;
|
|
199
|
+
return pattern.test(withoutAt) ? withoutAt.toLowerCase() : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function exactPathSegments(url: URL): string[] | null {
|
|
203
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
204
|
+
return url.pathname === `/${segments.join("/")}`
|
|
205
|
+
|| url.pathname === `/${segments.join("/")}/`
|
|
206
|
+
? segments
|
|
207
|
+
: null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const X_HOSTS = new Set(["x.com", "www.x.com", "twitter.com", "www.twitter.com"]);
|
|
211
|
+
const INSTAGRAM_HOSTS = new Set(["instagram.com", "www.instagram.com"]);
|
|
212
|
+
const TELEGRAM_HOSTS = new Set([
|
|
213
|
+
"t.me",
|
|
214
|
+
"www.t.me",
|
|
215
|
+
"telegram.me",
|
|
216
|
+
"www.telegram.me",
|
|
217
|
+
]);
|
|
218
|
+
const BLUESKY_HOSTS = new Set(["bsky.app", "www.bsky.app"]);
|
|
219
|
+
const LINKEDIN_HOSTS = new Set(["linkedin.com", "www.linkedin.com"]);
|
|
220
|
+
|
|
221
|
+
function normalizedX(value: string): string | null {
|
|
222
|
+
const enteredHandle = simpleHandle(value, /^[A-Za-z0-9_]{1,15}$/u);
|
|
223
|
+
if (enteredHandle !== null) return `https://x.com/${enteredHandle}`;
|
|
224
|
+
const url = parsedHttpsUrl(value, { allowQuery: false, hosts: X_HOSTS });
|
|
225
|
+
const segments = url === null ? null : exactPathSegments(url);
|
|
226
|
+
const handle = segments?.length === 1
|
|
227
|
+
? simpleHandle(segments[0]!, /^[A-Za-z0-9_]{1,15}$/u)
|
|
228
|
+
: null;
|
|
229
|
+
return handle === null ? null : `https://x.com/${handle}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizedInstagram(value: string): string | null {
|
|
233
|
+
const pattern = /^(?!.*\.\.)[A-Za-z0-9](?:[A-Za-z0-9._]{0,28}[A-Za-z0-9_])?$/u;
|
|
234
|
+
const enteredHandle = simpleHandle(value, pattern);
|
|
235
|
+
if (enteredHandle !== null) {
|
|
236
|
+
return `https://www.instagram.com/${enteredHandle}`;
|
|
237
|
+
}
|
|
238
|
+
const url = parsedHttpsUrl(value, {
|
|
239
|
+
allowQuery: false,
|
|
240
|
+
hosts: INSTAGRAM_HOSTS,
|
|
241
|
+
});
|
|
242
|
+
const segments = url === null ? null : exactPathSegments(url);
|
|
243
|
+
const handle = segments?.length === 1
|
|
244
|
+
? simpleHandle(segments[0]!, pattern)
|
|
245
|
+
: null;
|
|
246
|
+
return handle === null
|
|
247
|
+
? null
|
|
248
|
+
: `https://www.instagram.com/${handle}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizedTelegram(value: string): string | null {
|
|
252
|
+
const enteredHandle = simpleHandle(
|
|
253
|
+
value,
|
|
254
|
+
/^[A-Za-z][A-Za-z0-9_]{4,31}$/u,
|
|
255
|
+
);
|
|
256
|
+
if (enteredHandle !== null) return `https://t.me/${enteredHandle}`;
|
|
257
|
+
const url = parsedHttpsUrl(value, {
|
|
258
|
+
allowQuery: false,
|
|
259
|
+
hosts: TELEGRAM_HOSTS,
|
|
260
|
+
});
|
|
261
|
+
const segments = url === null ? null : exactPathSegments(url);
|
|
262
|
+
const handle = segments?.length === 1
|
|
263
|
+
? simpleHandle(segments[0]!, /^[A-Za-z][A-Za-z0-9_]{4,31}$/u)
|
|
264
|
+
: null;
|
|
265
|
+
return handle === null ? null : `https://t.me/${handle}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validBlueskyHandle(value: string): boolean {
|
|
269
|
+
if (
|
|
270
|
+
value.length < 3
|
|
271
|
+
|| value.length > 253
|
|
272
|
+
|| !value.includes(".")
|
|
273
|
+
|| value.startsWith(".")
|
|
274
|
+
|| value.endsWith(".")
|
|
275
|
+
) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const labels = value.split(".");
|
|
279
|
+
return labels.every(label =>
|
|
280
|
+
label.length >= 1
|
|
281
|
+
&& label.length <= 63
|
|
282
|
+
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizedBluesky(value: string): string | null {
|
|
287
|
+
const withoutAt = value.startsWith("@") ? value.slice(1) : value;
|
|
288
|
+
const enteredHandle = withoutAt.toLowerCase();
|
|
289
|
+
if (validBlueskyHandle(enteredHandle)) {
|
|
290
|
+
return `https://bsky.app/profile/${enteredHandle}`;
|
|
291
|
+
}
|
|
292
|
+
const url = parsedHttpsUrl(value, {
|
|
293
|
+
allowQuery: false,
|
|
294
|
+
hosts: BLUESKY_HOSTS,
|
|
295
|
+
});
|
|
296
|
+
const segments = url === null ? null : exactPathSegments(url);
|
|
297
|
+
const handle = segments?.length === 2 && segments[0] === "profile"
|
|
298
|
+
? segments[1]!.toLowerCase()
|
|
299
|
+
: null;
|
|
300
|
+
return handle !== null && validBlueskyHandle(handle)
|
|
301
|
+
? `https://bsky.app/profile/${handle}`
|
|
302
|
+
: null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizedLinkedIn(value: string): string | null {
|
|
306
|
+
const url = parsedHttpsUrl(value, {
|
|
307
|
+
allowQuery: false,
|
|
308
|
+
hosts: LINKEDIN_HOSTS,
|
|
309
|
+
});
|
|
310
|
+
const segments = url === null ? null : exactPathSegments(url);
|
|
311
|
+
if (
|
|
312
|
+
segments?.length !== 2
|
|
313
|
+
|| segments[0] !== "in"
|
|
314
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9-]{1,99}$/u.test(segments[1]!)
|
|
315
|
+
) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
return `https://www.linkedin.com/in/${segments[1]!.toLowerCase()}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizedWebsite(value: string): string | null {
|
|
322
|
+
const url = parsedHttpsUrl(value, { allowQuery: true });
|
|
323
|
+
return url === null ? null : url.href;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function normalizeSuiteProfileLink(
|
|
327
|
+
key: SuiteProfileLinkKey,
|
|
328
|
+
value: unknown,
|
|
329
|
+
): Result<string | null, SuiteProfileIssue> {
|
|
330
|
+
if (value === null) return ok(null);
|
|
331
|
+
if (typeof value !== "string") {
|
|
332
|
+
return err({ field: key, reason: "invalid" });
|
|
333
|
+
}
|
|
334
|
+
const trimmed = value.trim();
|
|
335
|
+
if (trimmed.length === 0) return ok(null);
|
|
336
|
+
const normalized = (() => {
|
|
337
|
+
switch (key) {
|
|
338
|
+
case "x":
|
|
339
|
+
return normalizedX(trimmed);
|
|
340
|
+
case "linkedin":
|
|
341
|
+
return normalizedLinkedIn(trimmed);
|
|
342
|
+
case "bluesky":
|
|
343
|
+
return normalizedBluesky(trimmed);
|
|
344
|
+
case "instagram":
|
|
345
|
+
return normalizedInstagram(trimmed);
|
|
346
|
+
case "telegram":
|
|
347
|
+
return normalizedTelegram(trimmed);
|
|
348
|
+
case "website":
|
|
349
|
+
return normalizedWebsite(trimmed);
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
352
|
+
return normalized === null
|
|
353
|
+
? err({ field: key, reason: "invalid" })
|
|
354
|
+
: ok(normalized);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parsedLinks(
|
|
358
|
+
value: unknown,
|
|
359
|
+
canonicalOnly: boolean,
|
|
360
|
+
): Result<SuiteProfileLinks, SuiteProfileIssue> {
|
|
361
|
+
if (
|
|
362
|
+
!isRecord(value)
|
|
363
|
+
|| !exactKeys(value, PROFILE_LINK_KEYS)
|
|
364
|
+
) {
|
|
365
|
+
return err({ field: "profile", reason: "invalid" });
|
|
366
|
+
}
|
|
367
|
+
const links: Record<SuiteProfileLinkKey, string | null> = {
|
|
368
|
+
bluesky: null,
|
|
369
|
+
instagram: null,
|
|
370
|
+
linkedin: null,
|
|
371
|
+
telegram: null,
|
|
372
|
+
website: null,
|
|
373
|
+
x: null,
|
|
374
|
+
};
|
|
375
|
+
for (const key of PROFILE_LINK_KEYS) {
|
|
376
|
+
const parsed = normalizeSuiteProfileLink(key, value[key]);
|
|
377
|
+
if (!parsed.ok) return parsed;
|
|
378
|
+
if (
|
|
379
|
+
canonicalOnly
|
|
380
|
+
&& parsed.value !== value[key]
|
|
381
|
+
) {
|
|
382
|
+
return err({ field: key, reason: "invalid" });
|
|
383
|
+
}
|
|
384
|
+
links[key] = parsed.value;
|
|
385
|
+
}
|
|
386
|
+
return ok(links);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function nonnegativeInteger(value: unknown): number | null {
|
|
390
|
+
return typeof value === "number"
|
|
391
|
+
&& Number.isSafeInteger(value)
|
|
392
|
+
&& value >= 0
|
|
393
|
+
? value
|
|
394
|
+
: null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parsedEmail(value: unknown): string | null {
|
|
398
|
+
return typeof value === "string"
|
|
399
|
+
&& value.length <= 320
|
|
400
|
+
&& value.trim() === value
|
|
401
|
+
&& !hasInvalidSingleLineControl(value)
|
|
402
|
+
&& /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value)
|
|
403
|
+
? value
|
|
404
|
+
: null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function parseSuiteProfileUpdateRequest(
|
|
408
|
+
value: unknown,
|
|
409
|
+
): Result<SuiteProfileUpdateRequest, SuiteProfileIssue> {
|
|
410
|
+
if (
|
|
411
|
+
!isRecord(value)
|
|
412
|
+
|| !exactKeys(value, ["bio", "expectedRevision", "links", "name"])
|
|
413
|
+
) {
|
|
414
|
+
return err({ field: "profile", reason: "invalid" });
|
|
415
|
+
}
|
|
416
|
+
const name = normalizedName(value["name"]);
|
|
417
|
+
if (!name.ok) return name;
|
|
418
|
+
const bio = normalizedBio(value["bio"]);
|
|
419
|
+
if (!bio.ok) return bio;
|
|
420
|
+
const links = parsedLinks(value["links"], false);
|
|
421
|
+
if (!links.ok) return links;
|
|
422
|
+
const expectedRevision = nonnegativeInteger(value["expectedRevision"]);
|
|
423
|
+
if (expectedRevision === null) {
|
|
424
|
+
return err({ field: "expectedRevision", reason: "invalid" });
|
|
425
|
+
}
|
|
426
|
+
return ok({
|
|
427
|
+
bio: bio.value,
|
|
428
|
+
expectedRevision,
|
|
429
|
+
links: links.value,
|
|
430
|
+
name: name.value,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function parseSuiteProfileView(
|
|
435
|
+
value: unknown,
|
|
436
|
+
): Result<SuiteProfileView, SuiteProfileIssue> {
|
|
437
|
+
if (
|
|
438
|
+
!isRecord(value)
|
|
439
|
+
|| !exactKeys(value, ["bio", "email", "links", "name", "revision"])
|
|
440
|
+
) {
|
|
441
|
+
return err({ field: "profile", reason: "invalid" });
|
|
442
|
+
}
|
|
443
|
+
const name = normalizedProfileViewName(value["name"]);
|
|
444
|
+
const bio = normalizedBio(value["bio"]);
|
|
445
|
+
const links = parsedLinks(value["links"], true);
|
|
446
|
+
const email = parsedEmail(value["email"]);
|
|
447
|
+
const revision = nonnegativeInteger(value["revision"]);
|
|
448
|
+
if (!name.ok) return name;
|
|
449
|
+
if (!bio.ok) return bio;
|
|
450
|
+
if (!links.ok) return links;
|
|
451
|
+
if (email === null) return err({ field: "email", reason: "invalid" });
|
|
452
|
+
if (revision === null) {
|
|
453
|
+
return err({ field: "expectedRevision", reason: "invalid" });
|
|
454
|
+
}
|
|
455
|
+
if (name.value !== value["name"] || bio.value !== value["bio"]) {
|
|
456
|
+
return err({ field: "profile", reason: "invalid" });
|
|
457
|
+
}
|
|
458
|
+
return ok({
|
|
459
|
+
bio: bio.value,
|
|
460
|
+
email,
|
|
461
|
+
links: links.value,
|
|
462
|
+
name: name.value,
|
|
463
|
+
revision,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isApplicationStatus(
|
|
468
|
+
value: unknown,
|
|
469
|
+
): value is SuiteCommunityApplicationStatus {
|
|
470
|
+
return typeof value === "string"
|
|
471
|
+
&& (SUITE_COMMUNITY_APPLICATION_STATUSES as readonly string[])
|
|
472
|
+
.includes(value);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parsedApplication(
|
|
476
|
+
value: unknown,
|
|
477
|
+
): Result<SuiteCommunityApplicationView | null, SuiteProfileIssue> {
|
|
478
|
+
if (value === null) return ok(null);
|
|
479
|
+
if (
|
|
480
|
+
!isRecord(value)
|
|
481
|
+
|| !exactKeys(value, [
|
|
482
|
+
"community",
|
|
483
|
+
"status",
|
|
484
|
+
"submittedAtMs",
|
|
485
|
+
"updatedAtMs",
|
|
486
|
+
])
|
|
487
|
+
|| value["community"] !== "oh-computer"
|
|
488
|
+
|| !isApplicationStatus(value["status"])
|
|
489
|
+
) {
|
|
490
|
+
return err({ field: "application", reason: "invalid" });
|
|
491
|
+
}
|
|
492
|
+
const submittedAtMs = nonnegativeInteger(value["submittedAtMs"]);
|
|
493
|
+
const updatedAtMs = nonnegativeInteger(value["updatedAtMs"]);
|
|
494
|
+
if (
|
|
495
|
+
submittedAtMs === null
|
|
496
|
+
|| updatedAtMs === null
|
|
497
|
+
|| updatedAtMs < submittedAtMs
|
|
498
|
+
) {
|
|
499
|
+
return err({ field: "application", reason: "invalid" });
|
|
500
|
+
}
|
|
501
|
+
return ok({
|
|
502
|
+
community: "oh-computer",
|
|
503
|
+
status: value["status"],
|
|
504
|
+
submittedAtMs,
|
|
505
|
+
updatedAtMs,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function parseSuiteCommunityProfileView(
|
|
510
|
+
value: unknown,
|
|
511
|
+
): Result<SuiteCommunityProfileView, SuiteProfileIssue> {
|
|
512
|
+
if (
|
|
513
|
+
!isRecord(value)
|
|
514
|
+
|| !exactKeys(value, ["application", "profile"])
|
|
515
|
+
) {
|
|
516
|
+
return err({ field: "profile", reason: "invalid" });
|
|
517
|
+
}
|
|
518
|
+
const application = parsedApplication(value["application"]);
|
|
519
|
+
if (!application.ok) return application;
|
|
520
|
+
const profile = parseSuiteProfileView(value["profile"]);
|
|
521
|
+
return profile.ok
|
|
522
|
+
? ok({ application: application.value, profile: profile.value })
|
|
523
|
+
: profile;
|
|
524
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { err, ok, type Result } from "@hraness/result";
|
|
2
|
+
import { deepFreeze } from "../immutable.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Billing destinations that have a reviewed server-owned return path.
|
|
6
|
+
*
|
|
7
|
+
* This set is intentionally narrower than the Accounts auth-consumer registry.
|
|
8
|
+
* Adding an auth consumer must not make it a valid billing redirect target.
|
|
9
|
+
*/
|
|
10
|
+
export const SUITE_RETURN_TARGETS = deepFreeze(["accounts"] as const);
|
|
11
|
+
|
|
12
|
+
export type SuiteReturnTarget = (typeof SUITE_RETURN_TARGETS)[number];
|
|
13
|
+
|
|
14
|
+
export function parseSuiteReturnTarget(
|
|
15
|
+
value: unknown,
|
|
16
|
+
): Result<SuiteReturnTarget, "invalid-return-target"> {
|
|
17
|
+
return value === "accounts"
|
|
18
|
+
? ok(value)
|
|
19
|
+
: err("invalid-return-target");
|
|
20
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { err, ok, type Result } from "@hraness/result";
|
|
2
|
+
|
|
3
|
+
declare const suiteUsernameBrand: unique symbol;
|
|
4
|
+
|
|
5
|
+
/** A canonical, public username owned by the suite accounts service. */
|
|
6
|
+
export type SuiteUsername = string & {
|
|
7
|
+
readonly [suiteUsernameBrand]: "SuiteUsername";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const SUITE_USERNAME_MIN_LENGTH = 3;
|
|
11
|
+
export const SUITE_USERNAME_MAX_LENGTH = 24;
|
|
12
|
+
|
|
13
|
+
export type SuiteUsernameIssue =
|
|
14
|
+
| "invalid-suite-username"
|
|
15
|
+
| "suite-username-too-long"
|
|
16
|
+
| "suite-username-too-short"
|
|
17
|
+
| "suite-username-reserved";
|
|
18
|
+
|
|
19
|
+
const suiteUsernamePattern =
|
|
20
|
+
/^[a-z0-9](?:[a-z0-9]|[-_](?=[a-z0-9]))*[a-z0-9]$/u;
|
|
21
|
+
|
|
22
|
+
const reservedSuiteUsernames = new Set([
|
|
23
|
+
"account",
|
|
24
|
+
"accounts",
|
|
25
|
+
"admin",
|
|
26
|
+
"api",
|
|
27
|
+
"auth",
|
|
28
|
+
"billing",
|
|
29
|
+
"design",
|
|
30
|
+
"docs",
|
|
31
|
+
"help",
|
|
32
|
+
"hraness",
|
|
33
|
+
"login",
|
|
34
|
+
"logout",
|
|
35
|
+
"new",
|
|
36
|
+
"newsletter",
|
|
37
|
+
"party",
|
|
38
|
+
"place",
|
|
39
|
+
"preview",
|
|
40
|
+
"pub",
|
|
41
|
+
"root",
|
|
42
|
+
"settings",
|
|
43
|
+
"social-image",
|
|
44
|
+
"source",
|
|
45
|
+
"sources",
|
|
46
|
+
"support",
|
|
47
|
+
"system",
|
|
48
|
+
"user",
|
|
49
|
+
"users",
|
|
50
|
+
"www",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function validateCanonicalSuiteUsername(
|
|
54
|
+
value: string,
|
|
55
|
+
): Result<SuiteUsername, SuiteUsernameIssue> {
|
|
56
|
+
if (value.length < SUITE_USERNAME_MIN_LENGTH) {
|
|
57
|
+
return err("suite-username-too-short");
|
|
58
|
+
}
|
|
59
|
+
if (value.length > SUITE_USERNAME_MAX_LENGTH) {
|
|
60
|
+
return err("suite-username-too-long");
|
|
61
|
+
}
|
|
62
|
+
if (!suiteUsernamePattern.test(value)) {
|
|
63
|
+
return err("invalid-suite-username");
|
|
64
|
+
}
|
|
65
|
+
return ok(value as SuiteUsername);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a human-entered username before validating its canonical form.
|
|
70
|
+
*
|
|
71
|
+
* Usernames are intentionally ASCII and case-insensitive. This avoids Unicode
|
|
72
|
+
* lookalikes and gives every spelling exactly one storage and lookup key.
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeSuiteUsername(
|
|
75
|
+
value: unknown,
|
|
76
|
+
): Result<SuiteUsername, SuiteUsernameIssue> {
|
|
77
|
+
if (typeof value !== "string") return err("invalid-suite-username");
|
|
78
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
79
|
+
const code = value.charCodeAt(index);
|
|
80
|
+
if (code > 127 || code < 32 || code === 127) {
|
|
81
|
+
return err("invalid-suite-username");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
const parsed = validateCanonicalSuiteUsername(trimmed.toLowerCase());
|
|
86
|
+
if (!parsed.ok) return parsed;
|
|
87
|
+
return reservedSuiteUsernames.has(parsed.value)
|
|
88
|
+
? err("suite-username-reserved")
|
|
89
|
+
: parsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse an already-canonical value crossing a trusted service boundary. */
|
|
93
|
+
export function parseSuiteUsername(
|
|
94
|
+
value: unknown,
|
|
95
|
+
): Result<SuiteUsername, SuiteUsernameIssue> {
|
|
96
|
+
if (typeof value !== "string") return err("invalid-suite-username");
|
|
97
|
+
const parsed = validateCanonicalSuiteUsername(value);
|
|
98
|
+
return parsed.ok && parsed.value === value
|
|
99
|
+
? parsed
|
|
100
|
+
: err(parsed.ok ? "invalid-suite-username" : parsed.error);
|
|
101
|
+
}
|