@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.
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/dist/client.js +6 -16
- package/dist/client.js.map +1 -1
- package/dist/edge.js +22 -28
- package/dist/edge.js.map +1 -1
- package/dist/guard-error.js +23 -0
- package/dist/guard-error.js.map +1 -0
- package/dist/helpers.js +77 -97
- package/dist/helpers.js.map +1 -1
- package/dist/impersonation.js +46 -18
- package/dist/impersonation.js.map +1 -1
- package/dist/index.js +21 -27
- package/dist/index.js.map +1 -1
- package/dist/invitation-links.js +13 -0
- package/dist/invitation-links.js.map +1 -0
- package/dist/onboarding/errors.js +4 -14
- package/dist/onboarding/errors.js.map +1 -1
- package/dist/onboarding/schemas.js +3 -15
- package/dist/onboarding/schemas.js.map +1 -1
- package/dist/onboarding/service.js +14 -68
- package/dist/onboarding/service.js.map +1 -1
- package/dist/org-api.js +10 -67
- package/dist/org-api.js.map +1 -1
- package/dist/profile/errors.js +4 -14
- package/dist/profile/errors.js.map +1 -1
- package/dist/profile/schemas.js +9 -10
- package/dist/profile/schemas.js.map +1 -1
- package/dist/profile/service.js +33 -53
- package/dist/profile/service.js.map +1 -1
- package/dist/roles.js +28 -5
- package/dist/roles.js.map +1 -1
- package/dist/server.js +80 -98
- package/dist/server.js.map +1 -1
- package/dist/team/errors.js +4 -13
- package/dist/team/errors.js.map +1 -1
- package/dist/team/schemas.js +5 -18
- package/dist/team/schemas.js.map +1 -1
- package/dist/team/service.js +80 -157
- package/dist/team/service.js.map +1 -1
- package/dist/trusted-origins.js +25 -0
- package/dist/trusted-origins.js.map +1 -0
- package/dist/workspace/errors.js +4 -14
- package/dist/workspace/errors.js.map +1 -1
- package/dist/workspace/schemas.js +2 -18
- package/dist/workspace/schemas.js.map +1 -1
- package/dist/workspace/service.js +31 -91
- package/dist/workspace/service.js.map +1 -1
- package/dist/workspace-bootstrap.js +33 -0
- package/dist/workspace-bootstrap.js.map +1 -0
- package/dist/workspace-init.js +46 -51
- package/dist/workspace-init.js.map +1 -1
- package/dist/workspace-slug.js +29 -0
- package/dist/workspace-slug.js.map +1 -0
- package/package.json +36 -14
- package/src/client.ts +6 -16
- package/src/edge.ts +22 -28
- package/src/guard-error.ts +36 -0
- package/src/helpers.ts +99 -114
- package/src/impersonation.ts +52 -17
- package/src/index.ts +15 -10
- package/src/invitation-links.ts +15 -0
- package/src/onboarding/errors.ts +5 -17
- package/src/onboarding/schemas.ts +3 -15
- package/src/onboarding/service.ts +11 -65
- package/src/org-api.ts +9 -66
- package/src/profile/errors.ts +5 -17
- package/src/profile/schemas.ts +10 -10
- package/src/profile/service.ts +39 -50
- package/src/roles.ts +36 -5
- package/src/server.ts +86 -104
- package/src/team/errors.ts +4 -13
- package/src/team/schemas.ts +5 -18
- package/src/team/service.ts +100 -174
- package/src/trusted-origins.ts +26 -0
- package/src/workspace/errors.ts +4 -14
- package/src/workspace/schemas.ts +2 -18
- package/src/workspace/service.ts +40 -92
- package/src/workspace-bootstrap.ts +57 -0
- package/src/workspace-init.ts +55 -56
- package/src/workspace-slug.ts +32 -0
package/src/server.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Better-Auth
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* - Email + password authentication
|
|
6
|
-
* - OAuth providers (Google, GitHub) with graceful env var fallback
|
|
7
|
-
* - Drizzle database adapter
|
|
8
|
-
* - Session persistence (7-day expiration, 1-day update age)
|
|
9
|
-
* - Organization plugin for multi-tenant workspace support (Phase 10)
|
|
10
|
-
* - Email sending via Resend for verification, password reset, welcome (Phase 14)
|
|
2
|
+
* The Better-Auth instance: email + password, optional Google/GitHub OAuth,
|
|
3
|
+
* organizations as workspaces, and the admin plugin for impersonation.
|
|
4
|
+
* Configured at module load; reaches the database and email provider.
|
|
11
5
|
*/
|
|
12
6
|
|
|
13
7
|
import { betterAuth } from "better-auth";
|
|
@@ -20,16 +14,12 @@ import {
|
|
|
20
14
|
userAc,
|
|
21
15
|
} from "better-auth/plugins/admin/access";
|
|
22
16
|
import { PLATFORM_ADMIN_ROLE } from "./roles";
|
|
17
|
+
import { resolveTrustedOrigins } from "./trusted-origins";
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
|
-
* Access control for the platform role.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* backs — "Invalid admin roles" at build time — which is the right
|
|
29
|
-
* behaviour: it stops a typo from silently granting nothing, or a
|
|
30
|
-
* renamed role from silently granting everything. `platform-admin`
|
|
31
|
-
* takes the plugin's own admin statements unchanged; the product
|
|
32
|
-
* defines no extra platform permissions yet.
|
|
20
|
+
* Access control for the platform role. Better-Auth refuses an `adminRoles`
|
|
21
|
+
* entry no role definition backs ("Invalid admin roles"); `platform-admin`
|
|
22
|
+
* takes the plugin's own admin statements unchanged.
|
|
33
23
|
*/
|
|
34
24
|
const accessControl = createAccessControl(defaultStatements);
|
|
35
25
|
const platformAdminRole = accessControl.newRole(adminAc.statements);
|
|
@@ -45,38 +35,57 @@ import {
|
|
|
45
35
|
invitation,
|
|
46
36
|
} from "@intelligo-dev/core/db/schema";
|
|
47
37
|
import {
|
|
38
|
+
ConsoleProvider,
|
|
39
|
+
getEmailProvider,
|
|
48
40
|
sendVerifyEmail,
|
|
49
41
|
sendPasswordResetEmail,
|
|
50
42
|
sendWelcomeEmail,
|
|
51
43
|
sendInvitationEmail,
|
|
52
44
|
} from "@intelligo-dev/core/email";
|
|
53
45
|
import { eq } from "drizzle-orm";
|
|
46
|
+
import { invitationLinks } from "./invitation-links";
|
|
47
|
+
import { personalWorkspaceSlug } from "./workspace-slug";
|
|
48
|
+
import { workspaceCreated } from "./workspace-bootstrap";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The origin this app is served from. `NEXT_PUBLIC_APP_URL` is required, but
|
|
52
|
+
* auth is configured at module load, before any composition root asserts it,
|
|
53
|
+
* and the absolute links in email still need a concrete host.
|
|
54
|
+
*/
|
|
55
|
+
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:4000";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Origins the CSRF check accepts. The fallback above must NOT apply here:
|
|
59
|
+
* a guessed port rejects every sign-in. See `resolveTrustedOrigins`.
|
|
60
|
+
*/
|
|
61
|
+
const TRUSTED_ORIGINS = resolveTrustedOrigins(process.env);
|
|
54
62
|
|
|
55
63
|
export const auth = betterAuth({
|
|
64
|
+
// Explicit, so the documented name is the one honoured; AUTH_SECRET is
|
|
65
|
+
// accepted as an alias.
|
|
66
|
+
secret: process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET,
|
|
56
67
|
database: drizzleAdapter(db, {
|
|
57
68
|
provider: "pg",
|
|
58
|
-
// Map our schema tables to Better-Auth's expected names
|
|
59
69
|
schema: {
|
|
60
70
|
user: users,
|
|
61
71
|
session: sessions,
|
|
62
72
|
account: accounts,
|
|
63
73
|
verification: verifications,
|
|
64
|
-
// Organization plugin tables (Phase 10)
|
|
65
74
|
organization: organizationTable,
|
|
66
75
|
member: member,
|
|
67
76
|
invitation: invitation,
|
|
68
77
|
},
|
|
69
78
|
}),
|
|
70
79
|
|
|
71
|
-
baseURL:
|
|
80
|
+
baseURL: APP_URL,
|
|
72
81
|
|
|
73
82
|
emailAndPassword: {
|
|
74
83
|
enabled: true,
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
// Verification is required exactly when a verification email can be
|
|
85
|
+
// sent. Without a provider the link only reaches the server console,
|
|
86
|
+
// so requiring it would lock every new account out — in production
|
|
87
|
+
// as much as in development. `validateEnv` warns about that case.
|
|
88
|
+
requireEmailVerification: !(getEmailProvider() instanceof ConsoleProvider),
|
|
80
89
|
sendResetPassword: async ({ user, url }) => {
|
|
81
90
|
sendPasswordResetEmail({
|
|
82
91
|
to: user.email,
|
|
@@ -88,7 +97,6 @@ export const auth = betterAuth({
|
|
|
88
97
|
},
|
|
89
98
|
},
|
|
90
99
|
|
|
91
|
-
// Email verification hook (EMAIL-04)
|
|
92
100
|
emailVerification: {
|
|
93
101
|
sendVerificationEmail: async ({ user, url }) => {
|
|
94
102
|
sendVerifyEmail({
|
|
@@ -104,7 +112,7 @@ export const auth = betterAuth({
|
|
|
104
112
|
},
|
|
105
113
|
|
|
106
114
|
socialProviders: {
|
|
107
|
-
//
|
|
115
|
+
// Each OAuth provider is enabled only when its env vars are set.
|
|
108
116
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
109
117
|
? {
|
|
110
118
|
google: {
|
|
@@ -114,7 +122,6 @@ export const auth = betterAuth({
|
|
|
114
122
|
}
|
|
115
123
|
: {}),
|
|
116
124
|
|
|
117
|
-
// GitHub OAuth - only enabled when env vars are configured
|
|
118
125
|
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
119
126
|
? {
|
|
120
127
|
github: {
|
|
@@ -126,63 +133,54 @@ export const auth = betterAuth({
|
|
|
126
133
|
},
|
|
127
134
|
|
|
128
135
|
session: {
|
|
129
|
-
// Sessions expire after 7 days
|
|
130
136
|
expiresIn: 60 * 60 * 24 * 7,
|
|
131
|
-
//
|
|
137
|
+
// Refreshed at most daily, extending the expiry while in use.
|
|
132
138
|
updateAge: 60 * 60 * 24,
|
|
133
139
|
},
|
|
134
140
|
|
|
135
|
-
trustedOrigins:
|
|
141
|
+
trustedOrigins: TRUSTED_ORIGINS,
|
|
136
142
|
|
|
137
|
-
// Database hooks for automatic workspace setup (WORK-01)
|
|
138
143
|
databaseHooks: {
|
|
139
144
|
user: {
|
|
140
145
|
create: {
|
|
141
146
|
after: async (user) => {
|
|
142
|
-
//
|
|
147
|
+
// Provision the personal workspace.
|
|
143
148
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await auth.api.createOrganization({
|
|
150
|
-
headers: new Headers(),
|
|
149
|
+
const created = await auth.api.createOrganization({
|
|
150
|
+
// Deliberately no `headers`: the organization plugin throws
|
|
151
|
+
// UNAUTHORIZED when headers are present but carry no session,
|
|
152
|
+
// and an empty `new Headers()` counts as present. Omitting
|
|
153
|
+
// them takes the system path `body.userId` exists for.
|
|
151
154
|
body: {
|
|
152
155
|
name: `${user.name || "User"}'s Workspace`,
|
|
153
|
-
slug:
|
|
154
|
-
userId: user.id,
|
|
156
|
+
slug: personalWorkspaceSlug(user.email, user.id),
|
|
157
|
+
userId: user.id,
|
|
155
158
|
},
|
|
156
159
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
error instanceof Error &&
|
|
163
|
-
(error.message?.includes("duplicate") ||
|
|
164
|
-
error.message?.includes("unique") ||
|
|
165
|
-
error.message?.includes("slug"))
|
|
166
|
-
) {
|
|
167
|
-
const orgs = await auth.api.listOrganizations({
|
|
168
|
-
headers: new Headers(),
|
|
160
|
+
if (created) {
|
|
161
|
+
workspaceCreated({
|
|
162
|
+
workspaceId: created.id,
|
|
163
|
+
userId: user.id,
|
|
164
|
+
email: user.email,
|
|
169
165
|
});
|
|
170
|
-
if (orgs && orgs.length > 0) {
|
|
171
|
-
// Org already created by the other hook — not an error.
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
166
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// The slug is derived from the user, so a racing provisioner
|
|
169
|
+
// loses on `organization.slug`'s unique index: the workspace
|
|
170
|
+
// exists, which is the outcome this hook wanted. Anything else
|
|
171
|
+
// is worth ops visibility, but never fails the registration.
|
|
172
|
+
const message =
|
|
173
|
+
error instanceof Error ? error.message : String(error);
|
|
174
|
+
if (!/already exists|duplicate|unique|slug/i.test(message)) {
|
|
175
|
+
console.error(
|
|
176
|
+
"[user.create hook] Failed to create workspace:",
|
|
177
|
+
error
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
// Acceptable for v0.2; consider transactional outbox for Phase 14.
|
|
185
|
-
const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"}/dashboard`;
|
|
182
|
+
// Fire-and-forget: no retry or outbox, a failure is only logged.
|
|
183
|
+
const dashboardUrl = `${APP_URL}/dashboard`;
|
|
186
184
|
sendWelcomeEmail({
|
|
187
185
|
to: user.email,
|
|
188
186
|
userName: user.name || user.email,
|
|
@@ -196,7 +194,7 @@ export const auth = betterAuth({
|
|
|
196
194
|
session: {
|
|
197
195
|
create: {
|
|
198
196
|
before: async (session): Promise<{ data: typeof session }> => {
|
|
199
|
-
//
|
|
197
|
+
// A soft-deleted user gets no session.
|
|
200
198
|
try {
|
|
201
199
|
const userRecord = await db
|
|
202
200
|
.select({ deletedAt: users.deletedAt })
|
|
@@ -214,25 +212,25 @@ export const auth = betterAuth({
|
|
|
214
212
|
"[session.create hook] Deleted user check failed:",
|
|
215
213
|
error
|
|
216
214
|
);
|
|
217
|
-
throw error;
|
|
215
|
+
throw error;
|
|
218
216
|
}
|
|
219
217
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// headers through hook context if middleware layering is needed later.
|
|
218
|
+
// Set the active organization. Reads `member` directly because
|
|
219
|
+
// `/organization/list` resolves the user from a session, and this
|
|
220
|
+
// hook runs while that session is being created.
|
|
224
221
|
try {
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
222
|
+
const [membership] = await db
|
|
223
|
+
.select({ organizationId: member.organizationId })
|
|
224
|
+
.from(member)
|
|
225
|
+
.where(eq(member.userId, session.userId))
|
|
226
|
+
.orderBy(member.createdAt)
|
|
227
|
+
.limit(1);
|
|
229
228
|
|
|
230
|
-
if (
|
|
231
|
-
// Auto-set active org on session creation
|
|
229
|
+
if (membership) {
|
|
232
230
|
return {
|
|
233
231
|
data: {
|
|
234
232
|
...session,
|
|
235
|
-
activeOrganizationId:
|
|
233
|
+
activeOrganizationId: membership.organizationId,
|
|
236
234
|
},
|
|
237
235
|
};
|
|
238
236
|
}
|
|
@@ -251,47 +249,31 @@ export const auth = betterAuth({
|
|
|
251
249
|
|
|
252
250
|
plugins: [
|
|
253
251
|
organization({
|
|
254
|
-
// Allow all users to create organizations (will be plan-gated in Phase 15)
|
|
255
252
|
allowUserToCreateOrganization: async () => true,
|
|
256
|
-
// Generous default limit (will be plan-gated later)
|
|
257
253
|
organizationLimit: 5,
|
|
258
|
-
// Creator becomes owner (explicit for clarity)
|
|
259
254
|
creatorRole: "owner",
|
|
260
|
-
// Invitation email sending via Resend (EMAIL-06, replaces Phase 10 placeholder)
|
|
261
255
|
sendInvitationEmail: async (data) => {
|
|
262
|
-
const
|
|
263
|
-
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000";
|
|
256
|
+
const { acceptUrl, declineUrl } = invitationLinks(APP_URL, data.id);
|
|
264
257
|
sendInvitationEmail({
|
|
265
258
|
to: data.email,
|
|
266
259
|
inviterName: data.inviter?.user?.name || "A team member",
|
|
267
260
|
workspaceName: data.organization?.name || "a workspace",
|
|
268
261
|
role: data.role || "member",
|
|
269
|
-
acceptUrl
|
|
270
|
-
declineUrl
|
|
262
|
+
acceptUrl,
|
|
263
|
+
declineUrl,
|
|
271
264
|
}).catch((err) =>
|
|
272
265
|
console.error("[Auth] Failed to send invitation email:", err)
|
|
273
266
|
);
|
|
274
267
|
},
|
|
275
|
-
// Invitations expire after 7 days (matches TEAM-10)
|
|
276
268
|
invitationExpiresIn: 60 * 60 * 24 * 7,
|
|
277
269
|
}),
|
|
278
270
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* `adminRoles` is deliberately a platform role and not a workspace
|
|
283
|
-
* one: workspace `owner` is per-tenant and every self-serve signup
|
|
284
|
-
* owns their own workspace, so gating anything cross-tenant on it
|
|
285
|
-
* grants it to everybody. `users.role` carries the platform role;
|
|
271
|
+
* Enabled for its impersonation endpoints, used by the console.
|
|
272
|
+
* `adminRoles` is a platform role, never a workspace one: every
|
|
273
|
+
* self-serve signup owns a workspace. `users.role` carries it;
|
|
286
274
|
* requirePlatformAdmin promotes from PLATFORM_ADMIN_EMAILS into it.
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* is measured in minutes, and a session inherited by whoever next
|
|
290
|
-
* uses that browser is the failure mode worth designing against.
|
|
291
|
-
*
|
|
292
|
-
* `allowImpersonatingAdmins` stays false: one platform admin
|
|
293
|
-
* cannot take over another's account, which is what keeps the
|
|
294
|
-
* audit trail meaningful.
|
|
275
|
+
* Sessions are capped at 30 minutes, and one admin cannot impersonate
|
|
276
|
+
* another, which keeps the audit trail meaningful.
|
|
295
277
|
*/
|
|
296
278
|
admin({
|
|
297
279
|
ac: accessControl,
|
package/src/team/errors.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* The team 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.
|
|
2
|
+
* Thrown for every failure the team service recognizes. Shaping it for a UI
|
|
3
|
+
* (a `{ success, error }` envelope, i18n, revalidation) is the transport's job.
|
|
10
4
|
*/
|
|
11
5
|
|
|
12
6
|
/**
|
|
@@ -54,14 +48,11 @@ export class TeamServiceError extends Error {
|
|
|
54
48
|
this.code = code;
|
|
55
49
|
this.meta = options?.meta;
|
|
56
50
|
if (options?.cause !== undefined) {
|
|
57
|
-
// ES2020 target predates the
|
|
58
|
-
// assign it directly so `instanceof Error` consumers (and Node's
|
|
59
|
-
// own error inspection) still see it.
|
|
51
|
+
// The ES2020 target predates the `cause` constructor option.
|
|
60
52
|
(this as { cause?: unknown }).cause = options.cause;
|
|
61
53
|
}
|
|
62
54
|
|
|
63
|
-
//
|
|
64
|
-
// transpilation targets loses `instanceof`).
|
|
55
|
+
// Extending built-ins loses `instanceof` on some transpilation targets.
|
|
65
56
|
Object.setPrototypeOf(this, TeamServiceError.prototype);
|
|
66
57
|
}
|
|
67
58
|
}
|
package/src/team/schemas.ts
CHANGED
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Team Management Validation Schemas
|
|
3
|
-
*
|
|
4
|
-
* Zod schemas for team invite and member management inputs, shared by
|
|
5
|
-
* the team service and its transports.
|
|
6
|
-
*
|
|
7
|
-
* (Ported from the product application’s team validation module — same
|
|
8
|
-
* semantics. Ignite's copy is retired at cutover.)
|
|
9
|
-
*/
|
|
1
|
+
/** Team inputs, shared by the team service and its transports. */
|
|
10
2
|
|
|
11
3
|
import { z } from "zod";
|
|
12
4
|
|
|
13
|
-
/**
|
|
14
|
-
* Invite Member Schema
|
|
15
|
-
* Email + role for inviting new team members
|
|
16
|
-
*/
|
|
17
5
|
export const inviteMemberSchema = z.object({
|
|
18
6
|
email: z.string().email("Please enter a valid email address"),
|
|
19
7
|
role: z.enum(["admin", "member"], {
|
|
20
|
-
|
|
8
|
+
// One message for a missing role and an unknown one: to the person
|
|
9
|
+
// filling in the form they are the same mistake. `message`, not
|
|
10
|
+
// `error`: the peer range admits zod 3, which reads only `message`.
|
|
11
|
+
message: "Please select a role",
|
|
21
12
|
}),
|
|
22
13
|
});
|
|
23
14
|
|
|
24
15
|
export type InviteMemberInput = z.infer<typeof inviteMemberSchema>;
|
|
25
16
|
|
|
26
|
-
/**
|
|
27
|
-
* Update Role Schema
|
|
28
|
-
* For changing an existing member's role
|
|
29
|
-
*/
|
|
30
17
|
export const updateRoleSchema = z.object({
|
|
31
18
|
memberId: z.string().min(1),
|
|
32
19
|
role: z.enum(["admin", "member"]),
|