@intelligo-dev/auth 1.0.0-beta.1 → 1.0.0-beta.14

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 (81) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +59 -0
  3. package/dist/client.js +6 -16
  4. package/dist/client.js.map +1 -1
  5. package/dist/edge.js +22 -28
  6. package/dist/edge.js.map +1 -1
  7. package/dist/guard-error.js +23 -0
  8. package/dist/guard-error.js.map +1 -0
  9. package/dist/helpers.js +77 -97
  10. package/dist/helpers.js.map +1 -1
  11. package/dist/impersonation.js +46 -18
  12. package/dist/impersonation.js.map +1 -1
  13. package/dist/index.js +21 -27
  14. package/dist/index.js.map +1 -1
  15. package/dist/invitation-links.js +13 -0
  16. package/dist/invitation-links.js.map +1 -0
  17. package/dist/onboarding/errors.js +4 -14
  18. package/dist/onboarding/errors.js.map +1 -1
  19. package/dist/onboarding/schemas.js +3 -15
  20. package/dist/onboarding/schemas.js.map +1 -1
  21. package/dist/onboarding/service.js +14 -68
  22. package/dist/onboarding/service.js.map +1 -1
  23. package/dist/org-api.js +10 -67
  24. package/dist/org-api.js.map +1 -1
  25. package/dist/profile/errors.js +4 -14
  26. package/dist/profile/errors.js.map +1 -1
  27. package/dist/profile/schemas.js +9 -10
  28. package/dist/profile/schemas.js.map +1 -1
  29. package/dist/profile/service.js +33 -53
  30. package/dist/profile/service.js.map +1 -1
  31. package/dist/roles.js +28 -5
  32. package/dist/roles.js.map +1 -1
  33. package/dist/server.js +80 -98
  34. package/dist/server.js.map +1 -1
  35. package/dist/team/errors.js +4 -13
  36. package/dist/team/errors.js.map +1 -1
  37. package/dist/team/schemas.js +5 -18
  38. package/dist/team/schemas.js.map +1 -1
  39. package/dist/team/service.js +80 -157
  40. package/dist/team/service.js.map +1 -1
  41. package/dist/trusted-origins.js +25 -0
  42. package/dist/trusted-origins.js.map +1 -0
  43. package/dist/workspace/errors.js +4 -14
  44. package/dist/workspace/errors.js.map +1 -1
  45. package/dist/workspace/schemas.js +2 -18
  46. package/dist/workspace/schemas.js.map +1 -1
  47. package/dist/workspace/service.js +31 -91
  48. package/dist/workspace/service.js.map +1 -1
  49. package/dist/workspace-bootstrap.js +33 -0
  50. package/dist/workspace-bootstrap.js.map +1 -0
  51. package/dist/workspace-init.js +46 -51
  52. package/dist/workspace-init.js.map +1 -1
  53. package/dist/workspace-slug.js +29 -0
  54. package/dist/workspace-slug.js.map +1 -0
  55. package/package.json +36 -14
  56. package/src/client.ts +6 -16
  57. package/src/edge.ts +22 -28
  58. package/src/guard-error.ts +36 -0
  59. package/src/helpers.ts +99 -114
  60. package/src/impersonation.ts +52 -17
  61. package/src/index.ts +15 -10
  62. package/src/invitation-links.ts +15 -0
  63. package/src/onboarding/errors.ts +5 -17
  64. package/src/onboarding/schemas.ts +3 -15
  65. package/src/onboarding/service.ts +11 -65
  66. package/src/org-api.ts +9 -66
  67. package/src/profile/errors.ts +5 -17
  68. package/src/profile/schemas.ts +10 -10
  69. package/src/profile/service.ts +39 -50
  70. package/src/roles.ts +36 -5
  71. package/src/server.ts +86 -104
  72. package/src/team/errors.ts +4 -13
  73. package/src/team/schemas.ts +5 -18
  74. package/src/team/service.ts +100 -174
  75. package/src/trusted-origins.ts +26 -0
  76. package/src/workspace/errors.ts +4 -14
  77. package/src/workspace/schemas.ts +2 -18
  78. package/src/workspace/service.ts +40 -92
  79. package/src/workspace-bootstrap.ts +57 -0
  80. package/src/workspace-init.ts +55 -56
  81. package/src/workspace-slug.ts +32 -0
@@ -1,13 +1,6 @@
1
1
  /**
2
- * Onboarding service error type.
3
- *
4
- * Mirrors `../team/errors.ts` and `../workspace/errors.ts`: the
5
- * onboarding service (./service.ts) throws this for every failure it
6
- * recognizes rather than returning an ad-hoc `{ success, error }`
7
- * envelope — that shaping is a transport concern (a Server Action, a
8
- * route handler) and belongs one layer up, alongside
9
- * revalidatePath/Sentry/toast/i18n, none of which this package may
10
- * depend on.
2
+ * Thrown for every failure the onboarding service recognizes. Shaping it for a UI
3
+ * (a `{ success, error }` envelope, i18n, revalidation) is the transport's job.
11
4
  */
12
5
 
13
6
  /**
@@ -17,9 +10,7 @@
17
10
  * - `not_found` — the caller's `users` row could not be found.
18
11
  */
19
12
  export type OnboardingServiceErrorCode =
20
- | "forbidden"
21
- | "invalid_input"
22
- | "not_found";
13
+ "forbidden" | "invalid_input" | "not_found";
23
14
 
24
15
  export class OnboardingServiceError extends Error {
25
16
  readonly code: OnboardingServiceErrorCode;
@@ -33,14 +24,11 @@ export class OnboardingServiceError extends Error {
33
24
  this.name = "OnboardingServiceError";
34
25
  this.code = code;
35
26
  if (options?.cause !== undefined) {
36
- // ES2020 target predates the standard `cause` constructor option;
37
- // assign it directly so `instanceof Error` consumers (and Node's
38
- // own error inspection) still see it.
27
+ // The ES2020 target predates the `cause` constructor option.
39
28
  (this as { cause?: unknown }).cause = options.cause;
40
29
  }
41
30
 
42
- // Restore prototype chain (extending built-ins across some
43
- // transpilation targets loses `instanceof`).
31
+ // Extending built-ins loses `instanceof` on some transpilation targets.
44
32
  Object.setPrototypeOf(this, OnboardingServiceError.prototype);
45
33
  }
46
34
  }
@@ -1,19 +1,7 @@
1
1
  /**
2
- * Onboarding validation schema.
3
- *
4
- * A single generic step-id validator — see the module doc comment on
5
- * `./service.ts` for why this is a bare, bounded string rather than a
6
- * product-specific enum. Ignite's original
7
- * `lib/validations/onboarding.ts` pinned this to
8
- * `z.enum(["role", "profile", "complete"])`, Career's own 3-step wizard
9
- * shape. The `users.onboarding_step` column itself is untyped `text`,
10
- * so that enum was a product-level constraint bolted onto a generic
11
- * column, not something this shared contract should require. A
12
- * consumer defines its own step ids (typically the `id` field of a
13
- * steps-config array it owns) and passes them straight through;
14
- * Ignite's cut-over copy keeps its own enum-shaped schema at the
15
- * transport layer if it wants stricter validation than "non-empty,
16
- * bounded string".
2
+ * A step id is a non-empty, bounded string, not an enum: step ids are the
3
+ * consumer's (the `id`s of its own steps config). A consumer wanting
4
+ * stricter validation keeps its own schema at the transport layer.
17
5
  */
18
6
 
19
7
  import { z } from "zod";
@@ -1,56 +1,9 @@
1
1
  /**
2
- * Onboarding service — the durable business rules behind a user's
3
- * first-run onboarding flow, extracted from the product application's
4
- * onboarding actions (page/registry migration, `onboarding` family,
5
- * roadmap item 11).
6
- *
7
- * Unlike `../team/service.ts` and `../workspace/service.ts`, this
8
- * service has no Better-Auth organization-plugin calls to make — it
9
- * reads and writes exactly two columns on `@intelligo-dev/core`'s `users`
10
- * table (`onboardingCompleted`, `onboardingStep`) directly via Drizzle.
11
- *
12
- * No ports: `complete()` and `skip()` only flip those two columns and
13
- * return the resulting state — they do NOT provision trial credits,
14
- * record referrals, or perform any other first-workspace bootstrapping.
15
- * Ignite's original `completeOnboarding()` action did exactly that
16
- * (`provisionTrialCredits`/`recordReferralSignup`/`grantReferralUpgrade`
17
- * inline), but that work belongs to the consumer's own
18
- * `onWorkspaceCreated` binding instead — see `../workspace-init.ts`'s
19
- * `onWorkspaceCreated` port and the `app-shell` registry item's
20
- * `lib/workspace-bootstrap.ts`, which already runs once per new
21
- * workspace. Duplicating it here would either double-provision (it
22
- * would fire again on every onboarding completion, not just the first
23
- * workspace) or force this package to depend on `@intelligo-dev/billing`,
24
- * which the allowlist in
25
- * `tests/architecture/dependency-direction.test.ts` forbids.
26
- *
27
- * ---------------------------------------------------------------------
28
- * Why `setStep` takes a bare `string`, not an enum
29
- * ---------------------------------------------------------------------
30
- * See the doc comment on `./schemas.ts`'s `setStepSchema`. Short
31
- * version: the `onboarding_step` column is untyped `text`, and a
32
- * framework-owned service can't know a consumer's step ids in advance
33
- * — a different product may have two steps, or six. This module
34
- * accepts any non-empty string up to 64 characters and persists it
35
- * verbatim.
36
- *
37
- * ---------------------------------------------------------------------
38
- * Why `skip()` has the same durable effect as `complete()`
39
- * ---------------------------------------------------------------------
40
- * The `users` table has no separate "skipped" column, so there is
41
- * nothing else to persist. Ignite's original `skipOnboarding()` action
42
- * logged an analytics event and then delegated to `completeOnboarding()`
43
- * unchanged; this service mirrors that shape as two distinct methods
44
- * (rather than collapsing `skip` into an alias) so a transport can
45
- * still log/tag the skip differently before or after calling it —
46
- * that shaping, like everything else transport-level, does not belong
47
- * in this service.
48
- *
49
- * Authorization (`requireAuth`) lives INSIDE each method, not at the
50
- * transport. Every recognized failure throws `OnboardingServiceError`
51
- * with a stable `code` — no revalidatePath/Sentry/next-intl/toast here;
52
- * that shaping is the transport's job (a Server Action, a route
53
- * handler).
2
+ * The caller's first-run onboarding state: two columns on `users`
3
+ * (`onboardingCompleted`, `onboardingStep`). Completing onboarding has no
4
+ * trial or credit side effects; first-workspace bootstrapping runs once per
5
+ * workspace through `ensureUserWorkspace`'s `onWorkspaceCreated`. Each method
6
+ * authorizes itself and throws `OnboardingServiceError`.
54
7
  */
55
8
 
56
9
  import { eq } from "drizzle-orm";
@@ -88,9 +41,7 @@ export function createOnboardingService() {
88
41
  }
89
42
  }
90
43
 
91
- /**
92
- * Get the caller's current onboarding state.
93
- */
44
+ /** The caller's current onboarding state. */
94
45
  async function getState(): Promise<OnboardingState> {
95
46
  const { user } = await callRequireAuth();
96
47
 
@@ -114,9 +65,8 @@ export function createOnboardingService() {
114
65
  }
115
66
 
116
67
  /**
117
- * Set the caller's current onboarding step. A product defines its
118
- * own step ids (see the module doc comment); this only validates
119
- * that `step` is a non-empty, bounded string and persists it
68
+ * Set the caller's current step. Step ids are the product's; this only
69
+ * checks for a non-empty string of at most 64 characters and persists it
120
70
  * verbatim. Does not touch `onboardingCompleted`.
121
71
  */
122
72
  async function setStep(step: string): Promise<OnboardingState> {
@@ -140,10 +90,7 @@ export function createOnboardingService() {
140
90
  return { completed: false, currentStep: parsed.data };
141
91
  }
142
92
 
143
- /**
144
- * Mark onboarding complete and clear the step. No trial/referral
145
- * side effects — see the module doc comment.
146
- */
93
+ /** Mark onboarding complete and clear the step. */
147
94
  async function complete(): Promise<OnboardingState> {
148
95
  const { user } = await callRequireAuth();
149
96
 
@@ -160,9 +107,8 @@ export function createOnboardingService() {
160
107
  }
161
108
 
162
109
  /**
163
- * Skip onboarding. Same durable effect as `complete()` — see the
164
- * module doc comment for why this is its own method rather than an
165
- * alias.
110
+ * Skip onboarding. Same durable effect as `complete()` (there is no
111
+ * "skipped" column); a separate method so a transport can tell them apart.
166
112
  */
167
113
  async function skip(): Promise<OnboardingState> {
168
114
  return complete();
package/src/org-api.ts CHANGED
@@ -1,62 +1,10 @@
1
1
  /**
2
- * Typed wrapper for Better-Auth organization plugin endpoints.
3
- *
4
- * The plugin endpoints (`/organization/...`) are added at runtime by
5
- * Better-Auth's organization plugin and are not present on the inferred
6
- * `auth.api` type. Without a wrapper, consumers had to reach for
7
- * `as any as Record<...>` casts or `@ts-expect-error` comments — both
8
- * lose all type information.
9
- *
10
- * This module declares the explicit input/output shapes for every
11
- * organization endpoint the team service calls and exposes them under
12
- * the plugin's HTTP path names (`/organization/invite-member`, etc.),
13
- * which is the vocabulary the rest of this package's `team/` module
14
- * uses.
15
- *
16
- * (Ported from the product application’s Better-Auth type wrapper as part of the
17
- * team-settings backend extraction — packages/auth owns the org-api
18
- * contract now; ignite's copy is retired at cutover.)
19
- *
20
- * IMPORTANT — found while wiring the real-DB integration test for this
21
- * extraction: ignite's original module built `orgApi` as
22
- * `auth.api as unknown as OrgApi`, i.e. a bare type-cast that assumes
23
- * `auth.api` is keyed by these HTTP path strings. It is not. Better-Auth's
24
- * organization plugin (`better-auth@1.6.30`,
25
- * `plugins/organization/organization.mjs`) exposes each endpoint on
26
- * `auth.api` under its own camelCase *server* id, which does not always
27
- * match the path or the *client* SDK name documented alongside it:
28
- *
29
- * path | auth.api key (server) | authClient.organization.* (client)
30
- * /organization/invite-member | createInvitation | inviteMember
31
- * /organization/get-invitation | getInvitation | getInvitation
32
- * /organization/accept-invitation | acceptInvitation | acceptInvitation
33
- * /organization/reject-invitation | rejectInvitation | rejectInvitation
34
- * /organization/cancel-invitation | cancelInvitation | cancelInvitation
35
- * /organization/remove-member | removeMember | removeMember
36
- * /organization/update-member-role | updateMemberRole | updateMemberRole
37
- * /organization/leave | leaveOrganization | leave
38
- * /organization/list | listOrganizations | list
39
- * /organization/set-active | setActiveOrganization | setActive
40
- * /organization/list-user-invitations | listUserInvitations | listUserInvitations
41
- *
42
- * A bare `auth.api as unknown as OrgApi` cast type-checks (TypeScript
43
- * cannot see through the cast) but throws `TypeError: ... is not a
44
- * function` at runtime for every call whose row above differs in the
45
- * first two columns — i.e. invite-member, leave, list, and set-active
46
- * unconditionally, since their server ids aren't just a casing change
47
- * of the path. This was invisible in ignite's test suite because
48
- * `actions/__tests__/team.test.ts` mocks `@/types/better-auth` (the
49
- * whole `orgApi` object) rather than exercising the cast against a
50
- * real `auth.api`, and only surfaced once this extraction's
51
- * `service.integration.test.ts` called the real thing. It is a live
52
- * bug in ignite's shipped team-management actions today, not a
53
- * hypothetical — worth a fix there independent of this migration.
54
- *
55
- * `orgApi` below is therefore a real object, not a cast: each path key
56
- * forwards to the correctly-named `auth.api` method. The `OrgApi`
57
- * type and every call site elsewhere in this package (`team/service.ts`
58
- * and its tests) are unaffected — they only ever see the path-keyed
59
- * shape.
2
+ * Typed access to Better-Auth's organization-plugin endpoints, which the
3
+ * inferred `auth.api` type does not include. Keys are the plugin's HTTP
4
+ * paths; each forwards to the plugin's server id, which is not always the
5
+ * path in camelCase (`/organization/invite-member` is `createInvitation`,
6
+ * `/organization/list` is `listOrganizations`), so a blanket cast of
7
+ * `auth.api` would type-check and then throw "is not a function".
60
8
  */
61
9
 
62
10
  import { auth } from "./server";
@@ -160,14 +108,9 @@ export interface OrgApi {
160
108
  }
161
109
 
162
110
  /**
163
- * `auth.api`'s organization-plugin methods are typed loosely by
164
- * Better-Auth (broad `Record<string, unknown>`-ish body/query types
165
- * driven by its own zod schemas) — each is cast to its specific
166
- * `OrgApi` member signature at the point of use below, which is the
167
- * same trust boundary the rest of this codebase already accepts for
168
- * these calls (see the module doc comment above for why a *blanket*
169
- * cast is not safe: it hides the wrong key entirely, whereas casting
170
- * per-member here only relaxes the parameter/return types).
111
+ * Better-Auth types these methods loosely; each is cast to its `OrgApi`
112
+ * member signature below, which relaxes only parameter and return types,
113
+ * never which method a key names.
171
114
  */
172
115
  const api = auth.api as unknown as Record<
173
116
  string,
@@ -1,13 +1,6 @@
1
1
  /**
2
- * Profile service error type.
3
- *
4
- * The profile service (./service.ts) throws this for every failure it
5
- * recognizes rather than returning an ad-hoc `{ success, error }`
6
- * envelope — that shaping is a transport concern (a Server Action, a
7
- * route handler) and belongs one layer up, alongside
8
- * revalidatePath/Sentry/toast/i18n, none of which this package may
9
- * depend on. Mirrors `TeamServiceError`/`WorkspaceServiceError`
10
- * (../team/errors.ts, ../workspace/errors.ts).
2
+ * Thrown for every failure the profile service recognizes. Shaping it for a UI
3
+ * (a `{ success, error }` envelope, i18n, revalidation) is the transport's job.
11
4
  */
12
5
 
13
6
  /**
@@ -17,9 +10,7 @@
17
10
  * failed (network, upstream API error, etc.).
18
11
  */
19
12
  export type ProfileServiceErrorCode =
20
- | "forbidden"
21
- | "invalid_input"
22
- | "provider_error";
13
+ "forbidden" | "invalid_input" | "provider_error";
23
14
 
24
15
  export interface ProfileServiceErrorMeta {
25
16
  [key: string]: unknown;
@@ -39,14 +30,11 @@ export class ProfileServiceError extends Error {
39
30
  this.code = code;
40
31
  this.meta = options?.meta;
41
32
  if (options?.cause !== undefined) {
42
- // ES2020 target predates the standard `cause` constructor option;
43
- // assign it directly so `instanceof Error` consumers (and Node's
44
- // own error inspection) still see it.
33
+ // The ES2020 target predates the `cause` constructor option.
45
34
  (this as { cause?: unknown }).cause = options.cause;
46
35
  }
47
36
 
48
- // Restore prototype chain (extending built-ins across some
49
- // transpilation targets loses `instanceof`).
37
+ // Extending built-ins loses `instanceof` on some transpilation targets.
50
38
  Object.setPrototypeOf(this, ProfileServiceError.prototype);
51
39
  }
52
40
  }
@@ -1,13 +1,4 @@
1
- /**
2
- * Profile Validation Schemas
3
- *
4
- * Zod schemas for user profile updates, shared by the profile service
5
- * and its transports.
6
- *
7
- * (Ported from the product application's `lib/validations/profile.ts` —
8
- * same semantics: optional name, optional-and-nullable image URL.
9
- * Ignite's copy is retired at cutover.)
10
- */
1
+ /** Profile update input, shared by the profile service and its transports. */
11
2
 
12
3
  import { z } from "zod";
13
4
 
@@ -21,3 +12,12 @@ export const updateProfileSchema = z.object({
21
12
  });
22
13
 
23
14
  export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
15
+
16
+ /**
17
+ * A BCP 47 language tag as next-intl routes it ("en", "mn", "pt-BR").
18
+ * Which tags an app actually ships is the app's `routing.locales`; the
19
+ * transport checks against that before calling the service.
20
+ */
21
+ export const preferredLanguageSchema = z
22
+ .string()
23
+ .regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "Not a language tag");
@@ -1,49 +1,11 @@
1
1
  /**
2
- * Profile management service — the durable business rules behind
3
- * reading the caller's own profile, updating it, and deleting the
4
- * account, extracted from the product application's profile actions
5
- * (page/registry migration, `profile-settings` family, roadmap item 9).
6
- *
7
- * Mirrors `createTeamService(ports)` / `createWorkspaceService(ports)`
8
- * (../team/service.ts, ../workspace/service.ts) one directory over: a
9
- * factory over optional ports, so this package's allowlisted dependency
10
- * (`@intelligo-dev/core` only — see
11
- * tests/architecture/dependency-direction.test.ts) never grows to
12
- * include an email-sending concern of its own. A consumer binds the
13
- * account-deletion confirmation email in at its composition root:
14
- *
15
- * const profileService = createProfileService({
16
- * onAccountDeleted: ({ email, name }) =>
17
- * sendEmail({ to: email, subject: ..., html: ... }), // @intelligo-dev/core/email
18
- * });
19
- *
20
- * Authorization (`requireAuth`) lives INSIDE each method, not at the
21
- * transport. Every recognized failure throws `ProfileServiceError` with
22
- * a stable `code` — no revalidatePath/Sentry/next-intl/toast here; that
23
- * shaping is the transport's job (a Server Action, a route handler).
24
- *
25
- * ---------------------------------------------------------------------
26
- * Why the confirmation email is a port, not a direct import
27
- * ---------------------------------------------------------------------
28
- * The product application's original `deleteAccount()`
29
- * calls `sendEmail` from
30
- * `@intelligo-dev/core/email` directly, inline, with a hardcoded English
31
- * HTML template. `@intelligo-dev/auth` already imports `@intelligo-dev/core/email`
32
- * elsewhere (`server.ts`'s Better-Auth hooks — verification, password
33
- * reset, welcome, invitation emails), so nothing in the allowlist
34
- * (auth → core only) would technically block importing `sendEmail`
35
- * here too. The port exists anyway, for the same reason
36
- * `TeamServicePorts.sendInvitationEmail` and
37
- * `WorkspaceServicePorts.checkWorkspaceLimit` are ports rather than
38
- * direct calls: this service should not own *content* — copy, subject
39
- * lines, template shape — for a side effect a consumer may want to
40
- * localize, skip, or replace with a different provider. `onAccountDeleted`
41
- * is fire-and-forget by design (matching ignite's `.catch(console.error)`
42
- * pattern): a failed confirmation email must never block the deletion
43
- * that already succeeded.
2
+ * The caller's own profile: read, update, delete the account. Each method
3
+ * authorizes itself and throws `ProfileServiceError`. The confirmation email
4
+ * is the `onAccountDeleted` port so its copy and provider stay the
5
+ * consumer's; it is fire-and-forget, since the deletion has already succeeded.
44
6
  */
45
7
 
46
- import { headers } from "next/headers";
8
+ import { getRequestHeaders } from "@intelligo-dev/core/request-context";
47
9
  import type { ZodType } from "zod";
48
10
  import { eq } from "drizzle-orm";
49
11
  import { createLogger } from "@intelligo-dev/core/logger";
@@ -52,7 +14,11 @@ import { users, sessions } from "@intelligo-dev/core/db/schema";
52
14
 
53
15
  import { auth } from "../server";
54
16
  import { requireAuth } from "../helpers";
55
- import { updateProfileSchema, type UpdateProfileInput } from "./schemas";
17
+ import {
18
+ preferredLanguageSchema,
19
+ updateProfileSchema,
20
+ type UpdateProfileInput,
21
+ } from "./schemas";
56
22
  import { ProfileServiceError, isProfileServiceError } from "./errors";
57
23
 
58
24
  const log = createLogger("ProfileService");
@@ -114,9 +80,7 @@ export function createProfileService(ports: ProfileServicePorts = {}) {
114
80
  }
115
81
  }
116
82
 
117
- /**
118
- * Get the caller's own profile.
119
- */
83
+ /** The caller's own profile. */
120
84
  async function getProfile(): Promise<ProfileRecord> {
121
85
  const { user } = await callRequireAuth();
122
86
 
@@ -133,13 +97,12 @@ export function createProfileService(ports: ProfileServicePorts = {}) {
133
97
  * Update the caller's own name/image via the Better-Auth user API.
134
98
  * Only the fields present in `input` are sent — omitting a field
135
99
  * leaves it unchanged; `image: null` is dropped rather than forwarded
136
- * (Better-Auth's `updateUser` does not accept `null`), matching
137
- * ignite's original action.
100
+ * (Better-Auth's `updateUser` does not accept `null`).
138
101
  */
139
102
  async function updateProfile(input: UpdateProfileInput): Promise<void> {
140
103
  await callRequireAuth();
141
104
  const validated = parseInput(updateProfileSchema, input);
142
- const hdrs = await headers();
105
+ const hdrs = await getRequestHeaders();
143
106
 
144
107
  const updateData: { name?: string; image?: string } = {};
145
108
  if (validated.name !== undefined) updateData.name = validated.name;
@@ -159,6 +122,31 @@ export function createProfileService(ports: ProfileServicePorts = {}) {
159
122
  }
160
123
  }
161
124
 
125
+ /**
126
+ * Record the caller's preferred language (`users.preferred_language`),
127
+ * which emails and a returning session's default locale read. A column
128
+ * Intelligo owns rather than a Better-Auth user field, so it is written
129
+ * directly.
130
+ */
131
+ async function setPreferredLanguage(locale: string): Promise<void> {
132
+ const { user } = await callRequireAuth();
133
+ const validated = parseInput(preferredLanguageSchema, locale);
134
+
135
+ try {
136
+ await db
137
+ .update(users)
138
+ .set({ preferredLanguage: validated, updatedAt: new Date() })
139
+ .where(eq(users.id, user.id));
140
+ } catch (error) {
141
+ log.error("setPreferredLanguage failed", { error: errorMessage(error) });
142
+ throw new ProfileServiceError(
143
+ "provider_error",
144
+ "Failed to update the preferred language",
145
+ { cause: error }
146
+ );
147
+ }
148
+ }
149
+
162
150
  /**
163
151
  * Delete the caller's own account: soft delete (`users.deletedAt`),
164
152
  * invalidate every session (force logout), then fire the
@@ -201,6 +189,7 @@ export function createProfileService(ports: ProfileServicePorts = {}) {
201
189
  return {
202
190
  getProfile,
203
191
  updateProfile,
192
+ setPreferredLanguage,
204
193
  deleteAccount,
205
194
  };
206
195
  }
package/src/roles.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  /**
2
- * The platform role.
3
- *
4
- * Deliberately a separate module from helpers.ts and server.ts: both
5
- * need the constant, and server.ts (the Better-Auth instance) cannot
6
- * import helpers.ts, which imports server.ts.
2
+ * Its own module because server.ts and helpers.ts both need it, and
3
+ * server.ts cannot import helpers.ts, which imports server.ts.
7
4
  */
8
5
 
9
6
  /**
@@ -15,3 +12,37 @@
15
12
  * on it is gated on nothing.
16
13
  */
17
14
  export const PLATFORM_ADMIN_ROLE = "platform-admin";
15
+
16
+ /**
17
+ * Where a user stands as a platform admin: `hasRole` reads `users.role`
18
+ * (comma-separated), `allowlisted` reads PLATFORM_ADMIN_EMAILS, the
19
+ * bootstrap that `requirePlatformAdmin` promotes into the column.
20
+ *
21
+ * The allowlist names an address, so it counts only once the address
22
+ * is verified: otherwise whoever registers an allowlisted address
23
+ * first — before the real admin, or on a deployment that sends no
24
+ * verification mail — would become an admin.
25
+ */
26
+ export function platformAdminStanding(user: {
27
+ email?: string | null;
28
+ emailVerified?: boolean | null;
29
+ role?: string | null;
30
+ }): { allowlisted: boolean; hasRole: boolean; roles: string[] } {
31
+ const allowlist = (process.env.PLATFORM_ADMIN_EMAILS ?? "")
32
+ .split(",")
33
+ .map((e) => e.trim().toLowerCase())
34
+ .filter(Boolean);
35
+
36
+ const email = user.email?.toLowerCase();
37
+ const roles = (user.role ?? "")
38
+ .split(",")
39
+ .map((r) => r.trim())
40
+ .filter(Boolean);
41
+
42
+ return {
43
+ allowlisted:
44
+ !!email && user.emailVerified === true && allowlist.includes(email),
45
+ hasRole: roles.includes(PLATFORM_ADMIN_ROLE),
46
+ roles,
47
+ };
48
+ }