@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,301 @@
|
|
|
1
|
+
import { err, isRecord, ok, type Result } from "@hraness/result";
|
|
2
|
+
import { deepFreeze } from "../immutable.js";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
parseSuiteAccountId,
|
|
6
|
+
type SuiteAccountId,
|
|
7
|
+
} from "./identifiers.js";
|
|
8
|
+
import {
|
|
9
|
+
parseSuiteUsername,
|
|
10
|
+
type SuiteUsername,
|
|
11
|
+
} from "./usernames.js";
|
|
12
|
+
|
|
13
|
+
export const SUITE_PRODUCTS = deepFreeze([
|
|
14
|
+
"soundfish",
|
|
15
|
+
"hra",
|
|
16
|
+
"peopleblade",
|
|
17
|
+
"ghostget",
|
|
18
|
+
// Compatibility-only readers for retired publication principal evidence.
|
|
19
|
+
"crclte",
|
|
20
|
+
"pub",
|
|
21
|
+
] as const);
|
|
22
|
+
export const LEGACY_SUITE_PRODUCT_IDS = deepFreeze([
|
|
23
|
+
"oprte",
|
|
24
|
+
"kitchen",
|
|
25
|
+
] as const);
|
|
26
|
+
export const SUITE_ENVIRONMENTS = deepFreeze([
|
|
27
|
+
"development",
|
|
28
|
+
"staging",
|
|
29
|
+
"production",
|
|
30
|
+
] as const);
|
|
31
|
+
export const SUITE_ISSUABLE_ENVIRONMENTS = deepFreeze([
|
|
32
|
+
"development",
|
|
33
|
+
"production",
|
|
34
|
+
] as const);
|
|
35
|
+
|
|
36
|
+
export type SuiteProduct = (typeof SUITE_PRODUCTS)[number];
|
|
37
|
+
export type LegacySuiteProductId =
|
|
38
|
+
(typeof LEGACY_SUITE_PRODUCT_IDS)[number];
|
|
39
|
+
export type SuiteEnvironment = (typeof SUITE_ENVIRONMENTS)[number];
|
|
40
|
+
export type SuiteIssuableEnvironment =
|
|
41
|
+
(typeof SUITE_ISSUABLE_ENVIRONMENTS)[number];
|
|
42
|
+
|
|
43
|
+
declare const identityIssuerBrand: unique symbol;
|
|
44
|
+
declare const identitySubjectBrand: unique symbol;
|
|
45
|
+
|
|
46
|
+
export type IdentityIssuer = string & {
|
|
47
|
+
readonly [identityIssuerBrand]: "IdentityIssuer";
|
|
48
|
+
};
|
|
49
|
+
export type IdentitySubject = string & {
|
|
50
|
+
readonly [identitySubjectBrand]: "IdentitySubject";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type IssuerSubject = {
|
|
54
|
+
readonly issuer: IdentityIssuer;
|
|
55
|
+
readonly subject: IdentitySubject;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type LegacyPrincipalLink = {
|
|
59
|
+
readonly environment: SuiteEnvironment;
|
|
60
|
+
readonly legacySubject: IdentitySubject;
|
|
61
|
+
readonly product: SuiteProduct;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type IdentityIssue =
|
|
65
|
+
| "invalid-audience"
|
|
66
|
+
| "invalid-environment"
|
|
67
|
+
| "invalid-issuer"
|
|
68
|
+
| "invalid-jwt-claims"
|
|
69
|
+
| "invalid-legacy-link"
|
|
70
|
+
| "invalid-product"
|
|
71
|
+
| "invalid-subject"
|
|
72
|
+
| "invalid-suite-account-id";
|
|
73
|
+
|
|
74
|
+
const localIssuerHosts = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
75
|
+
|
|
76
|
+
function containsAsciiControl(value: string): boolean {
|
|
77
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
78
|
+
const code = value.charCodeAt(index);
|
|
79
|
+
if (code <= 31 || code === 127) return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseIdentityIssuer(
|
|
85
|
+
value: unknown,
|
|
86
|
+
): Result<IdentityIssuer, "invalid-issuer"> {
|
|
87
|
+
if (typeof value !== "string" || value.length > 2_048 || value.trim() !== value) {
|
|
88
|
+
return err("invalid-issuer");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let parsed: URL;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(value);
|
|
94
|
+
} catch {
|
|
95
|
+
return err("invalid-issuer");
|
|
96
|
+
}
|
|
97
|
+
const local = localIssuerHosts.has(parsed.hostname);
|
|
98
|
+
if (
|
|
99
|
+
(parsed.protocol !== "https:" && !(local && parsed.protocol === "http:")) ||
|
|
100
|
+
parsed.username !== "" ||
|
|
101
|
+
parsed.password !== "" ||
|
|
102
|
+
parsed.pathname !== "/" ||
|
|
103
|
+
parsed.search !== "" ||
|
|
104
|
+
parsed.hash !== ""
|
|
105
|
+
) {
|
|
106
|
+
return err("invalid-issuer");
|
|
107
|
+
}
|
|
108
|
+
return ok(parsed.origin as IdentityIssuer);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseIdentitySubject(
|
|
112
|
+
value: unknown,
|
|
113
|
+
): Result<IdentitySubject, "invalid-subject"> {
|
|
114
|
+
if (
|
|
115
|
+
typeof value !== "string" ||
|
|
116
|
+
value.length < 1 ||
|
|
117
|
+
value.length > 255 ||
|
|
118
|
+
value.trim() !== value ||
|
|
119
|
+
containsAsciiControl(value)
|
|
120
|
+
) {
|
|
121
|
+
return err("invalid-subject");
|
|
122
|
+
}
|
|
123
|
+
return ok(value as IdentitySubject);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parseIssuerSubject(
|
|
127
|
+
value: unknown,
|
|
128
|
+
): Result<IssuerSubject, "invalid-issuer" | "invalid-subject"> {
|
|
129
|
+
if (!isRecord(value)) return err("invalid-subject");
|
|
130
|
+
const issuer = parseIdentityIssuer(value["issuer"]);
|
|
131
|
+
if (!issuer.ok) return issuer;
|
|
132
|
+
const subject = parseIdentitySubject(value["subject"]);
|
|
133
|
+
return subject.ok
|
|
134
|
+
? ok({ issuer: issuer.value, subject: subject.value })
|
|
135
|
+
: subject;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function parseSuiteProduct(
|
|
139
|
+
value: unknown,
|
|
140
|
+
): Result<SuiteProduct, "invalid-product"> {
|
|
141
|
+
switch (value) {
|
|
142
|
+
case "soundfish":
|
|
143
|
+
case "hra":
|
|
144
|
+
case "peopleblade":
|
|
145
|
+
case "ghostget":
|
|
146
|
+
case "crclte":
|
|
147
|
+
case "pub":
|
|
148
|
+
return ok(value);
|
|
149
|
+
// OPRTE and Kitchen are predecessor product IDs. Parse them only at
|
|
150
|
+
// foreign or stored boundaries and immediately return the Oompa product
|
|
151
|
+
// ID `hra` so new state cannot perpetuate either predecessor identity.
|
|
152
|
+
case "oprte":
|
|
153
|
+
case "kitchen":
|
|
154
|
+
return ok("hra");
|
|
155
|
+
default:
|
|
156
|
+
return err("invalid-product");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseSuiteEnvironment(
|
|
161
|
+
value: unknown,
|
|
162
|
+
): Result<SuiteEnvironment, "invalid-environment"> {
|
|
163
|
+
return typeof value === "string" &&
|
|
164
|
+
(SUITE_ENVIRONMENTS as readonly string[]).includes(value)
|
|
165
|
+
? ok(value as SuiteEnvironment)
|
|
166
|
+
: err("invalid-environment");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Staging remains parseable only for legacy stored and signed evidence.
|
|
171
|
+
* New link, receipt, and identity authority is limited to local development
|
|
172
|
+
* and the single production deployment.
|
|
173
|
+
*/
|
|
174
|
+
export function isSuiteIssuableEnvironment(
|
|
175
|
+
value: SuiteEnvironment,
|
|
176
|
+
): value is SuiteIssuableEnvironment {
|
|
177
|
+
return value === "development" || value === "production";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseLegacyPrincipalLink(
|
|
181
|
+
value: unknown,
|
|
182
|
+
): Result<LegacyPrincipalLink, IdentityIssue> {
|
|
183
|
+
if (!isRecord(value)) return err("invalid-legacy-link");
|
|
184
|
+
const product = parseSuiteProduct(value["product"]);
|
|
185
|
+
if (!product.ok) return product;
|
|
186
|
+
const environment = parseSuiteEnvironment(value["environment"]);
|
|
187
|
+
if (!environment.ok) return environment;
|
|
188
|
+
const legacySubject = parseIdentitySubject(value["legacySubject"]);
|
|
189
|
+
if (!legacySubject.ok) return legacySubject;
|
|
190
|
+
return ok({
|
|
191
|
+
environment: environment.value,
|
|
192
|
+
legacySubject: legacySubject.value,
|
|
193
|
+
product: product.value,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type SuiteJwtClaims = {
|
|
198
|
+
readonly audience: readonly string[];
|
|
199
|
+
readonly expiresAtSeconds: number;
|
|
200
|
+
readonly issuedAtSeconds: number;
|
|
201
|
+
readonly notBeforeSeconds?: number;
|
|
202
|
+
readonly principal: IssuerSubject;
|
|
203
|
+
readonly profileComplete: boolean;
|
|
204
|
+
readonly profileRevision: "username-v1" | null;
|
|
205
|
+
readonly suiteAccountId: SuiteAccountId;
|
|
206
|
+
readonly username: SuiteUsername | null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function parseAudience(value: unknown): Result<readonly string[], "invalid-audience"> {
|
|
210
|
+
const values = typeof value === "string" ? [value] : value;
|
|
211
|
+
if (
|
|
212
|
+
!Array.isArray(values) ||
|
|
213
|
+
values.length < 1 ||
|
|
214
|
+
values.length > 8 ||
|
|
215
|
+
values.some(
|
|
216
|
+
(entry) =>
|
|
217
|
+
typeof entry !== "string" ||
|
|
218
|
+
entry.length < 1 ||
|
|
219
|
+
entry.length > 255 ||
|
|
220
|
+
entry.trim() !== entry ||
|
|
221
|
+
containsAsciiControl(entry),
|
|
222
|
+
) ||
|
|
223
|
+
new Set(values).size !== values.length
|
|
224
|
+
) {
|
|
225
|
+
return err("invalid-audience");
|
|
226
|
+
}
|
|
227
|
+
return ok(values as readonly string[]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseTimestamp(value: unknown): number | null {
|
|
231
|
+
return typeof value === "number" &&
|
|
232
|
+
Number.isSafeInteger(value) &&
|
|
233
|
+
value >= 0
|
|
234
|
+
? value
|
|
235
|
+
: null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse a decoded JWT payload from `unknown`.
|
|
240
|
+
*
|
|
241
|
+
* This function does not verify a signature, issuer trust, audience ownership, or
|
|
242
|
+
* current time. Authorization must perform those checks before using the result.
|
|
243
|
+
*/
|
|
244
|
+
export function parseSuiteJwtClaims(
|
|
245
|
+
value: unknown,
|
|
246
|
+
): Result<SuiteJwtClaims, IdentityIssue> {
|
|
247
|
+
if (!isRecord(value)) return err("invalid-jwt-claims");
|
|
248
|
+
const principal = parseIssuerSubject({
|
|
249
|
+
issuer: value["iss"],
|
|
250
|
+
subject: value["sub"],
|
|
251
|
+
});
|
|
252
|
+
if (!principal.ok) return principal;
|
|
253
|
+
const suiteAccountId = parseSuiteAccountId(value["suite_account_id"]);
|
|
254
|
+
if (!suiteAccountId.ok) return suiteAccountId;
|
|
255
|
+
const audience = parseAudience(value["aud"]);
|
|
256
|
+
if (!audience.ok) return audience;
|
|
257
|
+
const issuedAtSeconds = parseTimestamp(value["iat"]);
|
|
258
|
+
const expiresAtSeconds = parseTimestamp(value["exp"]);
|
|
259
|
+
const notBeforeSeconds =
|
|
260
|
+
value["nbf"] === undefined ? undefined : parseTimestamp(value["nbf"]);
|
|
261
|
+
const legacyProfile = value["profile_revision"] === undefined
|
|
262
|
+
&& value["profile_complete"] === undefined
|
|
263
|
+
&& value["username"] === undefined;
|
|
264
|
+
const profileRevision = legacyProfile
|
|
265
|
+
? null
|
|
266
|
+
: value["profile_revision"] === "username-v1"
|
|
267
|
+
? "username-v1" as const
|
|
268
|
+
: undefined;
|
|
269
|
+
const profileComplete = legacyProfile
|
|
270
|
+
? false
|
|
271
|
+
: typeof value["profile_complete"] === "boolean"
|
|
272
|
+
? value["profile_complete"]
|
|
273
|
+
: undefined;
|
|
274
|
+
const username = legacyProfile || value["username"] === null
|
|
275
|
+
? null
|
|
276
|
+
: parseSuiteUsername(value["username"]);
|
|
277
|
+
if (
|
|
278
|
+
issuedAtSeconds === null ||
|
|
279
|
+
expiresAtSeconds === null ||
|
|
280
|
+
expiresAtSeconds <= issuedAtSeconds ||
|
|
281
|
+
notBeforeSeconds === null ||
|
|
282
|
+
(notBeforeSeconds !== undefined && notBeforeSeconds > expiresAtSeconds) ||
|
|
283
|
+
profileRevision === undefined ||
|
|
284
|
+
profileComplete === undefined ||
|
|
285
|
+
(username !== null && !username.ok) ||
|
|
286
|
+
profileComplete !== (username !== null)
|
|
287
|
+
) {
|
|
288
|
+
return err("invalid-jwt-claims");
|
|
289
|
+
}
|
|
290
|
+
return ok({
|
|
291
|
+
audience: audience.value,
|
|
292
|
+
expiresAtSeconds,
|
|
293
|
+
issuedAtSeconds,
|
|
294
|
+
...(notBeforeSeconds === undefined ? {} : { notBeforeSeconds }),
|
|
295
|
+
principal: principal.value,
|
|
296
|
+
profileComplete,
|
|
297
|
+
profileRevision,
|
|
298
|
+
suiteAccountId: suiteAccountId.value,
|
|
299
|
+
username: username?.value ?? null,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { err, ok, type Result } from "@hraness/result";
|
|
2
|
+
import { deepFreeze } from "../immutable.js";
|
|
3
|
+
import { getSuiteAccountsDeployment } from "../registry.js";
|
|
4
|
+
import { parseSuiteAccountId, type SuiteAccountId } from "./identifiers.js";
|
|
5
|
+
import {
|
|
6
|
+
normalizeSuiteProfileLink,
|
|
7
|
+
parseSuiteProfileUpdateRequest,
|
|
8
|
+
SUITE_PROFILE_URL_MAX_LENGTH,
|
|
9
|
+
type SuiteProfileIssue,
|
|
10
|
+
type SuiteProfileLinkKey,
|
|
11
|
+
type SuiteProfileLinks,
|
|
12
|
+
} from "./profiles.js";
|
|
13
|
+
import { parseSuiteUsername, type SuiteUsername } from "./usernames.js";
|
|
14
|
+
|
|
15
|
+
export type SuiteProfileLinkKeyV2 = SuiteProfileLinkKey | "github";
|
|
16
|
+
export type SuiteProfileLinksV2 = Readonly<SuiteProfileLinks & {
|
|
17
|
+
github: string | null;
|
|
18
|
+
}>;
|
|
19
|
+
declare const suiteAvatarRefBrand: unique symbol;
|
|
20
|
+
/** An opaque syntax-checked reference, not proof of ownership or publication. */
|
|
21
|
+
export type SuiteAvatarRef = string & {
|
|
22
|
+
readonly [suiteAvatarRefBrand]: "SuiteAvatarRef";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SuitePublicProfileV2 = Readonly<{
|
|
26
|
+
schemaVersion: 2;
|
|
27
|
+
accountId: SuiteAccountId;
|
|
28
|
+
username: SuiteUsername;
|
|
29
|
+
name: string;
|
|
30
|
+
bio: string;
|
|
31
|
+
links: SuiteProfileLinksV2;
|
|
32
|
+
avatarRef: SuiteAvatarRef | null;
|
|
33
|
+
revision: number;
|
|
34
|
+
}>;
|
|
35
|
+
|
|
36
|
+
export type SuiteProfileEditorV2 = Readonly<
|
|
37
|
+
Omit<SuitePublicProfileV2, "username"> & {
|
|
38
|
+
username: SuiteUsername | null;
|
|
39
|
+
publication: "private" | "published";
|
|
40
|
+
}
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
export type SuiteProfileUpdateV2 = Readonly<{
|
|
44
|
+
schemaVersion: 2;
|
|
45
|
+
expectedRevision: number;
|
|
46
|
+
name: string;
|
|
47
|
+
bio: string;
|
|
48
|
+
links: SuiteProfileLinksV2;
|
|
49
|
+
avatarRef: SuiteAvatarRef | null;
|
|
50
|
+
publication: "publish" | "private";
|
|
51
|
+
}>;
|
|
52
|
+
|
|
53
|
+
export type SuiteProfileV2Issue = Readonly<{
|
|
54
|
+
field:
|
|
55
|
+
| SuiteProfileLinkKeyV2
|
|
56
|
+
| "accountId"
|
|
57
|
+
| "avatarRef"
|
|
58
|
+
| "bio"
|
|
59
|
+
| "expectedRevision"
|
|
60
|
+
| "name"
|
|
61
|
+
| "profile"
|
|
62
|
+
| "publication"
|
|
63
|
+
| "revision"
|
|
64
|
+
| "schemaVersion"
|
|
65
|
+
| "username";
|
|
66
|
+
reason: "invalid" | "required" | "too_long";
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
const LINK_KEYS = [
|
|
70
|
+
"bluesky", "github", "instagram", "linkedin", "telegram", "website", "x",
|
|
71
|
+
] as const;
|
|
72
|
+
const PUBLIC_KEYS = [
|
|
73
|
+
"schemaVersion", "accountId", "username", "name", "bio", "links",
|
|
74
|
+
"avatarRef", "revision",
|
|
75
|
+
] as const;
|
|
76
|
+
const EDITOR_KEYS = [...PUBLIC_KEYS, "publication"] as const;
|
|
77
|
+
const UPDATE_KEYS = [
|
|
78
|
+
"schemaVersion", "expectedRevision", "name", "bio", "links", "avatarRef",
|
|
79
|
+
"publication",
|
|
80
|
+
] as const;
|
|
81
|
+
// Bound work before text normalization. HTTP callers must also cap their body.
|
|
82
|
+
const MAX_INPUT_TEXT_LENGTH = 32_768;
|
|
83
|
+
const AVATAR_PATTERN = /^avref_(?!0{64}$)[0-9a-f]{64}$/u;
|
|
84
|
+
|
|
85
|
+
function issue(
|
|
86
|
+
field: SuiteProfileV2Issue["field"] = "profile",
|
|
87
|
+
reason: SuiteProfileV2Issue["reason"] = "invalid",
|
|
88
|
+
): Result<never, SuiteProfileV2Issue> {
|
|
89
|
+
return deepFreeze(err({ field, reason }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function success<Value>(value: Value): Result<Value, SuiteProfileV2Issue> {
|
|
93
|
+
return deepFreeze(ok(value));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Copy data descriptors without evaluating getters or retaining caller records. */
|
|
97
|
+
function snapshot(
|
|
98
|
+
value: unknown,
|
|
99
|
+
keys: readonly string[],
|
|
100
|
+
): Record<string, unknown> | null {
|
|
101
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
105
|
+
if (prototype !== Object.prototype && prototype !== null) return null;
|
|
106
|
+
const actual = Reflect.ownKeys(value);
|
|
107
|
+
if (actual.length !== keys.length) return null;
|
|
108
|
+
const copy = Object.create(null) as Record<string, unknown>;
|
|
109
|
+
for (const key of actual) {
|
|
110
|
+
if (typeof key !== "string" || !keys.includes(key)) return null;
|
|
111
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
112
|
+
if (descriptor === undefined || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const field: unknown = descriptor.value;
|
|
116
|
+
copy[key] = field;
|
|
117
|
+
}
|
|
118
|
+
return copy;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nonnegativeInteger(value: unknown): value is number {
|
|
122
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseSuiteAvatarRef(
|
|
126
|
+
value: unknown,
|
|
127
|
+
): Result<SuiteAvatarRef, SuiteProfileV2Issue> {
|
|
128
|
+
return typeof value === "string" && value.length === 70 && AVATAR_PATTERN.test(value)
|
|
129
|
+
? success(value as SuiteAvatarRef)
|
|
130
|
+
: issue("avatarRef");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function optionalAvatar(value: unknown): Result<SuiteAvatarRef | null, SuiteProfileV2Issue> {
|
|
134
|
+
return value === null ? success(null) : parseSuiteAvatarRef(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function legacyIssue(error: SuiteProfileIssue): Result<never, SuiteProfileV2Issue> {
|
|
138
|
+
const field = error.field === "application" || error.field === "email"
|
|
139
|
+
? "profile"
|
|
140
|
+
: error.field;
|
|
141
|
+
return issue(field, error.reason);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** GitHub uses a bounded URL policy; this does not verify an existing username. */
|
|
145
|
+
export function normalizeSuiteProfileLinkV2(
|
|
146
|
+
key: unknown,
|
|
147
|
+
value: unknown,
|
|
148
|
+
): Result<string | null, SuiteProfileV2Issue> {
|
|
149
|
+
if (typeof key !== "string" || !LINK_KEYS.some((candidate) => candidate === key)) {
|
|
150
|
+
return issue();
|
|
151
|
+
}
|
|
152
|
+
const field = key as SuiteProfileLinkKeyV2;
|
|
153
|
+
if (value !== null && typeof value !== "string") return issue(field);
|
|
154
|
+
if (typeof value === "string" && value.length > SUITE_PROFILE_URL_MAX_LENGTH) {
|
|
155
|
+
return issue(field, "too_long");
|
|
156
|
+
}
|
|
157
|
+
if (field === "github") {
|
|
158
|
+
if (value === null || value === "") return success(null);
|
|
159
|
+
// Match the original input so URL parsing cannot erase ports, escapes,
|
|
160
|
+
// dot segments, empty queries, or control characters before validation.
|
|
161
|
+
if (/[^\x21-\x7e]/u.test(value)) return issue("github");
|
|
162
|
+
const match = /^https:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_-]{1,100})\/?$/iu.exec(value);
|
|
163
|
+
return match === null || match[0] !== value
|
|
164
|
+
? issue("github")
|
|
165
|
+
: success(`https://github.com/${match[1]!.toLowerCase()}`);
|
|
166
|
+
}
|
|
167
|
+
const parsed = normalizeSuiteProfileLink(field, value);
|
|
168
|
+
if (!parsed.ok) return legacyIssue(parsed.error);
|
|
169
|
+
if (parsed.value !== null && parsed.value.length > SUITE_PROFILE_URL_MAX_LENGTH) {
|
|
170
|
+
return issue(field, "too_long");
|
|
171
|
+
}
|
|
172
|
+
return success(parsed.value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parsedContent(
|
|
176
|
+
value: Record<string, unknown>,
|
|
177
|
+
canonical: boolean,
|
|
178
|
+
): Result<Readonly<{ name: string; bio: string; links: SuiteProfileLinksV2 }>, SuiteProfileV2Issue> {
|
|
179
|
+
for (const field of ["name", "bio"] as const) {
|
|
180
|
+
if (typeof value[field] === "string" && value[field].length > MAX_INPUT_TEXT_LENGTH) {
|
|
181
|
+
return issue(field, "too_long");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const inputLinks = snapshot(value["links"], LINK_KEYS);
|
|
185
|
+
if (inputLinks === null) return issue();
|
|
186
|
+
const links: Record<SuiteProfileLinkKeyV2, string | null> = {
|
|
187
|
+
bluesky: null, github: null, instagram: null, linkedin: null,
|
|
188
|
+
telegram: null, website: null, x: null,
|
|
189
|
+
};
|
|
190
|
+
for (const key of LINK_KEYS) {
|
|
191
|
+
const result = normalizeSuiteProfileLinkV2(key, inputLinks[key]);
|
|
192
|
+
if (!result.ok) return result;
|
|
193
|
+
if (canonical && result.value !== inputLinks[key]) return issue(key);
|
|
194
|
+
links[key] = result.value;
|
|
195
|
+
}
|
|
196
|
+
// Preserve the released text normalization without widening its six-link schema.
|
|
197
|
+
const parsed = parseSuiteProfileUpdateRequest({
|
|
198
|
+
expectedRevision: 0,
|
|
199
|
+
name: value["name"],
|
|
200
|
+
bio: value["bio"],
|
|
201
|
+
links: {
|
|
202
|
+
bluesky: links.bluesky, instagram: links.instagram, linkedin: links.linkedin,
|
|
203
|
+
telegram: links.telegram, website: links.website, x: links.x,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!parsed.ok) return legacyIssue(parsed.error);
|
|
207
|
+
if (canonical && (parsed.value.name !== value["name"] || parsed.value.bio !== value["bio"])) {
|
|
208
|
+
return issue();
|
|
209
|
+
}
|
|
210
|
+
return success({ name: parsed.value.name, bio: parsed.value.bio, links });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseView(
|
|
214
|
+
input: unknown,
|
|
215
|
+
editor: boolean,
|
|
216
|
+
): Result<SuiteProfileEditorV2 | SuitePublicProfileV2, SuiteProfileV2Issue> {
|
|
217
|
+
try {
|
|
218
|
+
const value = snapshot(input, editor ? EDITOR_KEYS : PUBLIC_KEYS);
|
|
219
|
+
if (value === null) return issue();
|
|
220
|
+
if (value["schemaVersion"] !== 2) return issue("schemaVersion");
|
|
221
|
+
const accountId = parseSuiteAccountId(value["accountId"]);
|
|
222
|
+
if (!accountId.ok) return issue("accountId");
|
|
223
|
+
const username = editor && value["username"] === null
|
|
224
|
+
? success(null)
|
|
225
|
+
: parseSuiteUsername(value["username"]);
|
|
226
|
+
if (!username.ok) return issue("username");
|
|
227
|
+
const revision = value["revision"];
|
|
228
|
+
if (!nonnegativeInteger(revision) || (!editor && revision === 0)) return issue("revision");
|
|
229
|
+
const avatarRef = optionalAvatar(value["avatarRef"]);
|
|
230
|
+
if (!avatarRef.ok) return avatarRef;
|
|
231
|
+
const publication = value["publication"];
|
|
232
|
+
if (editor && publication !== "private" && publication !== "published") {
|
|
233
|
+
return issue("publication");
|
|
234
|
+
}
|
|
235
|
+
if (editor && publication === "published" && username.value === null) return issue("username");
|
|
236
|
+
let content;
|
|
237
|
+
if (revision === 0) {
|
|
238
|
+
const links = snapshot(value["links"], LINK_KEYS);
|
|
239
|
+
if (value["name"] !== "" || value["bio"] !== "" || avatarRef.value !== null
|
|
240
|
+
|| publication !== "private" || links === null
|
|
241
|
+
|| LINK_KEYS.some((key) => links[key] !== null)) return issue();
|
|
242
|
+
content = {
|
|
243
|
+
name: "", bio: "",
|
|
244
|
+
links: { bluesky: null, github: null, instagram: null, linkedin: null,
|
|
245
|
+
telegram: null, website: null, x: null },
|
|
246
|
+
};
|
|
247
|
+
} else {
|
|
248
|
+
const parsed = parsedContent(value, true);
|
|
249
|
+
if (!parsed.ok) return parsed;
|
|
250
|
+
content = parsed.value;
|
|
251
|
+
}
|
|
252
|
+
const profile = {
|
|
253
|
+
schemaVersion: 2 as const,
|
|
254
|
+
accountId: accountId.value,
|
|
255
|
+
...content,
|
|
256
|
+
avatarRef: avatarRef.value,
|
|
257
|
+
revision,
|
|
258
|
+
};
|
|
259
|
+
if (editor && (publication === "private" || publication === "published")) {
|
|
260
|
+
return success({ ...profile, username: username.value, publication });
|
|
261
|
+
}
|
|
262
|
+
if (username.value === null) return issue("username");
|
|
263
|
+
return success({ ...profile, username: username.value });
|
|
264
|
+
} catch {
|
|
265
|
+
return issue();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Parse a canonical public projection. This does not authenticate its source. */
|
|
270
|
+
export function parseSuitePublicProfileV2(
|
|
271
|
+
value: unknown,
|
|
272
|
+
): Result<SuitePublicProfileV2, SuiteProfileV2Issue> {
|
|
273
|
+
const parsed = parseView(value, false);
|
|
274
|
+
if (!parsed.ok) return parsed;
|
|
275
|
+
if (parsed.value.username === null || isEditor(parsed.value)) return issue();
|
|
276
|
+
return success({ ...parsed.value, username: parsed.value.username });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Parse a canonical owner view, including the coherent revision-zero default. */
|
|
280
|
+
export function parseSuiteProfileEditorV2(
|
|
281
|
+
value: unknown,
|
|
282
|
+
): Result<SuiteProfileEditorV2, SuiteProfileV2Issue> {
|
|
283
|
+
const parsed = parseView(value, true);
|
|
284
|
+
if (!parsed.ok) return parsed;
|
|
285
|
+
return isEditor(parsed.value) ? success(parsed.value) : issue();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isEditor(value: SuiteProfileEditorV2 | SuitePublicProfileV2): value is SuiteProfileEditorV2 {
|
|
289
|
+
return Object.hasOwn(value, "publication");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Normalize an explicit revision-bound edit; the authority applies publication. */
|
|
293
|
+
export function parseSuiteProfileUpdateV2(
|
|
294
|
+
input: unknown,
|
|
295
|
+
): Result<SuiteProfileUpdateV2, SuiteProfileV2Issue> {
|
|
296
|
+
try {
|
|
297
|
+
const value = snapshot(input, UPDATE_KEYS);
|
|
298
|
+
if (value === null) return issue();
|
|
299
|
+
if (value["schemaVersion"] !== 2) return issue("schemaVersion");
|
|
300
|
+
const expectedRevision = value["expectedRevision"];
|
|
301
|
+
if (!nonnegativeInteger(expectedRevision)) return issue("expectedRevision");
|
|
302
|
+
const publication = value["publication"];
|
|
303
|
+
if (publication !== "publish" && publication !== "private") return issue("publication");
|
|
304
|
+
const avatarRef = optionalAvatar(value["avatarRef"]);
|
|
305
|
+
if (!avatarRef.ok) return avatarRef;
|
|
306
|
+
const content = parsedContent(value, false);
|
|
307
|
+
if (!content.ok) return content;
|
|
308
|
+
return success({ schemaVersion: 2, expectedRevision, ...content.value,
|
|
309
|
+
avatarRef: avatarRef.value, publication });
|
|
310
|
+
} catch {
|
|
311
|
+
return issue();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function avatarUrl(ref: unknown, path: string): Result<string, SuiteProfileV2Issue> {
|
|
316
|
+
const parsed = parseSuiteAvatarRef(ref);
|
|
317
|
+
return parsed.ok
|
|
318
|
+
? success(`${getSuiteAccountsDeployment("production").accountsOrigin}${path}/${parsed.value}.webp`)
|
|
319
|
+
: parsed;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Syntax only; serving requires current publication consent and a live endpoint. */
|
|
323
|
+
export function suiteProfileAvatarPublicUrl(ref: unknown): Result<string, SuiteProfileV2Issue> {
|
|
324
|
+
return avatarUrl(ref, "/suite/profile/avatar/v1");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Syntax only; serving requires the owner's authenticated session and a live endpoint. */
|
|
328
|
+
export function suiteProfileAvatarEditorUrl(ref: unknown): Result<string, SuiteProfileV2Issue> {
|
|
329
|
+
return avatarUrl(ref, "/api/profile/avatar/v1");
|
|
330
|
+
}
|