@manablox/auth 0.1.0 → 0.3.0

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/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # `@manablox/auth`
2
+
3
+ Sessions, API keys and permissions. better-auth owns sign-in and sessions; this package wraps it, resolves a `Principal` from a request, checks a permission against the role table, and issues and verifies API keys, including ones restricted to a set of spaces.
4
+
5
+ ## Exports
6
+
7
+ - `createAuth`, `resolvePrincipal`
8
+ - `can`, `assertCan`, `actorRoles`, `Permission`, `Principal`
9
+ - `ApiKeyService`
10
+ - `promoteFirstUser`, `attachBootstrapOwner` — the first account is the superadmin
11
+
12
+ ## Depends on
13
+
14
+ @manablox/db, better-auth
15
+
16
+ ## Test
17
+
18
+ ```sh
19
+ pnpm --filter @manablox/auth test
20
+ ```
@@ -0,0 +1,305 @@
1
+ import { Database, MembershipRow, Repositories, SpaceRow } from "@manablox/db";
2
+ import { ALL_PERMISSIONS, AuthConfig, BUILT_IN_ROLES, BuiltInRole, CONTENT_ACTIONS, ContentAction, ContentPermission, ContentPermission as ContentPermission$1, Grant, Manablox, PERMISSION_GROUPS, Permission, Permission as Permission$1, PermissionGroup, SpaceRole, SpaceRole as SpaceRole$1, grantsCover, intersectGrants, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, typesCoveredBy } from "@manablox/core";
3
+ //#region src/rbac.d.ts
4
+ export interface Principal {
5
+ userId: string;
6
+ email: string;
7
+ /** Instance-wide role; `superadmin` short-circuits every space check. */
8
+ role: string;
9
+ /** Space id → the name of the role held there. */
10
+ spaces: Record<string, SpaceRole$1>;
11
+ /**
12
+ * Space id → the grants that role carries, resolved when the principal is. Absent for
13
+ * a space whose role is built in: those are answered from the table above.
14
+ */
15
+ permissions?: Record<string, readonly string[]>;
16
+ /** True when the request authenticated with an API key rather than a session. */
17
+ viaApiKey?: boolean;
18
+ /**
19
+ * Spaces this principal is confined to, or `null`/absent for no confinement. Set by an
20
+ * API key that was issued with a space restriction: it narrows the key below its
21
+ * owner's own access and, unlike a role, it also binds a superadmin.
22
+ */
23
+ allowedSpaceIds?: string[] | null;
24
+ /**
25
+ * Grants this principal is confined to, or `null`/absent for no confinement. Set by an
26
+ * API key issued with a permission restriction: like `allowedSpaceIds` it only ever
27
+ * narrows the owner's own access, and it binds a superadmin too.
28
+ */
29
+ allowedGrants?: readonly string[] | null;
30
+ }
31
+ /** The grants a principal's role gives in a space, whatever kind of role it is. */
32
+ export declare function grantsIn(principal: Principal, spaceId: string): readonly string[];
33
+ /**
34
+ * What a principal can actually do in a space: the role's grants (everything, for a
35
+ * superadmin) narrowed by an API key's restriction, if the request came through one.
36
+ */
37
+ export declare function effectiveGrants(principal: Principal, spaceId: string): readonly string[];
38
+ export declare function can(principal: Principal | null, spaceId: string | null, permission: Permission$1, typeId?: string | null): boolean;
39
+ export declare function assertCan(principal: Principal | null, spaceId: string | null, permission: Permission$1, typeId?: string | null): void;
40
+ /**
41
+ * The content types a principal may perform an action on in a space, or `null` for
42
+ * every type — what a listing narrows its filter to.
43
+ */
44
+ export declare function allowedTypeIds(principal: Principal | null, spaceId: string, permission: ContentPermission$1): string[] | null;
45
+ /** Roles used by field-level `readRoles`/`writeRoles` checks. */
46
+ export declare function actorRoles(principal: Principal | null, spaceId: string | null): string[];
47
+ //#endregion
48
+ //#region src/api-key.d.ts
49
+ export interface IssuedApiKey {
50
+ id: string;
51
+ name: string;
52
+ /** The full secret, shown once at creation and never recoverable afterwards. */
53
+ key: string;
54
+ prefix: string;
55
+ }
56
+ export interface IssueApiKeyOptions {
57
+ expiresAt?: Date | undefined;
58
+ /** Spaces the key may act in. `null`/omitted issues an unrestricted key. */
59
+ spaceIds?: string[] | null | undefined;
60
+ /** Grants the key may use. `null`/omitted leaves the owner's role as the limit. */
61
+ permissions?: string[] | null | undefined;
62
+ }
63
+ /**
64
+ * Takes a presented key apart. The secret is base64url and may itself contain `_`, so
65
+ * the key is not split on it: the prefix is a fixed twelve hex characters and the
66
+ * secret is whatever follows.
67
+ */
68
+ export declare function parseApiKey(presented: string): {
69
+ prefix: string;
70
+ secret: string;
71
+ } | null;
72
+ /**
73
+ * Long-lived credentials for headless consumers.
74
+ *
75
+ * Keys are stored as a SHA-256 digest, never in plaintext, and are looked up by an
76
+ * indexed non-secret prefix so verification is one indexed read plus one constant-time
77
+ * comparison — not a scan-and-compare over every row.
78
+ */
79
+ export declare class ApiKeyService {
80
+ private readonly db;
81
+ private readonly repos;
82
+ constructor(db: Database, repos: Repositories);
83
+ issue(userId: string, name: string, options?: IssueApiKeyOptions): Promise<IssuedApiKey>;
84
+ /**
85
+ * Deletes the row rather than clearing `enabled`: a revoked key is never listed again
86
+ * or re-enabled, so a disabled row is only a secret digest left lying around.
87
+ */
88
+ revoke(id: string): Promise<void>;
89
+ list(userId: string): Promise<{
90
+ id: string;
91
+ name: string | null;
92
+ start: string | null;
93
+ enabled: boolean;
94
+ expiresAt: Date | null;
95
+ lastRequest: Date | null;
96
+ spaceIds: string[] | null;
97
+ permissions: string[] | null;
98
+ createdAt: Date;
99
+ }[]>;
100
+ resolve(presented: string): Promise<Principal | null>;
101
+ /** Removes expired keys; scheduled by the jobs package. */
102
+ pruneExpired(): Promise<number>;
103
+ }
104
+ //#endregion
105
+ //#region src/password.d.ts
106
+ /**
107
+ * Argon2id, the current OWASP recommendation over bcrypt — which also silently truncates
108
+ * passwords at 72 bytes. One definition serves better-auth's own sign-in path and the
109
+ * accounts an administrator creates, so both write the same hash format.
110
+ */
111
+ export declare function hashPassword(password: string): Promise<string>;
112
+ export declare function verifyPassword(stored: string, password: string): Promise<boolean>;
113
+ /** Matches better-auth's `minPasswordLength`, so a password set here signs in there. */
114
+ export declare const MIN_PASSWORD_LENGTH = 12;
115
+ //#endregion
116
+ //#region src/user.service.d.ts
117
+ export type InstanceRole = 'superadmin' | 'editor';
118
+ export interface CreateUserInput {
119
+ name: string;
120
+ email: string;
121
+ password: string;
122
+ role: InstanceRole;
123
+ }
124
+ export interface UpdateUserInput {
125
+ name?: string | undefined;
126
+ email?: string | undefined;
127
+ }
128
+ /** A user row without anything a directory listing should not carry. */
129
+ export interface UserSummary {
130
+ id: string;
131
+ name: string;
132
+ email: string;
133
+ image: string | null;
134
+ role: string;
135
+ banned: boolean;
136
+ banReason: string | null;
137
+ createdAt: Date;
138
+ updatedAt: Date;
139
+ }
140
+ export interface UserDetail extends UserSummary {
141
+ memberships: Array<{
142
+ spaceId: string;
143
+ role: MembershipRow['role'];
144
+ space: SpaceRow;
145
+ }>;
146
+ }
147
+ /**
148
+ * Instance-wide user administration: the accounts, their instance role, and whether they
149
+ * may sign in at all. Space membership stays with `SpaceService`, because it is a
150
+ * property of the space.
151
+ *
152
+ * Every rule here exists to keep the instance reachable: an administrator cannot lock
153
+ * themself out, and the instance always keeps at least one superadmin.
154
+ */
155
+ export declare class UserService {
156
+ private readonly repos;
157
+ constructor(repos: Repositories);
158
+ get(userId: string): Promise<UserDetail>;
159
+ list(pagination: {
160
+ limit: number;
161
+ offset: number;
162
+ }, search?: string): Promise<{
163
+ total: number;
164
+ limit: number;
165
+ offset: number;
166
+ items: UserSummary[];
167
+ }>;
168
+ create(input: CreateUserInput): Promise<UserSummary>;
169
+ update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
170
+ /** Changing the instance role; the last superadmin cannot step down. */
171
+ setRole(userId: string, role: InstanceRole): Promise<UserSummary>;
172
+ /**
173
+ * A new password, and every session gone with the old one: whoever held the account
174
+ * before the reset does not keep it afterwards.
175
+ */
176
+ setPassword(userId: string, password: string): Promise<void>;
177
+ /** A banned user is signed out everywhere and refused on the next request. */
178
+ ban(actorId: string, userId: string, reason: string | null): Promise<UserSummary>;
179
+ unban(userId: string): Promise<UserSummary>;
180
+ delete(actorId: string, userId: string): Promise<void>;
181
+ /** Signs the user out of every device without touching the account. */
182
+ revokeSessions(userId: string): Promise<void>;
183
+ private require;
184
+ private assertNotSelf;
185
+ /**
186
+ * Whatever happens to `userId`, one superadmin must remain — otherwise the instance
187
+ * has no one left who can create a space or manage users, and no way back.
188
+ */
189
+ private assertNotLastSuperadmin;
190
+ }
191
+ //#endregion
192
+ //#region src/index.d.ts
193
+ export type ManabloxAuth = ReturnType<typeof createAuth>;
194
+ /** better-auth, wired to the Drizzle schema. Sessions are rows, so concurrent devices
195
+ * each hold their own. */
196
+ export interface AuthCallbacks {
197
+ /** Runs after a user row is created, inside better-auth's own transaction path. */
198
+ onUserCreated?: (userId: string) => Promise<void>;
199
+ /**
200
+ * Whether the public sign-up endpoint may create an account right now. Absent means
201
+ * always. The host closes it once the first account exists, so every later account is
202
+ * created by an administrator rather than by whoever finds the login page.
203
+ */
204
+ allowSignUp?: () => Promise<boolean>;
205
+ }
206
+ export declare function createAuth(config: AuthConfig, db: Database, callbacks?: AuthCallbacks): import("better-auth").Auth<{
207
+ secret: string;
208
+ baseURL?: string;
209
+ trustedOrigins: string[];
210
+ database: (options: import("better-auth").BetterAuthOptions) => import("better-auth").DBAdapter<import("better-auth").BetterAuthOptions>;
211
+ emailAndPassword: {
212
+ enabled: boolean;
213
+ minPasswordLength: number;
214
+ password: {
215
+ hash: typeof hashPassword;
216
+ verify: ({ hash: stored, password }: {
217
+ hash: string;
218
+ password: string;
219
+ }) => Promise<boolean>;
220
+ };
221
+ };
222
+ session: {
223
+ expiresIn: number;
224
+ updateAge: number;
225
+ cookieCache: {
226
+ enabled: true;
227
+ maxAge: number;
228
+ };
229
+ };
230
+ plugins: [{
231
+ id: "bearer";
232
+ version: string;
233
+ hooks: {
234
+ before: {
235
+ matcher(context: import("better-auth").HookEndpointContext): boolean;
236
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
237
+ context: {
238
+ headers: Headers;
239
+ };
240
+ } | undefined>>;
241
+ }[];
242
+ after: {
243
+ matcher(context: import("better-auth").HookEndpointContext): true;
244
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
245
+ }[];
246
+ };
247
+ options: import("better-auth/plugins").BearerOptions | undefined;
248
+ }];
249
+ databaseHooks: {
250
+ user: {
251
+ create: {
252
+ before: (user: {
253
+ id: string;
254
+ createdAt: Date;
255
+ updatedAt: Date;
256
+ email: string;
257
+ emailVerified: boolean;
258
+ name: string;
259
+ image?: string | null | undefined;
260
+ } & Record<string, unknown>) => Promise<{
261
+ data: {
262
+ id: string;
263
+ createdAt: Date;
264
+ updatedAt: Date;
265
+ email: string;
266
+ emailVerified: boolean;
267
+ name: string;
268
+ image?: string | null | undefined;
269
+ } & Record<string, unknown>;
270
+ }>;
271
+ after: (user: {
272
+ id: string;
273
+ createdAt: Date;
274
+ updatedAt: Date;
275
+ email: string;
276
+ emailVerified: boolean;
277
+ name: string;
278
+ image?: string | null | undefined;
279
+ } & Record<string, unknown>) => Promise<void>;
280
+ };
281
+ };
282
+ };
283
+ advanced: {
284
+ database: {
285
+ generateId: () => `${string}-${string}-${string}-${string}-${string}`;
286
+ };
287
+ };
288
+ }>;
289
+ /**
290
+ * Resolves a request's session into a `Principal`, including its space memberships.
291
+ * Returns `null` for anonymous requests rather than throwing — route guards decide.
292
+ */
293
+ export declare function resolvePrincipal(auth: ManabloxAuth, repos: Repositories, headers: Headers, apiKeys?: ApiKeyService): Promise<Principal | null>;
294
+ /**
295
+ * Promotes the very first account to `superadmin` and grants it ownership of every
296
+ * existing space, so a fresh install is reachable.
297
+ *
298
+ * Called from better-auth's user-create hook rather than at startup, so it fires for an
299
+ * account created after the server is already running.
300
+ */
301
+ export declare function promoteFirstUser(manablox: Manablox, repos: Repositories, userId: string): Promise<void>;
302
+ /** Covers an instance whose first account predates this behaviour. */
303
+ export declare function attachBootstrapOwner(manablox: Manablox, repos: Repositories): void;
304
+ //#endregion
305
+ export { ALL_PERMISSIONS, BUILT_IN_ROLES, type BuiltInRole, CONTENT_ACTIONS, type ContentAction, type ContentPermission, type Grant, PERMISSION_GROUPS, type Permission, type PermissionGroup, type SpaceRole, grantsCover, intersectGrants, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, typesCoveredBy };
package/dist/index.js ADDED
@@ -0,0 +1,418 @@
1
+ import { rethrowUniqueViolation, schema } from "@manablox/db";
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
+ import { APIError } from "better-auth/api";
5
+ import { bearer } from "better-auth/plugins";
6
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
7
+ import { ALL_PERMISSIONS, ALL_PERMISSIONS as ALL_PERMISSIONS$1, BUILT_IN_ROLES, CONTENT_ACTIONS, ManabloxError, PERMISSION_GROUPS, grantsCover, grantsCover as grantsCover$1, intersectGrants, intersectGrants as intersectGrants$1, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, permissionsFor as permissionsFor$1, typesCoveredBy, typesCoveredBy as typesCoveredBy$1 } from "@manablox/core";
8
+ import { and, eq, sql } from "drizzle-orm";
9
+ //#region src/password.ts
10
+ /**
11
+ * Argon2id, the current OWASP recommendation over bcrypt — which also silently truncates
12
+ * passwords at 72 bytes. One definition serves better-auth's own sign-in path and the
13
+ * accounts an administrator creates, so both write the same hash format.
14
+ */
15
+ async function hashPassword(password) {
16
+ const { hash } = await import("@node-rs/argon2");
17
+ return hash(password, {
18
+ memoryCost: 19456,
19
+ timeCost: 2,
20
+ parallelism: 1
21
+ });
22
+ }
23
+ async function verifyPassword(stored, password) {
24
+ const { verify } = await import("@node-rs/argon2");
25
+ return verify(stored, password);
26
+ }
27
+ /** Matches better-auth's `minPasswordLength`, so a password set here signs in there. */
28
+ const MIN_PASSWORD_LENGTH = 12;
29
+ //#endregion
30
+ //#region src/api-key.ts
31
+ const PREFIX = "mbx";
32
+ /**
33
+ * Takes a presented key apart. The secret is base64url and may itself contain `_`, so
34
+ * the key is not split on it: the prefix is a fixed twelve hex characters and the
35
+ * secret is whatever follows.
36
+ */
37
+ function parseApiKey(presented) {
38
+ const match = /^([a-z]+)_([0-9a-f]{12})_([A-Za-z0-9_-]+)$/.exec(presented);
39
+ if (!match || match[1] !== PREFIX) return null;
40
+ return {
41
+ prefix: match[2],
42
+ secret: match[3]
43
+ };
44
+ }
45
+ /**
46
+ * Long-lived credentials for headless consumers.
47
+ *
48
+ * Keys are stored as a SHA-256 digest, never in plaintext, and are looked up by an
49
+ * indexed non-secret prefix so verification is one indexed read plus one constant-time
50
+ * comparison — not a scan-and-compare over every row.
51
+ */
52
+ var ApiKeyService = class {
53
+ db;
54
+ repos;
55
+ constructor(db, repos) {
56
+ this.db = db;
57
+ this.repos = repos;
58
+ }
59
+ async issue(userId, name, options = {}) {
60
+ const secret = randomBytes(32).toString("base64url");
61
+ const prefix = randomBytes(6).toString("hex");
62
+ const key = `${PREFIX}_${prefix}_${secret}`;
63
+ const [row] = await this.db.insert(schema.apikeys).values({
64
+ userId,
65
+ name,
66
+ prefix,
67
+ start: key.slice(0, 12),
68
+ key: digest(secret),
69
+ expiresAt: options.expiresAt ?? null,
70
+ spaceIds: options.spaceIds?.length ? options.spaceIds : null,
71
+ permissions: options.permissions ?? null
72
+ }).returning();
73
+ if (!row) throw new ManabloxError("apiKey.create.failed");
74
+ return {
75
+ id: row.id,
76
+ name,
77
+ key,
78
+ prefix
79
+ };
80
+ }
81
+ /**
82
+ * Deletes the row rather than clearing `enabled`: a revoked key is never listed again
83
+ * or re-enabled, so a disabled row is only a secret digest left lying around.
84
+ */
85
+ async revoke(id) {
86
+ await this.db.delete(schema.apikeys).where(eq(schema.apikeys.id, id));
87
+ }
88
+ async list(userId) {
89
+ return this.db.select({
90
+ id: schema.apikeys.id,
91
+ name: schema.apikeys.name,
92
+ start: schema.apikeys.start,
93
+ enabled: schema.apikeys.enabled,
94
+ expiresAt: schema.apikeys.expiresAt,
95
+ lastRequest: schema.apikeys.lastRequest,
96
+ spaceIds: schema.apikeys.spaceIds,
97
+ permissions: schema.apikeys.permissions,
98
+ createdAt: schema.apikeys.createdAt
99
+ }).from(schema.apikeys).where(eq(schema.apikeys.userId, userId));
100
+ }
101
+ async resolve(presented) {
102
+ const parsed = parseApiKey(presented);
103
+ if (!parsed) return null;
104
+ const { prefix, secret } = parsed;
105
+ const row = (await this.db.select().from(schema.apikeys).where(and(eq(schema.apikeys.prefix, prefix), eq(schema.apikeys.enabled, true))).limit(1))[0];
106
+ if (!row) return null;
107
+ if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
108
+ const expected = Buffer.from(row.key, "hex");
109
+ const actual = Buffer.from(digest(secret), "hex");
110
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
111
+ this.db.update(schema.apikeys).set({ lastRequest: /* @__PURE__ */ new Date() }).where(eq(schema.apikeys.id, row.id)).catch(() => void 0);
112
+ const user = await this.repos.users.findById(row.userId);
113
+ if (!user || user.banned) return null;
114
+ const resolved = await this.repos.users.principal(user.id);
115
+ if (!resolved) return null;
116
+ const allowed = row.spaceIds?.length ? new Set(row.spaceIds) : null;
117
+ const spaces = {};
118
+ const permissions = {};
119
+ for (const [spaceId, role] of Object.entries(resolved.spaces)) {
120
+ if (allowed && !allowed.has(spaceId)) continue;
121
+ spaces[spaceId] = role;
122
+ const grants = resolved.permissions[spaceId];
123
+ if (grants) permissions[spaceId] = grants;
124
+ }
125
+ return {
126
+ userId: user.id,
127
+ email: user.email,
128
+ role: user.role,
129
+ spaces,
130
+ permissions,
131
+ viaApiKey: true,
132
+ allowedSpaceIds: allowed ? [...allowed] : null,
133
+ allowedGrants: row.permissions
134
+ };
135
+ }
136
+ /** Removes expired keys; scheduled by the jobs package. */
137
+ async pruneExpired() {
138
+ return (await this.db.delete(schema.apikeys).where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`).returning({ id: schema.apikeys.id })).length;
139
+ }
140
+ };
141
+ const digest = (secret) => createHash("sha256").update(secret).digest("hex");
142
+ //#endregion
143
+ //#region src/rbac.ts
144
+ /** The grants a principal's role gives in a space, whatever kind of role it is. */
145
+ function grantsIn(principal, spaceId) {
146
+ const role = principal.spaces[spaceId];
147
+ if (!role) return [];
148
+ return principal.permissions?.[spaceId] ?? permissionsFor$1(role);
149
+ }
150
+ /**
151
+ * What a principal can actually do in a space: the role's grants (everything, for a
152
+ * superadmin) narrowed by an API key's restriction, if the request came through one.
153
+ */
154
+ function effectiveGrants(principal, spaceId) {
155
+ if (principal.allowedSpaceIds && !principal.allowedSpaceIds.includes(spaceId)) return [];
156
+ const held = principal.role === "superadmin" ? ALL_PERMISSIONS$1 : grantsIn(principal, spaceId);
157
+ return principal.allowedGrants ? intersectGrants$1(held, principal.allowedGrants) : held;
158
+ }
159
+ function can(principal, spaceId, permission, typeId) {
160
+ if (!principal) return false;
161
+ if (principal.allowedSpaceIds && (!spaceId || !principal.allowedSpaceIds.includes(spaceId))) return false;
162
+ if (principal.allowedGrants && !grantsCover$1(principal.allowedGrants, permission, typeId)) return false;
163
+ if (principal.role === "superadmin") return true;
164
+ if (!spaceId) return false;
165
+ return grantsCover$1(grantsIn(principal, spaceId), permission, typeId);
166
+ }
167
+ function assertCan(principal, spaceId, permission, typeId) {
168
+ if (can(principal, spaceId, permission, typeId)) return;
169
+ if (!principal) throw ManabloxError.unauthorized();
170
+ throw ManabloxError.forbidden("auth.forbidden", {
171
+ permission,
172
+ spaceId,
173
+ ...typeId ? { typeId } : {}
174
+ });
175
+ }
176
+ /**
177
+ * The content types a principal may perform an action on in a space, or `null` for
178
+ * every type — what a listing narrows its filter to.
179
+ */
180
+ function allowedTypeIds(principal, spaceId, permission) {
181
+ if (!principal) return [];
182
+ if (principal.role === "superadmin" && !principal.allowedGrants) return null;
183
+ return typesCoveredBy$1(effectiveGrants(principal, spaceId), permission);
184
+ }
185
+ /** Roles used by field-level `readRoles`/`writeRoles` checks. */
186
+ function actorRoles(principal, spaceId) {
187
+ if (!principal) return [];
188
+ const roles = [principal.role];
189
+ if (spaceId && principal.spaces[spaceId]) roles.push(principal.spaces[spaceId]);
190
+ return roles;
191
+ }
192
+ //#endregion
193
+ //#region src/user.service.ts
194
+ /**
195
+ * `users_email_key` is enforced in the database, so a taken address arrives as a
196
+ * Postgres unique violation and would surface as an opaque 500.
197
+ */
198
+ const emailConflict = (email) => (error) => rethrowUniqueViolation(error, {
199
+ constraint: "email",
200
+ key: "user.email.taken",
201
+ path: ["email"],
202
+ params: { email: email ?? "" },
203
+ errorKey: "user.validation.failed"
204
+ });
205
+ /**
206
+ * Instance-wide user administration: the accounts, their instance role, and whether they
207
+ * may sign in at all. Space membership stays with `SpaceService`, because it is a
208
+ * property of the space.
209
+ *
210
+ * Every rule here exists to keep the instance reachable: an administrator cannot lock
211
+ * themself out, and the instance always keeps at least one superadmin.
212
+ */
213
+ var UserService = class {
214
+ repos;
215
+ constructor(repos) {
216
+ this.repos = repos;
217
+ }
218
+ async get(userId) {
219
+ const user = await this.repos.users.findById(userId);
220
+ if (!user) throw ManabloxError.notFound("user.notFound", { id: userId });
221
+ const memberships = await this.repos.users.membershipsWithSpaces(userId);
222
+ return {
223
+ ...summary(user),
224
+ memberships: memberships.map((row) => ({
225
+ spaceId: row.spaceId,
226
+ role: row.role,
227
+ space: row.space
228
+ }))
229
+ };
230
+ }
231
+ async list(pagination, search) {
232
+ const page = await this.repos.users.list(pagination, search);
233
+ return {
234
+ ...page,
235
+ items: page.items.map(summary)
236
+ };
237
+ }
238
+ async create(input) {
239
+ const email = normaliseEmail(input.email);
240
+ return summary(await this.repos.users.create({
241
+ name: input.name.trim(),
242
+ email,
243
+ role: input.role,
244
+ passwordHash: await hashPassword(input.password)
245
+ }).catch(emailConflict(email)));
246
+ }
247
+ async update(userId, input) {
248
+ const data = {};
249
+ if (input.name !== void 0) data.name = input.name.trim();
250
+ if (input.email !== void 0) data.email = normaliseEmail(input.email);
251
+ return summary(await this.repos.users.update(userId, data).catch(emailConflict(data.email)));
252
+ }
253
+ /** Changing the instance role; the last superadmin cannot step down. */
254
+ async setRole(userId, role) {
255
+ if (role !== "superadmin") await this.assertNotLastSuperadmin(userId);
256
+ return summary(await this.repos.users.setRole(userId, role));
257
+ }
258
+ /**
259
+ * A new password, and every session gone with the old one: whoever held the account
260
+ * before the reset does not keep it afterwards.
261
+ */
262
+ async setPassword(userId, password) {
263
+ await this.require(userId);
264
+ await this.repos.users.setPasswordHash(userId, await hashPassword(password));
265
+ await this.repos.users.revokeSessions(userId);
266
+ }
267
+ /** A banned user is signed out everywhere and refused on the next request. */
268
+ async ban(actorId, userId, reason) {
269
+ this.assertNotSelf(actorId, userId);
270
+ await this.assertNotLastSuperadmin(userId);
271
+ const user = await this.repos.users.setBanned(userId, true, reason);
272
+ await this.repos.users.revokeSessions(userId);
273
+ return summary(user);
274
+ }
275
+ async unban(userId) {
276
+ return summary(await this.repos.users.setBanned(userId, false, null));
277
+ }
278
+ async delete(actorId, userId) {
279
+ this.assertNotSelf(actorId, userId);
280
+ await this.require(userId);
281
+ await this.assertNotLastSuperadmin(userId);
282
+ await this.repos.users.delete(userId);
283
+ }
284
+ /** Signs the user out of every device without touching the account. */
285
+ async revokeSessions(userId) {
286
+ await this.require(userId);
287
+ await this.repos.users.revokeSessions(userId);
288
+ }
289
+ async require(userId) {
290
+ const user = await this.repos.users.findById(userId);
291
+ if (!user) throw ManabloxError.notFound("user.notFound", { id: userId });
292
+ return user;
293
+ }
294
+ assertNotSelf(actorId, userId) {
295
+ if (actorId === userId) throw ManabloxError.badRequest("user.self.protected", { id: userId });
296
+ }
297
+ /**
298
+ * Whatever happens to `userId`, one superadmin must remain — otherwise the instance
299
+ * has no one left who can create a space or manage users, and no way back.
300
+ */
301
+ async assertNotLastSuperadmin(userId) {
302
+ if ((await this.require(userId)).role !== "superadmin") return;
303
+ if (await this.repos.users.countByRole("superadmin") <= 1) throw ManabloxError.badRequest("user.lastSuperadmin", { id: userId });
304
+ }
305
+ };
306
+ function summary(user) {
307
+ return {
308
+ id: user.id,
309
+ name: user.name,
310
+ email: user.email,
311
+ image: user.image,
312
+ role: user.role,
313
+ banned: user.banned,
314
+ banReason: user.banReason,
315
+ createdAt: user.createdAt,
316
+ updatedAt: user.updatedAt
317
+ };
318
+ }
319
+ /** Lower-cased and trimmed, as better-auth stores it, so two spellings cannot coexist. */
320
+ function normaliseEmail(email) {
321
+ return email.trim().toLowerCase();
322
+ }
323
+ //#endregion
324
+ //#region src/index.ts
325
+ function createAuth(config, db, callbacks = {}) {
326
+ return betterAuth({
327
+ secret: config.secret,
328
+ ...config.baseUrl ? { baseURL: config.baseUrl } : {},
329
+ trustedOrigins: config.trustedOrigins ?? [],
330
+ database: drizzleAdapter(db, {
331
+ provider: "pg",
332
+ schema: {
333
+ user: schema.users,
334
+ session: schema.sessions,
335
+ account: schema.accounts,
336
+ verification: schema.verifications,
337
+ apikey: schema.apikeys
338
+ }
339
+ }),
340
+ emailAndPassword: {
341
+ enabled: config.emailAndPassword ?? true,
342
+ minPasswordLength: 12,
343
+ password: {
344
+ hash: hashPassword,
345
+ verify: ({ hash: stored, password }) => verifyPassword(stored, password)
346
+ }
347
+ },
348
+ session: {
349
+ expiresIn: config.sessionMaxAge ?? 604800,
350
+ updateAge: 86400,
351
+ cookieCache: {
352
+ enabled: true,
353
+ maxAge: 300
354
+ }
355
+ },
356
+ plugins: [bearer()],
357
+ databaseHooks: { user: { create: {
358
+ before: async (user) => {
359
+ if (!callbacks.allowSignUp || await callbacks.allowSignUp()) return { data: user };
360
+ throw new APIError("FORBIDDEN", { message: "auth.signUp.closed" });
361
+ },
362
+ after: async (user) => {
363
+ await callbacks.onUserCreated?.(user.id);
364
+ }
365
+ } } },
366
+ advanced: { database: { generateId: () => crypto.randomUUID() } }
367
+ });
368
+ }
369
+ /**
370
+ * Resolves a request's session into a `Principal`, including its space memberships.
371
+ * Returns `null` for anonymous requests rather than throwing — route guards decide.
372
+ */
373
+ async function resolvePrincipal(auth, repos, headers, apiKeys) {
374
+ const presented = headers.get("x-api-key");
375
+ if (presented && apiKeys) {
376
+ const principal = await apiKeys.resolve(presented);
377
+ if (principal) return principal;
378
+ }
379
+ const session = await auth.api.getSession({ headers });
380
+ if (!session?.user) return null;
381
+ const principal = await repos.users.principal(session.user.id);
382
+ if (!principal || principal.banned) return null;
383
+ return {
384
+ userId: session.user.id,
385
+ email: session.user.email,
386
+ role: principal.role,
387
+ spaces: principal.spaces,
388
+ permissions: principal.permissions
389
+ };
390
+ }
391
+ /**
392
+ * Promotes the very first account to `superadmin` and grants it ownership of every
393
+ * existing space, so a fresh install is reachable.
394
+ *
395
+ * Called from better-auth's user-create hook rather than at startup, so it fires for an
396
+ * account created after the server is already running.
397
+ */
398
+ async function promoteFirstUser(manablox, repos, userId) {
399
+ if (await repos.users.count() !== 1) return;
400
+ const user = await repos.users.findById(userId);
401
+ if (!user || user.role === "superadmin") return;
402
+ await repos.users.setRole(userId, "superadmin");
403
+ for (const space of await repos.spaces.all()) await repos.users.grant(userId, space.id, "owner");
404
+ manablox.logger.info({ email: user.email }, "first account promoted to superadmin");
405
+ }
406
+ /** Covers an instance whose first account predates this behaviour. */
407
+ function attachBootstrapOwner(manablox, repos) {
408
+ manablox.hooks.on("after:start", async () => {
409
+ const { items } = await repos.users.list({
410
+ limit: 1,
411
+ offset: 0
412
+ });
413
+ const first = items[0];
414
+ if (first && await repos.users.count() === 1) await promoteFirstUser(manablox, repos, first.id);
415
+ }, { source: "@manablox/auth" });
416
+ }
417
+ //#endregion
418
+ export { ALL_PERMISSIONS, ApiKeyService, BUILT_IN_ROLES, CONTENT_ACTIONS, MIN_PASSWORD_LENGTH, PERMISSION_GROUPS, UserService, actorRoles, allowedTypeIds, assertCan, attachBootstrapOwner, can, createAuth, effectiveGrants, grantsCover, grantsIn, hashPassword, intersectGrants, isBuiltInRole, normaliseGrants, parseApiKey, parseGrant, permissionsFor, promoteFirstUser, resolvePrincipal, typesCoveredBy, verifyPassword };
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@manablox/auth",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
- "types": "./src/index.ts",
8
- "default": "./src/index.ts"
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
9
  }
10
10
  },
11
- "main": "./src/index.ts",
12
- "types": "./src/index.ts",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.1.0",
15
- "@manablox/db": "0.1.0",
14
+ "@manablox/core": "0.3.0",
15
+ "@manablox/db": "0.3.0",
16
16
  "better-auth": "^1.7.2",
17
17
  "drizzle-orm": "^0.45.2",
18
18
  "@node-rs/argon2": "^2.2.0"
@@ -20,10 +20,17 @@
20
20
  "devDependencies": {
21
21
  "@manablox/config-typescript": "0.0.0",
22
22
  "@types/node": "^26.4.1",
23
+ "tsdown": "^0.23.0",
23
24
  "typescript": "^7.0.2",
24
25
  "vitest": "^5.0.0"
25
26
  },
27
+ "files": [
28
+ "dist",
29
+ "!dist/**/*.map",
30
+ "README.md"
31
+ ],
26
32
  "scripts": {
33
+ "build": "tsdown",
27
34
  "typecheck": "tsc --noEmit",
28
35
  "test": "vitest run"
29
36
  }
package/src/api-key.ts DELETED
@@ -1,143 +0,0 @@
1
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
- import { ManabloxError } from '@manablox/core';
3
- import { type Database, type Repositories, schema } from '@manablox/db';
4
- import { and, eq, sql } from 'drizzle-orm';
5
- import type { Principal, SpaceRole } from './rbac.js';
6
-
7
- export interface IssuedApiKey {
8
- id: string;
9
- name: string;
10
- /** The full secret, shown once at creation and never recoverable afterwards. */
11
- key: string;
12
- prefix: string;
13
- }
14
-
15
- export interface IssueApiKeyOptions {
16
- expiresAt?: Date | undefined;
17
- /** Spaces the key may act in. `null`/omitted issues an unrestricted key. */
18
- spaceIds?: string[] | null | undefined;
19
- }
20
-
21
- const PREFIX = 'mbx';
22
-
23
- /**
24
- * Long-lived credentials for headless consumers.
25
- *
26
- * Keys are stored as a SHA-256 digest, never in plaintext, and are looked up by an
27
- * indexed non-secret prefix so verification is one indexed read plus one constant-time
28
- * comparison — not a scan-and-compare over every row.
29
- */
30
- export class ApiKeyService {
31
- constructor(
32
- private readonly db: Database,
33
- private readonly repos: Repositories,
34
- ) {}
35
-
36
- async issue(
37
- userId: string,
38
- name: string,
39
- options: IssueApiKeyOptions = {},
40
- ): Promise<IssuedApiKey> {
41
- const secret = randomBytes(32).toString('base64url');
42
- const prefix = randomBytes(6).toString('hex');
43
- const key = `${PREFIX}_${prefix}_${secret}`;
44
-
45
- const [row] = await this.db
46
- .insert(schema.apikeys)
47
- .values({
48
- userId,
49
- name,
50
- prefix,
51
- start: key.slice(0, 12),
52
- key: digest(secret),
53
- expiresAt: options.expiresAt ?? null,
54
- spaceIds: options.spaceIds?.length ? options.spaceIds : null,
55
- })
56
- .returning();
57
-
58
- if (!row) throw new ManabloxError('apiKey.create.failed');
59
- return { id: row.id, name, key, prefix };
60
- }
61
-
62
- /**
63
- * Deletes the row rather than clearing `enabled`: a revoked key is never listed again
64
- * or re-enabled, so a disabled row is only a secret digest left lying around.
65
- */
66
- async revoke(id: string): Promise<void> {
67
- await this.db.delete(schema.apikeys).where(eq(schema.apikeys.id, id));
68
- }
69
-
70
- async list(userId: string) {
71
- return this.db
72
- .select({
73
- id: schema.apikeys.id,
74
- name: schema.apikeys.name,
75
- start: schema.apikeys.start,
76
- enabled: schema.apikeys.enabled,
77
- expiresAt: schema.apikeys.expiresAt,
78
- lastRequest: schema.apikeys.lastRequest,
79
- spaceIds: schema.apikeys.spaceIds,
80
- createdAt: schema.apikeys.createdAt,
81
- })
82
- .from(schema.apikeys)
83
- .where(eq(schema.apikeys.userId, userId));
84
- }
85
-
86
- async resolve(presented: string): Promise<Principal | null> {
87
- const parts = presented.split('_');
88
- if (parts.length !== 3 || parts[0] !== PREFIX) return null;
89
- const [, prefix, secret] = parts as [string, string, string];
90
-
91
- const rows = await this.db
92
- .select()
93
- .from(schema.apikeys)
94
- .where(and(eq(schema.apikeys.prefix, prefix), eq(schema.apikeys.enabled, true)))
95
- .limit(1);
96
-
97
- const row = rows[0];
98
- if (!row) return null;
99
- if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
100
-
101
- const expected = Buffer.from(row.key, 'hex');
102
- const actual = Buffer.from(digest(secret), 'hex');
103
- if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
104
-
105
- // Best-effort touch; a failure here must never fail the request.
106
- void this.db
107
- .update(schema.apikeys)
108
- .set({ lastRequest: new Date() })
109
- .where(eq(schema.apikeys.id, row.id))
110
- .catch(() => undefined);
111
-
112
- const user = await this.repos.users.findById(row.userId);
113
- if (!user || user.banned) return null;
114
-
115
- const memberships = await this.repos.users.memberships(user.id);
116
- const allowed = row.spaceIds?.length ? new Set(row.spaceIds) : null;
117
- const spaces: Record<string, SpaceRole> = {};
118
- for (const membership of memberships) {
119
- if (allowed && !allowed.has(membership.spaceId)) continue;
120
- spaces[membership.spaceId] = membership.role;
121
- }
122
-
123
- return {
124
- userId: user.id,
125
- email: user.email,
126
- role: user.role,
127
- spaces,
128
- viaApiKey: true,
129
- allowedSpaceIds: allowed ? [...allowed] : null,
130
- };
131
- }
132
-
133
- /** Removes expired keys; scheduled by the jobs package. */
134
- async pruneExpired(): Promise<number> {
135
- const deleted = await this.db
136
- .delete(schema.apikeys)
137
- .where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`)
138
- .returning({ id: schema.apikeys.id });
139
- return deleted.length;
140
- }
141
- }
142
-
143
- const digest = (secret: string): string => createHash('sha256').update(secret).digest('hex');
package/src/index.ts DELETED
@@ -1,155 +0,0 @@
1
- import type { AuthConfig, Manablox } from '@manablox/core';
2
- import type { Database, Repositories } from '@manablox/db';
3
- import { schema } from '@manablox/db';
4
- import { betterAuth } from 'better-auth';
5
- import { drizzleAdapter } from 'better-auth/adapters/drizzle';
6
- import { bearer } from 'better-auth/plugins';
7
- import type { ApiKeyService } from './api-key.js';
8
- import type { Principal } from './rbac.js';
9
-
10
- export * from './api-key.js';
11
- export * from './rbac.js';
12
-
13
- export type ManabloxAuth = ReturnType<typeof createAuth>;
14
-
15
- /** better-auth, wired to the Drizzle schema. Sessions are rows, so concurrent devices
16
- * each hold their own. */
17
- export interface AuthCallbacks {
18
- /** Runs after a user row is created, inside better-auth's own transaction path. */
19
- onUserCreated?: (userId: string) => Promise<void>;
20
- }
21
-
22
- export function createAuth(config: AuthConfig, db: Database, callbacks: AuthCallbacks = {}) {
23
- return betterAuth({
24
- secret: config.secret,
25
- ...(config.baseUrl ? { baseURL: config.baseUrl } : {}),
26
- trustedOrigins: config.trustedOrigins ?? [],
27
-
28
- database: drizzleAdapter(db, {
29
- provider: 'pg',
30
- schema: {
31
- user: schema.users,
32
- session: schema.sessions,
33
- account: schema.accounts,
34
- verification: schema.verifications,
35
- apikey: schema.apikeys,
36
- },
37
- }),
38
-
39
- emailAndPassword: {
40
- enabled: config.emailAndPassword ?? true,
41
- minPasswordLength: 12,
42
- // Argon2id: memory-hard, and the current OWASP recommendation over bcrypt, which
43
- // also silently truncates passwords at 72 bytes.
44
- password: {
45
- hash: async (password) => {
46
- const { hash } = await import('@node-rs/argon2');
47
- return hash(password, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
48
- },
49
- verify: async ({ hash: stored, password }) => {
50
- const { verify } = await import('@node-rs/argon2');
51
- return verify(stored, password);
52
- },
53
- },
54
- },
55
-
56
- session: {
57
- expiresIn: config.sessionMaxAge ?? 60 * 60 * 24 * 7,
58
- updateAge: 60 * 60 * 24,
59
- cookieCache: { enabled: true, maxAge: 60 * 5 },
60
- },
61
-
62
- // `bearer` lets a non-browser client present the session token as an Authorization
63
- // header instead of a cookie. Long-lived machine credentials are handled separately
64
- // by `ApiKeyService` below — better-auth 1.7 ships no api-key plugin.
65
- plugins: [bearer()],
66
-
67
- databaseHooks: {
68
- user: {
69
- create: {
70
- after: async (user) => {
71
- await callbacks.onUserCreated?.(user.id);
72
- },
73
- },
74
- },
75
- },
76
-
77
- advanced: { database: { generateId: () => crypto.randomUUID() } },
78
- });
79
- }
80
-
81
- /**
82
- * Resolves a request's session into a `Principal`, including its space memberships.
83
- * Returns `null` for anonymous requests rather than throwing — route guards decide.
84
- */
85
- export async function resolvePrincipal(
86
- auth: ManabloxAuth,
87
- repos: Repositories,
88
- headers: Headers,
89
- apiKeys?: ApiKeyService,
90
- ): Promise<Principal | null> {
91
- // An `x-api-key` header takes precedence: it identifies a machine consumer and never
92
- // carries a browser session's ambient authority.
93
- const presented = headers.get('x-api-key');
94
- if (presented && apiKeys) {
95
- const principal = await apiKeys.resolve(presented);
96
- if (principal) return principal;
97
- }
98
-
99
- const session = await auth.api.getSession({ headers });
100
- if (!session?.user) return null;
101
-
102
- // Role and memberships come from the database, not from the session payload, so a
103
- // permission change takes effect on the next request rather than when better-auth's
104
- // session cache happens to expire.
105
- const principal = await repos.users.principal(session.user.id);
106
- if (!principal || principal.banned) return null;
107
-
108
- return {
109
- userId: session.user.id,
110
- email: session.user.email,
111
- role: principal.role,
112
- spaces: principal.spaces,
113
- };
114
- }
115
-
116
- /**
117
- * Promotes the very first account to `superadmin` and grants it ownership of every
118
- * existing space, so a fresh install is reachable.
119
- *
120
- * Called from better-auth's user-create hook rather than at startup, so it fires for an
121
- * account created after the server is already running.
122
- */
123
- export async function promoteFirstUser(
124
- manablox: Manablox,
125
- repos: Repositories,
126
- userId: string,
127
- ): Promise<void> {
128
- const count = await repos.users.count();
129
- if (count !== 1) return;
130
-
131
- const user = await repos.users.findById(userId);
132
- if (!user || user.role === 'superadmin') return;
133
-
134
- await repos.users.setRole(userId, 'superadmin');
135
- for (const space of await repos.spaces.all()) {
136
- await repos.users.grant(userId, space.id, 'owner');
137
- }
138
-
139
- manablox.logger.info({ email: user.email }, 'first account promoted to superadmin');
140
- }
141
-
142
- /** Covers an instance whose first account predates this behaviour. */
143
- export function attachBootstrapOwner(manablox: Manablox, repos: Repositories): void {
144
- manablox.hooks.on(
145
- 'after:start',
146
- async () => {
147
- const { items } = await repos.users.list({ limit: 1, offset: 0 });
148
- const first = items[0];
149
- if (first && (await repos.users.count()) === 1) {
150
- await promoteFirstUser(manablox, repos, first.id);
151
- }
152
- },
153
- { source: '@manablox/auth' },
154
- );
155
- }
package/src/rbac.ts DELETED
@@ -1,146 +0,0 @@
1
- import { ManabloxError } from '@manablox/core';
2
-
3
- export type SpaceRole = 'owner' | 'admin' | 'editor' | 'author' | 'viewer';
4
-
5
- export type Permission =
6
- | 'space:read'
7
- | 'space:write'
8
- | 'space:delete'
9
- | 'contentType:read'
10
- | 'contentType:write'
11
- | 'contentType:delete'
12
- | 'content:read'
13
- | 'content:write'
14
- | 'content:delete'
15
- | 'content:publish'
16
- | 'asset:read'
17
- | 'asset:write'
18
- | 'asset:delete'
19
- | 'user:read'
20
- | 'user:write'
21
- | 'webhook:read'
22
- | 'webhook:write';
23
-
24
- /** Role → permission mapping. */
25
- const ROLE_PERMISSIONS: Record<SpaceRole, Permission[]> = {
26
- owner: [
27
- 'space:read',
28
- 'space:write',
29
- 'space:delete',
30
- 'contentType:read',
31
- 'contentType:write',
32
- 'contentType:delete',
33
- 'content:read',
34
- 'content:write',
35
- 'content:delete',
36
- 'content:publish',
37
- 'asset:read',
38
- 'asset:write',
39
- 'asset:delete',
40
- 'user:read',
41
- 'user:write',
42
- 'webhook:read',
43
- 'webhook:write',
44
- ],
45
- admin: [
46
- 'space:read',
47
- 'space:write',
48
- 'contentType:read',
49
- 'contentType:write',
50
- 'contentType:delete',
51
- 'content:read',
52
- 'content:write',
53
- 'content:delete',
54
- 'content:publish',
55
- 'asset:read',
56
- 'asset:write',
57
- 'asset:delete',
58
- 'user:read',
59
- 'user:write',
60
- 'webhook:read',
61
- 'webhook:write',
62
- ],
63
- editor: [
64
- 'space:read',
65
- 'contentType:read',
66
- 'content:read',
67
- 'content:write',
68
- 'content:delete',
69
- 'content:publish',
70
- 'asset:read',
71
- 'asset:write',
72
- 'asset:delete',
73
- 'user:read',
74
- 'webhook:read',
75
- ],
76
- // An author may write and delete, but never publish — the reason the role exists.
77
- author: [
78
- 'space:read',
79
- 'contentType:read',
80
- 'content:read',
81
- 'content:write',
82
- 'content:delete',
83
- 'asset:read',
84
- 'asset:write',
85
- 'user:read',
86
- ],
87
- viewer: ['space:read', 'contentType:read', 'content:read', 'asset:read', 'user:read'],
88
- };
89
-
90
- export interface Principal {
91
- userId: string;
92
- email: string;
93
- /** Instance-wide role; `superadmin` short-circuits every space check. */
94
- role: string;
95
- /** Space id → role in that space. */
96
- spaces: Record<string, SpaceRole>;
97
- /** True when the request authenticated with an API key rather than a session. */
98
- viaApiKey?: boolean;
99
- /**
100
- * Spaces this principal is confined to, or `null`/absent for no confinement. Set by an
101
- * API key that was issued with a space restriction: it narrows the key below its
102
- * owner's own access and, unlike a role, it also binds a superadmin.
103
- */
104
- allowedSpaceIds?: string[] | null;
105
- }
106
-
107
- export function permissionsFor(role: SpaceRole): readonly Permission[] {
108
- return ROLE_PERMISSIONS[role] ?? [];
109
- }
110
-
111
- export function can(
112
- principal: Principal | null,
113
- spaceId: string | null,
114
- permission: Permission,
115
- ): boolean {
116
- if (!principal) return false;
117
- // Checked ahead of the superadmin short-circuit: a restricted key must not reach
118
- // outside its spaces, and an instance-wide operation has no space to be inside.
119
- if (principal.allowedSpaceIds && (!spaceId || !principal.allowedSpaceIds.includes(spaceId))) {
120
- return false;
121
- }
122
- if (principal.role === 'superadmin') return true;
123
- if (!spaceId) return false;
124
-
125
- const role = principal.spaces[spaceId];
126
- if (!role) return false;
127
- return ROLE_PERMISSIONS[role].includes(permission);
128
- }
129
-
130
- export function assertCan(
131
- principal: Principal | null,
132
- spaceId: string | null,
133
- permission: Permission,
134
- ): void {
135
- if (can(principal, spaceId, permission)) return;
136
- if (!principal) throw ManabloxError.unauthorized();
137
- throw ManabloxError.forbidden('auth.forbidden', { permission, spaceId });
138
- }
139
-
140
- /** Roles used by field-level `readRoles`/`writeRoles` checks. */
141
- export function actorRoles(principal: Principal | null, spaceId: string | null): string[] {
142
- if (!principal) return [];
143
- const roles = [principal.role];
144
- if (spaceId && principal.spaces[spaceId]) roles.push(principal.spaces[spaceId]);
145
- return roles;
146
- }
package/test/rbac.test.ts DELETED
@@ -1,58 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { actorRoles, assertCan, can, type Principal, permissionsFor } from '../src/rbac.js';
3
-
4
- const SPACE = 'space-1';
5
-
6
- const principal = (over: Partial<Principal> = {}): Principal => ({
7
- userId: 'u1',
8
- email: 'u@example.com',
9
- role: 'editor',
10
- spaces: { [SPACE]: 'editor' },
11
- ...over,
12
- });
13
-
14
- describe('rbac', () => {
15
- it('denies everything to an anonymous caller', () => {
16
- expect(can(null, SPACE, 'content:read')).toBe(false);
17
- expect(() => assertCan(null, SPACE, 'content:read')).toThrow(/unauthorized/);
18
- });
19
-
20
- it('lets an author write but not publish — the reason the role exists', () => {
21
- const author = principal({ spaces: { [SPACE]: 'author' } });
22
- expect(can(author, SPACE, 'content:write')).toBe(true);
23
- expect(can(author, SPACE, 'content:publish')).toBe(false);
24
- expect(() => assertCan(author, SPACE, 'content:publish')).toThrow(/forbidden/);
25
- });
26
-
27
- it('confines a viewer to reads', () => {
28
- const viewer = principal({ spaces: { [SPACE]: 'viewer' } });
29
- expect(can(viewer, SPACE, 'content:read')).toBe(true);
30
- expect(can(viewer, SPACE, 'content:write')).toBe(false);
31
- expect(can(viewer, SPACE, 'asset:write')).toBe(false);
32
- });
33
-
34
- it('grants nothing in a space the user is not a member of', () => {
35
- expect(can(principal(), 'other-space', 'content:read')).toBe(false);
36
- });
37
-
38
- it('short-circuits every check for a superadmin', () => {
39
- const root = principal({ role: 'superadmin', spaces: {} });
40
- expect(can(root, 'any-space', 'space:delete')).toBe(true);
41
- });
42
-
43
- it('reserves space deletion for the owner', () => {
44
- expect(can(principal({ spaces: { [SPACE]: 'admin' } }), SPACE, 'space:delete')).toBe(false);
45
- expect(can(principal({ spaces: { [SPACE]: 'owner' } }), SPACE, 'space:delete')).toBe(true);
46
- });
47
-
48
- it('exposes the roles a field-level permission check needs', () => {
49
- expect(actorRoles(principal({ role: 'editor' }), SPACE)).toEqual(['editor', 'editor']);
50
- expect(actorRoles(null, SPACE)).toEqual([]);
51
- });
52
-
53
- it('never grants a write permission through a read-only role', () => {
54
- for (const permission of permissionsFor('viewer')) {
55
- expect(permission.endsWith(':read')).toBe(true);
56
- }
57
- });
58
- });
package/tsconfig.json DELETED
@@ -1 +0,0 @@
1
- { "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
package/vitest.config.ts DELETED
@@ -1,2 +0,0 @@
1
- import { defineConfig } from 'vitest/config';
2
- export default defineConfig({ test: { environment: 'node', include: ['test/**/*.test.ts'] } });