@intelligo-dev/auth 1.0.0-beta.1

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/dist/client.js +25 -0
  3. package/dist/client.js.map +1 -0
  4. package/dist/edge.js +39 -0
  5. package/dist/edge.js.map +1 -0
  6. package/dist/helpers.js +248 -0
  7. package/dist/helpers.js.map +1 -0
  8. package/dist/impersonation.js +42 -0
  9. package/dist/impersonation.js.map +1 -0
  10. package/dist/index.js +30 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/onboarding/errors.js +31 -0
  13. package/dist/onboarding/errors.js.map +1 -0
  14. package/dist/onboarding/schemas.js +24 -0
  15. package/dist/onboarding/schemas.js.map +1 -0
  16. package/dist/onboarding/service.js +147 -0
  17. package/dist/onboarding/service.js.map +1 -0
  18. package/dist/org-api.js +86 -0
  19. package/dist/org-api.js.map +1 -0
  20. package/dist/profile/errors.js +32 -0
  21. package/dist/profile/errors.js.map +1 -0
  22. package/dist/profile/schemas.js +20 -0
  23. package/dist/profile/schemas.js.map +1 -0
  24. package/dist/profile/service.js +157 -0
  25. package/dist/profile/service.js.map +1 -0
  26. package/dist/roles.js +17 -0
  27. package/dist/roles.js.map +1 -0
  28. package/dist/server.js +251 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/team/errors.js +31 -0
  31. package/dist/team/errors.js.map +1 -0
  32. package/dist/team/schemas.js +29 -0
  33. package/dist/team/schemas.js.map +1 -0
  34. package/dist/team/service.js +438 -0
  35. package/dist/team/service.js.map +1 -0
  36. package/dist/workspace/errors.js +32 -0
  37. package/dist/workspace/errors.js.map +1 -0
  38. package/dist/workspace/schemas.js +45 -0
  39. package/dist/workspace/schemas.js.map +1 -0
  40. package/dist/workspace/service.js +268 -0
  41. package/dist/workspace/service.js.map +1 -0
  42. package/dist/workspace-init.js +121 -0
  43. package/dist/workspace-init.js.map +1 -0
  44. package/package.json +58 -0
  45. package/src/client.ts +27 -0
  46. package/src/edge.ts +43 -0
  47. package/src/helpers.ts +317 -0
  48. package/src/impersonation.ts +57 -0
  49. package/src/index.ts +109 -0
  50. package/src/onboarding/errors.ts +52 -0
  51. package/src/onboarding/schemas.ts +27 -0
  52. package/src/onboarding/service.ts +174 -0
  53. package/src/org-api.ts +198 -0
  54. package/src/profile/errors.ts +58 -0
  55. package/src/profile/schemas.ts +23 -0
  56. package/src/profile/service.ts +208 -0
  57. package/src/roles.ts +17 -0
  58. package/src/server.ts +305 -0
  59. package/src/team/errors.ts +71 -0
  60. package/src/team/schemas.ts +35 -0
  61. package/src/team/service.ts +611 -0
  62. package/src/workspace/errors.ts +69 -0
  63. package/src/workspace/schemas.ts +57 -0
  64. package/src/workspace/service.ts +381 -0
  65. package/src/workspace-init.ts +140 -0
@@ -0,0 +1,174 @@
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).
54
+ */
55
+
56
+ import { eq } from "drizzle-orm";
57
+ import { db } from "@intelligo-dev/core/db";
58
+ import { users } from "@intelligo-dev/core/db/schema";
59
+
60
+ import { requireAuth } from "../helpers";
61
+ import { setStepSchema } from "./schemas";
62
+ import { OnboardingServiceError, isOnboardingServiceError } from "./errors";
63
+
64
+ /** The caller's onboarding progress. */
65
+ export interface OnboardingState {
66
+ completed: boolean;
67
+ currentStep: string | null;
68
+ }
69
+
70
+ function errorMessage(error: unknown): string {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+
74
+ /** Maps requireAuth failures to `forbidden`. */
75
+ function toForbidden(error: unknown): OnboardingServiceError {
76
+ if (isOnboardingServiceError(error)) return error;
77
+ return new OnboardingServiceError("forbidden", errorMessage(error), {
78
+ cause: error,
79
+ });
80
+ }
81
+
82
+ export function createOnboardingService() {
83
+ async function callRequireAuth() {
84
+ try {
85
+ return await requireAuth();
86
+ } catch (error) {
87
+ throw toForbidden(error);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Get the caller's current onboarding state.
93
+ */
94
+ async function getState(): Promise<OnboardingState> {
95
+ const { user } = await callRequireAuth();
96
+
97
+ const [row] = await db
98
+ .select({
99
+ onboardingCompleted: users.onboardingCompleted,
100
+ onboardingStep: users.onboardingStep,
101
+ })
102
+ .from(users)
103
+ .where(eq(users.id, user.id))
104
+ .limit(1);
105
+
106
+ if (!row) {
107
+ throw new OnboardingServiceError("not_found", "User not found");
108
+ }
109
+
110
+ return {
111
+ completed: row.onboardingCompleted,
112
+ currentStep: row.onboardingStep,
113
+ };
114
+ }
115
+
116
+ /**
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
120
+ * verbatim. Does not touch `onboardingCompleted`.
121
+ */
122
+ async function setStep(step: string): Promise<OnboardingState> {
123
+ const { user } = await callRequireAuth();
124
+
125
+ const parsed = setStepSchema.safeParse(step);
126
+ if (!parsed.success) {
127
+ throw new OnboardingServiceError(
128
+ "invalid_input",
129
+ parsed.error.issues.map((issue) => issue.message).join("; ") ||
130
+ "Invalid step",
131
+ { cause: parsed.error }
132
+ );
133
+ }
134
+
135
+ await db
136
+ .update(users)
137
+ .set({ onboardingStep: parsed.data, updatedAt: new Date() })
138
+ .where(eq(users.id, user.id));
139
+
140
+ return { completed: false, currentStep: parsed.data };
141
+ }
142
+
143
+ /**
144
+ * Mark onboarding complete and clear the step. No trial/referral
145
+ * side effects — see the module doc comment.
146
+ */
147
+ async function complete(): Promise<OnboardingState> {
148
+ const { user } = await callRequireAuth();
149
+
150
+ await db
151
+ .update(users)
152
+ .set({
153
+ onboardingCompleted: true,
154
+ onboardingStep: null,
155
+ updatedAt: new Date(),
156
+ })
157
+ .where(eq(users.id, user.id));
158
+
159
+ return { completed: true, currentStep: null };
160
+ }
161
+
162
+ /**
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.
166
+ */
167
+ async function skip(): Promise<OnboardingState> {
168
+ return complete();
169
+ }
170
+
171
+ return { getState, setStep, complete, skip };
172
+ }
173
+
174
+ export type OnboardingService = ReturnType<typeof createOnboardingService>;
package/src/org-api.ts ADDED
@@ -0,0 +1,198 @@
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.
60
+ */
61
+
62
+ import { auth } from "./server";
63
+
64
+ export interface OrgEndpointOptions<
65
+ TBody = Record<string, unknown>,
66
+ TQuery = Record<string, unknown>,
67
+ > {
68
+ headers: Headers;
69
+ body?: TBody;
70
+ query?: TQuery;
71
+ }
72
+
73
+ export type OrgRole = "owner" | "admin" | "member";
74
+
75
+ export interface OrgListItem {
76
+ id: string;
77
+ name: string;
78
+ slug?: string;
79
+ }
80
+
81
+ export interface OrgInvitation {
82
+ id: string;
83
+ email: string;
84
+ role: OrgRole;
85
+ status: "pending" | "accepted" | "rejected" | "canceled";
86
+ expiresAt: string | Date;
87
+ organizationId: string;
88
+ organizationName?: string;
89
+ inviterEmail?: string;
90
+ inviterId?: string;
91
+ }
92
+
93
+ /**
94
+ * Minimal member shape read off `auth.api.getFullOrganization()`.
95
+ * `getFullOrganization` is not part of the org-plugin's typed
96
+ * `/organization/...` surface — it lives on the base `auth.api` — so
97
+ * it is called directly rather than through `orgApi` below.
98
+ */
99
+ export interface OrgMember {
100
+ id?: string;
101
+ userId: string;
102
+ role: string;
103
+ }
104
+
105
+ export interface OrgApi {
106
+ "/organization/invite-member": (
107
+ opts: OrgEndpointOptions<{
108
+ email: string;
109
+ role: Exclude<OrgRole, "owner">;
110
+ organizationId: string;
111
+ }>
112
+ ) => Promise<OrgInvitation | null>;
113
+
114
+ "/organization/get-invitation": (
115
+ opts: OrgEndpointOptions<never, { id: string }>
116
+ ) => Promise<OrgInvitation>;
117
+
118
+ "/organization/accept-invitation": (
119
+ opts: OrgEndpointOptions<{ invitationId: string }>
120
+ ) => Promise<unknown>;
121
+
122
+ "/organization/reject-invitation": (
123
+ opts: OrgEndpointOptions<{ invitationId: string }>
124
+ ) => Promise<unknown>;
125
+
126
+ "/organization/cancel-invitation": (
127
+ opts: OrgEndpointOptions<{ invitationId: string }>
128
+ ) => Promise<unknown>;
129
+
130
+ "/organization/remove-member": (
131
+ opts: OrgEndpointOptions<{
132
+ memberIdOrEmail: string;
133
+ organizationId: string;
134
+ }>
135
+ ) => Promise<unknown>;
136
+
137
+ "/organization/update-member-role": (
138
+ opts: OrgEndpointOptions<{
139
+ memberId: string;
140
+ role: OrgRole;
141
+ organizationId: string;
142
+ }>
143
+ ) => Promise<unknown>;
144
+
145
+ "/organization/leave": (
146
+ opts: OrgEndpointOptions<{ organizationId: string }>
147
+ ) => Promise<unknown>;
148
+
149
+ "/organization/list": (
150
+ opts: OrgEndpointOptions
151
+ ) => Promise<OrgListItem[] | null>;
152
+
153
+ "/organization/set-active": (
154
+ opts: OrgEndpointOptions<{ organizationId: string }>
155
+ ) => Promise<unknown>;
156
+
157
+ "/organization/list-user-invitations": (
158
+ opts: OrgEndpointOptions
159
+ ) => Promise<OrgInvitation[] | null>;
160
+ }
161
+
162
+ /**
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).
171
+ */
172
+ const api = auth.api as unknown as Record<
173
+ string,
174
+ (opts: OrgEndpointOptions<never, never>) => Promise<unknown>
175
+ >;
176
+
177
+ export const orgApi: OrgApi = {
178
+ "/organization/invite-member":
179
+ api.createInvitation as OrgApi["/organization/invite-member"],
180
+ "/organization/get-invitation":
181
+ api.getInvitation as OrgApi["/organization/get-invitation"],
182
+ "/organization/accept-invitation":
183
+ api.acceptInvitation as OrgApi["/organization/accept-invitation"],
184
+ "/organization/reject-invitation":
185
+ api.rejectInvitation as OrgApi["/organization/reject-invitation"],
186
+ "/organization/cancel-invitation":
187
+ api.cancelInvitation as OrgApi["/organization/cancel-invitation"],
188
+ "/organization/remove-member":
189
+ api.removeMember as OrgApi["/organization/remove-member"],
190
+ "/organization/update-member-role":
191
+ api.updateMemberRole as OrgApi["/organization/update-member-role"],
192
+ "/organization/leave": api.leaveOrganization as OrgApi["/organization/leave"],
193
+ "/organization/list": api.listOrganizations as OrgApi["/organization/list"],
194
+ "/organization/set-active":
195
+ api.setActiveOrganization as OrgApi["/organization/set-active"],
196
+ "/organization/list-user-invitations":
197
+ api.listUserInvitations as OrgApi["/organization/list-user-invitations"],
198
+ };
@@ -0,0 +1,58 @@
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).
11
+ */
12
+
13
+ /**
14
+ * - `forbidden` — the caller is unauthenticated.
15
+ * - `invalid_input` — schema validation failed.
16
+ * - `provider_error` — the underlying Better-Auth user API call itself
17
+ * failed (network, upstream API error, etc.).
18
+ */
19
+ export type ProfileServiceErrorCode =
20
+ | "forbidden"
21
+ | "invalid_input"
22
+ | "provider_error";
23
+
24
+ export interface ProfileServiceErrorMeta {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export class ProfileServiceError extends Error {
29
+ readonly code: ProfileServiceErrorCode;
30
+ readonly meta?: ProfileServiceErrorMeta;
31
+
32
+ constructor(
33
+ code: ProfileServiceErrorCode,
34
+ message: string,
35
+ options?: { meta?: ProfileServiceErrorMeta; cause?: unknown }
36
+ ) {
37
+ super(message);
38
+ this.name = "ProfileServiceError";
39
+ this.code = code;
40
+ this.meta = options?.meta;
41
+ 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.
45
+ (this as { cause?: unknown }).cause = options.cause;
46
+ }
47
+
48
+ // Restore prototype chain (extending built-ins across some
49
+ // transpilation targets loses `instanceof`).
50
+ Object.setPrototypeOf(this, ProfileServiceError.prototype);
51
+ }
52
+ }
53
+
54
+ export function isProfileServiceError(
55
+ error: unknown
56
+ ): error is ProfileServiceError {
57
+ return error instanceof ProfileServiceError;
58
+ }
@@ -0,0 +1,23 @@
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
+ */
11
+
12
+ import { z } from "zod";
13
+
14
+ export const updateProfileSchema = z.object({
15
+ name: z
16
+ .string()
17
+ .min(2, "Name must be at least 2 characters")
18
+ .max(100, "Name must be at most 100 characters")
19
+ .optional(),
20
+ image: z.string().url("Image must be a valid URL").optional().nullable(),
21
+ });
22
+
23
+ export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
@@ -0,0 +1,208 @@
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.
44
+ */
45
+
46
+ import { headers } from "next/headers";
47
+ import type { ZodType } from "zod";
48
+ import { eq } from "drizzle-orm";
49
+ import { createLogger } from "@intelligo-dev/core/logger";
50
+ import { db } from "@intelligo-dev/core/db";
51
+ import { users, sessions } from "@intelligo-dev/core/db/schema";
52
+
53
+ import { auth } from "../server";
54
+ import { requireAuth } from "../helpers";
55
+ import { updateProfileSchema, type UpdateProfileInput } from "./schemas";
56
+ import { ProfileServiceError, isProfileServiceError } from "./errors";
57
+
58
+ const log = createLogger("ProfileService");
59
+
60
+ export interface ProfileRecord {
61
+ id: string;
62
+ name: string | null;
63
+ email: string;
64
+ image?: string | null;
65
+ emailVerified: boolean;
66
+ }
67
+
68
+ export type ProfileServicePorts = {
69
+ /**
70
+ * Fired after the account has been soft-deleted and its sessions
71
+ * invalidated. Fire-and-forget from the service's perspective — a
72
+ * failure here is logged by the consumer, never surfaced to the
73
+ * caller of `deleteAccount()` (the deletion has already succeeded).
74
+ * No port ⇒ no confirmation email is sent.
75
+ */
76
+ onAccountDeleted?: (input: {
77
+ userId: string;
78
+ email: string;
79
+ name: string;
80
+ }) => Promise<void>;
81
+ };
82
+
83
+ function errorMessage(error: unknown): string {
84
+ return error instanceof Error ? error.message : String(error);
85
+ }
86
+
87
+ /** Maps requireAuth failures to `forbidden`. */
88
+ function toForbidden(error: unknown): ProfileServiceError {
89
+ if (isProfileServiceError(error)) return error;
90
+ return new ProfileServiceError("forbidden", errorMessage(error), {
91
+ cause: error,
92
+ });
93
+ }
94
+
95
+ function parseInput<T>(schema: ZodType<T>, input: unknown): T {
96
+ const result = schema.safeParse(input);
97
+ if (!result.success) {
98
+ throw new ProfileServiceError(
99
+ "invalid_input",
100
+ result.error.issues.map((issue) => issue.message).join("; ") ||
101
+ "Invalid input",
102
+ { cause: result.error }
103
+ );
104
+ }
105
+ return result.data;
106
+ }
107
+
108
+ export function createProfileService(ports: ProfileServicePorts = {}) {
109
+ async function callRequireAuth() {
110
+ try {
111
+ return await requireAuth();
112
+ } catch (error) {
113
+ throw toForbidden(error);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Get the caller's own profile.
119
+ */
120
+ async function getProfile(): Promise<ProfileRecord> {
121
+ const { user } = await callRequireAuth();
122
+
123
+ return {
124
+ id: user.id,
125
+ name: user.name ?? null,
126
+ email: user.email,
127
+ image: user.image ?? null,
128
+ emailVerified: Boolean(user.emailVerified),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Update the caller's own name/image via the Better-Auth user API.
134
+ * Only the fields present in `input` are sent — omitting a field
135
+ * 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.
138
+ */
139
+ async function updateProfile(input: UpdateProfileInput): Promise<void> {
140
+ await callRequireAuth();
141
+ const validated = parseInput(updateProfileSchema, input);
142
+ const hdrs = await headers();
143
+
144
+ const updateData: { name?: string; image?: string } = {};
145
+ if (validated.name !== undefined) updateData.name = validated.name;
146
+ if (validated.image !== undefined && validated.image !== null) {
147
+ updateData.image = validated.image;
148
+ }
149
+
150
+ try {
151
+ await auth.api.updateUser({ headers: hdrs, body: updateData });
152
+ } catch (error) {
153
+ log.error("updateUser failed", { error: errorMessage(error) });
154
+ throw new ProfileServiceError(
155
+ "provider_error",
156
+ "Failed to update profile",
157
+ { cause: error }
158
+ );
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Delete the caller's own account: soft delete (`users.deletedAt`),
164
+ * invalidate every session (force logout), then fire the
165
+ * `onAccountDeleted` port, if bound, without waiting on it.
166
+ */
167
+ async function deleteAccount(): Promise<void> {
168
+ const { user } = await callRequireAuth();
169
+
170
+ try {
171
+ await db
172
+ .update(users)
173
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
174
+ .where(eq(users.id, user.id));
175
+
176
+ await db.delete(sessions).where(eq(sessions.userId, user.id));
177
+ } catch (error) {
178
+ log.error("deleteAccount failed", { error: errorMessage(error) });
179
+ throw new ProfileServiceError(
180
+ "provider_error",
181
+ "Failed to delete account",
182
+ { cause: error }
183
+ );
184
+ }
185
+
186
+ if (ports.onAccountDeleted) {
187
+ ports
188
+ .onAccountDeleted({
189
+ userId: user.id,
190
+ email: user.email,
191
+ name: user.name || "",
192
+ })
193
+ .catch((err) =>
194
+ log.error("onAccountDeleted port failed", {
195
+ error: errorMessage(err),
196
+ })
197
+ );
198
+ }
199
+ }
200
+
201
+ return {
202
+ getProfile,
203
+ updateProfile,
204
+ deleteAccount,
205
+ };
206
+ }
207
+
208
+ export type ProfileService = ReturnType<typeof createProfileService>;
package/src/roles.ts ADDED
@@ -0,0 +1,17 @@
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.
7
+ */
8
+
9
+ /**
10
+ * Grants the operational console and, through Better-Auth's admin
11
+ * plugin, the ability to impersonate a user for support.
12
+ *
13
+ * Not a workspace role. Workspace `owner` is per-tenant — every
14
+ * self-serve signup owns a workspace — so anything cross-tenant gated
15
+ * on it is gated on nothing.
16
+ */
17
+ export const PLATFORM_ADMIN_ROLE = "platform-admin";