@hraness/suite-accounts 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +496 -0
  3. package/compiler-foundation.css +1 -0
  4. package/dist/auth-client.js +16 -0
  5. package/dist/auth-proxy.js +757 -0
  6. package/dist/bearer-verifier.js +1119 -0
  7. package/dist/browser-session.js +208 -0
  8. package/dist/client-configuration.js +470 -0
  9. package/dist/convex-browser-auth-browser.js +923 -0
  10. package/dist/convex-browser-auth-server.js +846 -0
  11. package/dist/convex-browser-auth.js +597 -0
  12. package/dist/entitlements.js +502 -0
  13. package/dist/identity/functions.js +44 -0
  14. package/dist/identity/index.js +1716 -0
  15. package/dist/identity/return-targets.js +33 -0
  16. package/dist/index.js +1133 -0
  17. package/dist/oidc-device-code.js +253 -0
  18. package/dist/oidc-rp.js +2259 -0
  19. package/dist/oidc-session-policy.js +5 -0
  20. package/dist/oidc-surface-server.js +2332 -0
  21. package/dist/profile-form.js +1934 -0
  22. package/dist/profile.js +17 -0
  23. package/dist/public-config.js +462 -0
  24. package/dist/react.js +137 -0
  25. package/dist/receipt-verifier.js +619 -0
  26. package/dist/registry.js +327 -0
  27. package/dist/stylex-manifest.json +1 -0
  28. package/dist/stylex.css +435 -0
  29. package/dist/urls.js +391 -0
  30. package/package.json +263 -0
  31. package/portfolio-inventory.json +37 -0
  32. package/src/auth-client.ts +37 -0
  33. package/src/auth-proxy.ts +473 -0
  34. package/src/bearer-verifier.ts +478 -0
  35. package/src/browser-session.ts +260 -0
  36. package/src/client-configuration.ts +172 -0
  37. package/src/convex-browser-auth-browser.ts +238 -0
  38. package/src/convex-browser-auth-server.ts +492 -0
  39. package/src/convex-browser-auth.ts +208 -0
  40. package/src/convex-url.ts +74 -0
  41. package/src/entitlements.ts +317 -0
  42. package/src/identity/catalog.ts +227 -0
  43. package/src/identity/consumers.ts +53 -0
  44. package/src/identity/functions.ts +70 -0
  45. package/src/identity/identifiers.ts +82 -0
  46. package/src/identity/index.ts +10 -0
  47. package/src/identity/links.ts +280 -0
  48. package/src/identity/membership.ts +156 -0
  49. package/src/identity/principals.ts +301 -0
  50. package/src/identity/profiles-v2.ts +330 -0
  51. package/src/identity/profiles.ts +524 -0
  52. package/src/identity/return-targets.ts +20 -0
  53. package/src/identity/usernames.ts +101 -0
  54. package/src/identity/views.ts +289 -0
  55. package/src/immutable.ts +25 -0
  56. package/src/index.ts +7 -0
  57. package/src/oidc-continuation.ts +59 -0
  58. package/src/oidc-device-code.ts +401 -0
  59. package/src/oidc-rp.ts +2217 -0
  60. package/src/oidc-session-policy.ts +6 -0
  61. package/src/oidc-surface-server.ts +186 -0
  62. package/src/profile-form.css +1 -0
  63. package/src/profile-form.stylex.ts +226 -0
  64. package/src/profile-form.tsx +314 -0
  65. package/src/profile.ts +33 -0
  66. package/src/public-config.ts +294 -0
  67. package/src/public-profile-form-result.ts +82 -0
  68. package/src/public-profile-form.tsx +319 -0
  69. package/src/public-profile-links.ts +100 -0
  70. package/src/react.tsx +196 -0
  71. package/src/receipt-verifier.ts +273 -0
  72. package/src/registry.ts +600 -0
  73. package/src/urls.ts +234 -0
@@ -0,0 +1,314 @@
1
+ "use client";
2
+
3
+ export { SuitePublicProfileForm } from "./public-profile-form.js";
4
+ export type { SuitePublicProfileFormProps, SuitePublicProfileFormResult } from "./public-profile-form.js";
5
+
6
+ import { profileFormClasses } from "./profile-form.stylex.js";
7
+
8
+ import {
9
+ SUITE_PROFILE_BIO_MAX_LENGTH,
10
+ SUITE_PROFILE_NAME_MAX_LENGTH,
11
+ parseSuiteProfileUpdateRequest,
12
+ type SuiteProfileIssue,
13
+ type SuiteProfileLinkKey,
14
+ type SuiteProfileUpdateRequest,
15
+ type SuiteProfileView,
16
+ } from "./identity/profiles.js";
17
+ import {
18
+ type FormEvent,
19
+ useId,
20
+ useState,
21
+ } from "react";
22
+
23
+ export type SuiteProfileFormResult =
24
+ | Readonly<{ profile: SuiteProfileView; status: "saved" }>
25
+ | Readonly<{ profile: SuiteProfileView; status: "conflict" }>;
26
+
27
+ export type SuiteProfileFormProps = Readonly<{
28
+ className?: string;
29
+ initialProfile: SuiteProfileView;
30
+ onSave: (
31
+ request: SuiteProfileUpdateRequest,
32
+ ) => Promise<SuiteProfileFormResult>;
33
+ onSaved?: (profile: SuiteProfileView) => void;
34
+ submitLabel: string;
35
+ }>;
36
+
37
+ type LinkInputs = Record<SuiteProfileLinkKey, string>;
38
+ type FieldErrors = Partial<Record<
39
+ SuiteProfileIssue["field"],
40
+ string
41
+ >>;
42
+
43
+ function withoutFieldError(
44
+ errors: FieldErrors,
45
+ field: SuiteProfileIssue["field"],
46
+ ): FieldErrors {
47
+ const next = { ...errors };
48
+ delete next[field];
49
+ return next;
50
+ }
51
+
52
+ const LINK_FIELDS = [
53
+ ["x", "X"],
54
+ ["linkedin", "LinkedIn"],
55
+ ["bluesky", "Bluesky"],
56
+ ["instagram", "Instagram"],
57
+ ["telegram", "Telegram"],
58
+ ["website", "Personal website"],
59
+ ] as const satisfies readonly (readonly [SuiteProfileLinkKey, string])[];
60
+
61
+ function linkInputs(profile: SuiteProfileView): LinkInputs {
62
+ return {
63
+ bluesky: profile.links.bluesky ?? "",
64
+ instagram: profile.links.instagram ?? "",
65
+ linkedin: profile.links.linkedin ?? "",
66
+ telegram: profile.links.telegram ?? "",
67
+ website: profile.links.website ?? "",
68
+ x: profile.links.x ?? "",
69
+ };
70
+ }
71
+
72
+ function issueMessage(issue: SuiteProfileIssue): string {
73
+ if (issue.field === "name") {
74
+ return issue.reason === "required"
75
+ ? "Enter a name."
76
+ : `Use ${SUITE_PROFILE_NAME_MAX_LENGTH} characters or fewer.`;
77
+ }
78
+ if (issue.field === "bio") {
79
+ return `Use ${SUITE_PROFILE_BIO_MAX_LENGTH} characters or fewer.`;
80
+ }
81
+ if (issue.field === "linkedin") return "Enter a LinkedIn profile URL.";
82
+ if (issue.field === "website") return "Enter a complete HTTPS URL.";
83
+ if (
84
+ issue.field === "x"
85
+ || issue.field === "bluesky"
86
+ || issue.field === "instagram"
87
+ || issue.field === "telegram"
88
+ ) {
89
+ const label = LINK_FIELDS.find(([key]) => key === issue.field)?.[1]
90
+ ?? "profile";
91
+ return `Enter a valid ${label} handle or profile URL.`;
92
+ }
93
+ return "Check the profile and try again.";
94
+ }
95
+
96
+ function applyProfile(
97
+ profile: SuiteProfileView,
98
+ setters: Readonly<{
99
+ setBio: (value: string) => void;
100
+ setEmail: (value: string) => void;
101
+ setLinks: (value: LinkInputs) => void;
102
+ setName: (value: string) => void;
103
+ setRevision: (value: number) => void;
104
+ }>,
105
+ ): void {
106
+ setters.setBio(profile.bio);
107
+ setters.setEmail(profile.email);
108
+ setters.setLinks(linkInputs(profile));
109
+ setters.setName(profile.name);
110
+ setters.setRevision(profile.revision);
111
+ }
112
+
113
+ export function SuiteProfileForm({
114
+ className,
115
+ initialProfile,
116
+ onSave,
117
+ onSaved,
118
+ submitLabel,
119
+ }: SuiteProfileFormProps) {
120
+ const id = useId();
121
+ const [name, setName] = useState(initialProfile.name);
122
+ const [email, setEmail] = useState(initialProfile.email);
123
+ const [bio, setBio] = useState(initialProfile.bio);
124
+ const [links, setLinks] = useState<LinkInputs>(() =>
125
+ linkInputs(initialProfile)
126
+ );
127
+ const [revision, setRevision] = useState(initialProfile.revision);
128
+ const [pending, setPending] = useState(false);
129
+ const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
130
+ const [formError, setFormError] = useState<string | null>(null);
131
+
132
+ async function submit(event: FormEvent<HTMLFormElement>) {
133
+ event.preventDefault();
134
+ if (pending) return;
135
+ setFieldErrors({});
136
+ setFormError(null);
137
+ const parsed = parseSuiteProfileUpdateRequest({
138
+ bio,
139
+ expectedRevision: revision,
140
+ links,
141
+ name,
142
+ });
143
+ if (!parsed.ok) {
144
+ setFieldErrors({
145
+ [parsed.error.field]: issueMessage(parsed.error),
146
+ });
147
+ return;
148
+ }
149
+ setPending(true);
150
+ try {
151
+ const result = await onSave(parsed.value);
152
+ applyProfile(result.profile, {
153
+ setBio,
154
+ setEmail,
155
+ setLinks,
156
+ setName,
157
+ setRevision,
158
+ });
159
+ if (result.status === "conflict") {
160
+ setFormError(
161
+ "The profile changed elsewhere. Review it and save again.",
162
+ );
163
+ return;
164
+ }
165
+ onSaved?.(result.profile);
166
+ } catch {
167
+ setFormError("The profile could not be saved. Try again.");
168
+ } finally {
169
+ setPending(false);
170
+ }
171
+ }
172
+
173
+ const formClassName = className === undefined
174
+ ? `suite-profile-form ${profileFormClasses.form}`
175
+ : `suite-profile-form ${profileFormClasses.form} ${className}`;
176
+
177
+ return (
178
+ <form
179
+ aria-busy={pending}
180
+ className={formClassName}
181
+ onSubmit={(event) => {
182
+ void submit(event);
183
+ }}
184
+ >
185
+ <label className={`suite-profile-field ${profileFormClasses.field}`} htmlFor={`${id}-name`}>
186
+ <span className={profileFormClasses.label}>Name</span>
187
+ <input
188
+ className={profileFormClasses.control}
189
+ aria-describedby={
190
+ fieldErrors.name === undefined ? undefined : `${id}-name-error`
191
+ }
192
+ aria-invalid={fieldErrors.name === undefined ? undefined : "true"}
193
+ autoComplete="name"
194
+ disabled={pending}
195
+ id={`${id}-name`}
196
+ maxLength={SUITE_PROFILE_NAME_MAX_LENGTH}
197
+ onChange={(event) => {
198
+ setName(event.currentTarget.value);
199
+ setFieldErrors(errors => withoutFieldError(errors, "name"));
200
+ }}
201
+ required
202
+ type="text"
203
+ value={name}
204
+ />
205
+ {fieldErrors.name === undefined
206
+ ? null
207
+ : (
208
+ <span
209
+ className={`suite-profile-error ${profileFormClasses.error}`}
210
+ id={`${id}-name-error`}
211
+ role="alert"
212
+ >
213
+ {fieldErrors.name}
214
+ </span>
215
+ )}
216
+ </label>
217
+
218
+ <label className={`suite-profile-field ${profileFormClasses.field}`} htmlFor={`${id}-email`}>
219
+ <span className={profileFormClasses.label}>Email</span>
220
+ <input
221
+ className={profileFormClasses.email}
222
+ aria-readonly="true"
223
+ autoComplete="email"
224
+ id={`${id}-email`}
225
+ readOnly
226
+ type="email"
227
+ value={email}
228
+ />
229
+ </label>
230
+
231
+ <label className={`suite-profile-field ${profileFormClasses.field}`} htmlFor={`${id}-bio`}>
232
+ <span className={profileFormClasses.label}>Bio</span>
233
+ <textarea
234
+ className={profileFormClasses.textarea}
235
+ aria-describedby={
236
+ fieldErrors.bio === undefined ? undefined : `${id}-bio-error`
237
+ }
238
+ aria-invalid={fieldErrors.bio === undefined ? undefined : "true"}
239
+ disabled={pending}
240
+ id={`${id}-bio`}
241
+ maxLength={SUITE_PROFILE_BIO_MAX_LENGTH}
242
+ onChange={(event) => {
243
+ setBio(event.currentTarget.value);
244
+ setFieldErrors(errors => withoutFieldError(errors, "bio"));
245
+ }}
246
+ placeholder="Introduce yourself"
247
+ rows={5}
248
+ value={bio}
249
+ />
250
+ {fieldErrors.bio === undefined
251
+ ? null
252
+ : (
253
+ <span
254
+ className={`suite-profile-error ${profileFormClasses.error}`}
255
+ id={`${id}-bio-error`}
256
+ role="alert"
257
+ >
258
+ {fieldErrors.bio}
259
+ </span>
260
+ )}
261
+ </label>
262
+
263
+ {LINK_FIELDS.map(([key, label]) => {
264
+ const error = fieldErrors[key];
265
+ return (
266
+ <label
267
+ className={`suite-profile-field ${profileFormClasses.field}`}
268
+ htmlFor={`${id}-${key}`}
269
+ key={key}
270
+ >
271
+ <span className={profileFormClasses.label}>{label}</span>
272
+ <input
273
+ className={profileFormClasses.control}
274
+ aria-describedby={
275
+ error === undefined ? undefined : `${id}-${key}-error`
276
+ }
277
+ aria-invalid={error === undefined ? undefined : "true"}
278
+ autoCapitalize="none"
279
+ autoComplete="url"
280
+ disabled={pending}
281
+ id={`${id}-${key}`}
282
+ inputMode="url"
283
+ maxLength={2_048}
284
+ onChange={(event) => {
285
+ const value = event.currentTarget.value;
286
+ setLinks(current => ({ ...current, [key]: value }));
287
+ setFieldErrors(errors => withoutFieldError(errors, key));
288
+ }}
289
+ spellCheck={false}
290
+ type="text"
291
+ value={links[key]}
292
+ />
293
+ {error === undefined
294
+ ? null
295
+ : (
296
+ <span
297
+ className={`suite-profile-error ${profileFormClasses.error}`}
298
+ id={`${id}-${key}-error`}
299
+ role="alert"
300
+ >
301
+ {error}
302
+ </span>
303
+ )}
304
+ </label>
305
+ );
306
+ })}
307
+
308
+ {formError === null
309
+ ? null
310
+ : <p className={`suite-profile-error ${profileFormClasses.error}`} role="alert">{formError}</p>}
311
+ <button className={profileFormClasses.button} disabled={pending} type="submit">{submitLabel}</button>
312
+ </form>
313
+ );
314
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,33 @@
1
+ import {
2
+ SUITE_COMMUNITY_APPLICATION_STATUSES,
3
+ SUITE_PROFILE_BIO_MAX_LENGTH,
4
+ SUITE_PROFILE_NAME_MAX_LENGTH,
5
+ SUITE_PROFILE_URL_MAX_LENGTH,
6
+ normalizeSuiteProfileLink,
7
+ parseSuiteCommunityProfileView,
8
+ parseSuiteProfileUpdateRequest,
9
+ parseSuiteProfileView,
10
+ } from "./identity/profiles.js";
11
+
12
+ export * from "./identity/profiles-v2.js";
13
+
14
+ export {
15
+ SUITE_COMMUNITY_APPLICATION_STATUSES,
16
+ SUITE_PROFILE_BIO_MAX_LENGTH,
17
+ SUITE_PROFILE_NAME_MAX_LENGTH,
18
+ SUITE_PROFILE_URL_MAX_LENGTH,
19
+ normalizeSuiteProfileLink,
20
+ parseSuiteCommunityProfileView,
21
+ parseSuiteProfileUpdateRequest,
22
+ parseSuiteProfileView,
23
+ };
24
+ export type {
25
+ SuiteCommunityApplicationStatus,
26
+ SuiteCommunityApplicationView,
27
+ SuiteCommunityProfileView,
28
+ SuiteProfileIssue,
29
+ SuiteProfileLinkKey,
30
+ SuiteProfileLinks,
31
+ SuiteProfileUpdateRequest,
32
+ SuiteProfileView,
33
+ } from "./identity/profiles.js";
@@ -0,0 +1,294 @@
1
+ import { parseConvexDeployment } from "./convex-url.js";
2
+ import { deepFreeze } from "./immutable.js";
3
+
4
+ import {
5
+ getSuiteAccountsCurrentConsumerEnvironment,
6
+ getSuiteAccountsCurrentConsumer,
7
+ getSuiteAccountsConsumer,
8
+ getSuiteAccountsDeployment,
9
+ isSuiteAccountsCurrentConsumerId,
10
+ type SuiteAccountsAuthConfiguration,
11
+ type SuiteAccountsCurrentConsumerId,
12
+ type SuiteAccountsEnvironment,
13
+ type SuiteAccountsRegisteredConsumerId,
14
+ } from "./registry.js";
15
+
16
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
17
+
18
+ export const SUITE_ACCOUNTS_PUBLIC_ENVIRONMENT_KEYS = deepFreeze([
19
+ "NEXT_PUBLIC_ACCOUNTS_CONVEX_URL",
20
+ "NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL",
21
+ "NEXT_PUBLIC_SITE_URL",
22
+ ] as const);
23
+
24
+ export type SuiteAccountsPublicEnvironmentKey =
25
+ (typeof SUITE_ACCOUNTS_PUBLIC_ENVIRONMENT_KEYS)[number];
26
+
27
+ export type SuiteAccountsPublicEnvironment = Readonly<{
28
+ NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN?: string | undefined;
29
+ NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL?: string | undefined;
30
+ NEXT_PUBLIC_ACCOUNTS_CONVEX_URL?: string | undefined;
31
+ NEXT_PUBLIC_SITE_URL?: string | undefined;
32
+ }>;
33
+
34
+ type ReadySuiteAccountsPublicConfigBase = Readonly<{
35
+ canonicalProductOrigin: string;
36
+ consumer: SuiteAccountsPublicConsumerId;
37
+ convexSiteUrl: string;
38
+ convexUrl: string;
39
+ environment: SuiteAccountsEnvironment;
40
+ kind: "ready";
41
+ siteUrl: string;
42
+ surfaceOrigin: string;
43
+ }>;
44
+
45
+ export type SuiteAccountsPublicConsumerId =
46
+ | SuiteAccountsRegisteredConsumerId
47
+ | SuiteAccountsCurrentConsumerId;
48
+
49
+ export type ReadySuiteAccountsAuthConfiguration =
50
+ | Readonly<{
51
+ authBasePath: "/api/auth";
52
+ authMode: "authority" | "proxy";
53
+ }>
54
+ | Readonly<{
55
+ authBasePath: "/api/suite-auth";
56
+ authMode: "oidc-rp";
57
+ }>;
58
+
59
+ export type ReadySuiteAccountsPublicConfig =
60
+ ReadySuiteAccountsPublicConfigBase
61
+ & ReadySuiteAccountsAuthConfiguration;
62
+
63
+ export type SuiteAccountsPublicConfig =
64
+ | ReadySuiteAccountsPublicConfig
65
+ | Readonly<{ kind: "invalid"; message: string }>
66
+ | Readonly<{
67
+ kind: "missing";
68
+ missing: readonly SuiteAccountsPublicEnvironmentKey[];
69
+ }>
70
+ | Readonly<{
71
+ canonicalProductOrigin: string;
72
+ environment: "production";
73
+ kind: "unavailable";
74
+ message: string;
75
+ surfaceOrigin: string;
76
+ }>;
77
+
78
+ function parseOrigin(
79
+ value: string,
80
+ field: SuiteAccountsPublicEnvironmentKey | "NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN",
81
+ ): URL {
82
+ let url: URL;
83
+ try {
84
+ url = new URL(value);
85
+ } catch {
86
+ throw new Error(`${field} must be an absolute URL.`);
87
+ }
88
+ const loopback = LOOPBACK_HOSTS.has(url.hostname);
89
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) {
90
+ throw new Error(
91
+ `${field} must use HTTPS (HTTP is allowed only on loopback).`,
92
+ );
93
+ }
94
+ if (
95
+ url.username !== ""
96
+ || url.password !== ""
97
+ || url.search !== ""
98
+ || url.hash !== ""
99
+ || (url.pathname !== "" && url.pathname !== "/")
100
+ ) {
101
+ throw new Error(`${field} must be a credential-free origin.`);
102
+ }
103
+ return new URL(url.origin);
104
+ }
105
+
106
+ function readyRemoteConfig(
107
+ consumer: SuiteAccountsPublicConsumerId,
108
+ siteUrl: string,
109
+ convexUrl: string,
110
+ convexSiteUrl: string,
111
+ ): ReadySuiteAccountsPublicConfig | null {
112
+ const registration = getPublicConsumer(consumer);
113
+ const environment = "production" as const;
114
+ const consumerEnvironment = getSuiteAccountsCurrentConsumerEnvironment(
115
+ consumer,
116
+ environment,
117
+ );
118
+ const deployment = getSuiteAccountsDeployment(environment);
119
+ if (
120
+ consumerEnvironment?.siteUrl === siteUrl
121
+ && deployment.convexUrl === convexUrl
122
+ && deployment.convexSiteUrl === convexSiteUrl
123
+ ) {
124
+ return deepFreeze({
125
+ ...publicAuthConfiguration(registration.auth),
126
+ canonicalProductOrigin: siteUrl,
127
+ consumer,
128
+ convexSiteUrl,
129
+ convexUrl,
130
+ environment,
131
+ kind: "ready",
132
+ siteUrl,
133
+ surfaceOrigin: siteUrl,
134
+ });
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function publicAuthConfiguration(
140
+ auth: SuiteAccountsAuthConfiguration,
141
+ ): ReadySuiteAccountsAuthConfiguration {
142
+ switch (auth.kind) {
143
+ case "authority":
144
+ return { authBasePath: auth.basePath, authMode: auth.kind };
145
+ case "oidc-rp":
146
+ return { authBasePath: auth.basePath, authMode: auth.kind };
147
+ case "proxy":
148
+ return { authBasePath: auth.basePath, authMode: auth.kind };
149
+ }
150
+ }
151
+
152
+ function getPublicConsumer(consumer: SuiteAccountsPublicConsumerId) {
153
+ return isSuiteAccountsCurrentConsumerId(consumer)
154
+ ? getSuiteAccountsCurrentConsumer(consumer)
155
+ : getSuiteAccountsConsumer(consumer);
156
+ }
157
+
158
+ export function parseSuiteAccountsPublicConfig(
159
+ consumer: SuiteAccountsPublicConsumerId,
160
+ environment: SuiteAccountsPublicEnvironment,
161
+ ): Exclude<SuiteAccountsPublicConfig, { kind: "invalid" }> {
162
+ const missing = SUITE_ACCOUNTS_PUBLIC_ENVIRONMENT_KEYS.filter((name) => {
163
+ const value = environment[name];
164
+ return typeof value !== "string" || value.trim() === "";
165
+ });
166
+ if (missing.length > 0) return deepFreeze({ kind: "missing", missing });
167
+
168
+ const site = parseOrigin(
169
+ environment.NEXT_PUBLIC_SITE_URL!,
170
+ "NEXT_PUBLIC_SITE_URL",
171
+ );
172
+ const convexSite = parseOrigin(
173
+ environment.NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL!,
174
+ "NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL",
175
+ );
176
+ const deployment = parseConvexDeployment(
177
+ environment.NEXT_PUBLIC_ACCOUNTS_CONVEX_URL,
178
+ );
179
+ if (deployment.kind !== "ready") {
180
+ throw new Error(
181
+ deployment.kind === "invalid"
182
+ ? `NEXT_PUBLIC_ACCOUNTS_CONVEX_URL is invalid: ${deployment.message}`
183
+ : "NEXT_PUBLIC_ACCOUNTS_CONVEX_URL is required.",
184
+ );
185
+ }
186
+ const convex = parseOrigin(
187
+ deployment.url,
188
+ "NEXT_PUBLIC_ACCOUNTS_CONVEX_URL",
189
+ );
190
+ const loopback = [site, convex, convexSite].map(url =>
191
+ LOOPBACK_HOSTS.has(url.hostname)
192
+ );
193
+ if (loopback.every(Boolean)) {
194
+ // This new registration has no local trust surface. Preserve existing
195
+ // consumers' released loopback behavior below.
196
+ if (consumer === "aicharts") {
197
+ throw new Error(
198
+ "AI Charts authentication requires its registered production origin.",
199
+ );
200
+ }
201
+ if (
202
+ deployment.transport !== "local"
203
+ || site.hostname !== convex.hostname
204
+ || site.hostname !== convexSite.hostname
205
+ ) {
206
+ throw new Error(
207
+ "Local consumer and Accounts endpoints must use the same loopback host.",
208
+ );
209
+ }
210
+ const registration = getPublicConsumer(consumer);
211
+ return deepFreeze({
212
+ ...publicAuthConfiguration(registration.auth),
213
+ canonicalProductOrigin: site.origin,
214
+ consumer,
215
+ convexSiteUrl: convexSite.origin,
216
+ convexUrl: convex.origin,
217
+ environment: "local",
218
+ kind: "ready",
219
+ siteUrl: site.origin,
220
+ surfaceOrigin: site.origin,
221
+ });
222
+ }
223
+ if (loopback.some(Boolean)) {
224
+ throw new Error(
225
+ "Consumer and Accounts endpoints cannot mix local and remote environments.",
226
+ );
227
+ }
228
+ const previewSurfaceValue = environment.NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN;
229
+ if (previewSurfaceValue !== undefined) {
230
+ const previewSurface = parseOrigin(
231
+ previewSurfaceValue,
232
+ "NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN",
233
+ );
234
+ if (!previewSurface.hostname.endsWith(".vercel.app")) {
235
+ throw new Error(
236
+ "NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN must use a generated .vercel.app origin.",
237
+ );
238
+ }
239
+ const production = readyRemoteConfig(
240
+ consumer,
241
+ site.origin,
242
+ convex.origin,
243
+ convexSite.origin,
244
+ );
245
+ if (production === null) {
246
+ throw new Error(
247
+ `${getPublicConsumer(consumer).displayName} and Accounts endpoints `
248
+ + "do not match the production deployment.",
249
+ );
250
+ }
251
+ return deepFreeze({
252
+ canonicalProductOrigin: site.origin,
253
+ environment: "production",
254
+ kind: "unavailable",
255
+ message: "Suite authentication is unavailable on generated Vercel Preview origins.",
256
+ surfaceOrigin: previewSurface.origin,
257
+ });
258
+ }
259
+ const remote = readyRemoteConfig(
260
+ consumer,
261
+ site.origin,
262
+ convex.origin,
263
+ convexSite.origin,
264
+ );
265
+ if (remote !== null) return remote;
266
+ throw new Error(
267
+ `${getPublicConsumer(consumer).displayName} and Accounts endpoints `
268
+ + "do not match an owned deployment environment.",
269
+ );
270
+ }
271
+
272
+ export function suiteAccountsPublicConfigFromEnvironment(
273
+ consumer: SuiteAccountsPublicConsumerId,
274
+ environment: SuiteAccountsPublicEnvironment = {
275
+ NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN:
276
+ process.env.NEXT_PUBLIC_VERCEL_SURFACE_ORIGIN,
277
+ NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL:
278
+ process.env.NEXT_PUBLIC_ACCOUNTS_CONVEX_SITE_URL,
279
+ NEXT_PUBLIC_ACCOUNTS_CONVEX_URL:
280
+ process.env.NEXT_PUBLIC_ACCOUNTS_CONVEX_URL,
281
+ NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
282
+ },
283
+ ): SuiteAccountsPublicConfig {
284
+ try {
285
+ return parseSuiteAccountsPublicConfig(consumer, environment);
286
+ } catch (error) {
287
+ return deepFreeze({
288
+ kind: "invalid",
289
+ message: error instanceof Error
290
+ ? error.message
291
+ : "Suite Accounts configuration is invalid.",
292
+ });
293
+ }
294
+ }