@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.
- package/LICENSE +201 -0
- package/dist/client.js +25 -0
- package/dist/client.js.map +1 -0
- package/dist/edge.js +39 -0
- package/dist/edge.js.map +1 -0
- package/dist/helpers.js +248 -0
- package/dist/helpers.js.map +1 -0
- package/dist/impersonation.js +42 -0
- package/dist/impersonation.js.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/onboarding/errors.js +31 -0
- package/dist/onboarding/errors.js.map +1 -0
- package/dist/onboarding/schemas.js +24 -0
- package/dist/onboarding/schemas.js.map +1 -0
- package/dist/onboarding/service.js +147 -0
- package/dist/onboarding/service.js.map +1 -0
- package/dist/org-api.js +86 -0
- package/dist/org-api.js.map +1 -0
- package/dist/profile/errors.js +32 -0
- package/dist/profile/errors.js.map +1 -0
- package/dist/profile/schemas.js +20 -0
- package/dist/profile/schemas.js.map +1 -0
- package/dist/profile/service.js +157 -0
- package/dist/profile/service.js.map +1 -0
- package/dist/roles.js +17 -0
- package/dist/roles.js.map +1 -0
- package/dist/server.js +251 -0
- package/dist/server.js.map +1 -0
- package/dist/team/errors.js +31 -0
- package/dist/team/errors.js.map +1 -0
- package/dist/team/schemas.js +29 -0
- package/dist/team/schemas.js.map +1 -0
- package/dist/team/service.js +438 -0
- package/dist/team/service.js.map +1 -0
- package/dist/workspace/errors.js +32 -0
- package/dist/workspace/errors.js.map +1 -0
- package/dist/workspace/schemas.js +45 -0
- package/dist/workspace/schemas.js.map +1 -0
- package/dist/workspace/service.js +268 -0
- package/dist/workspace/service.js.map +1 -0
- package/dist/workspace-init.js +121 -0
- package/dist/workspace-init.js.map +1 -0
- package/package.json +58 -0
- package/src/client.ts +27 -0
- package/src/edge.ts +43 -0
- package/src/helpers.ts +317 -0
- package/src/impersonation.ts +57 -0
- package/src/index.ts +109 -0
- package/src/onboarding/errors.ts +52 -0
- package/src/onboarding/schemas.ts +27 -0
- package/src/onboarding/service.ts +174 -0
- package/src/org-api.ts +198 -0
- package/src/profile/errors.ts +58 -0
- package/src/profile/schemas.ts +23 -0
- package/src/profile/service.ts +208 -0
- package/src/roles.ts +17 -0
- package/src/server.ts +305 -0
- package/src/team/errors.ts +71 -0
- package/src/team/schemas.ts +35 -0
- package/src/team/service.ts +611 -0
- package/src/workspace/errors.ts +69 -0
- package/src/workspace/schemas.ts +57 -0
- package/src/workspace/service.ts +381 -0
- 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";
|