@shipfox/api-auth 9.1.0 → 9.2.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.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +28 -3
  4. package/dist/config.d.ts +4 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +22 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/admin-role-model.d.ts +5 -0
  9. package/dist/core/admin-role-model.d.ts.map +1 -0
  10. package/dist/core/admin-role-model.js +18 -0
  11. package/dist/core/admin-role-model.js.map +1 -0
  12. package/dist/core/admin-role.d.ts +13 -0
  13. package/dist/core/admin-role.d.ts.map +1 -0
  14. package/dist/core/admin-role.js +21 -0
  15. package/dist/core/admin-role.js.map +1 -0
  16. package/dist/core/auth.d.ts +9 -0
  17. package/dist/core/auth.d.ts.map +1 -1
  18. package/dist/core/auth.js +71 -20
  19. package/dist/core/auth.js.map +1 -1
  20. package/dist/core/entities/admin-grant.d.ts +10 -0
  21. package/dist/core/entities/admin-grant.d.ts.map +1 -0
  22. package/dist/core/entities/admin-grant.js +3 -0
  23. package/dist/core/entities/admin-grant.js.map +1 -0
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.d.ts.map +1 -1
  26. package/dist/core/errors.js +19 -0
  27. package/dist/core/errors.js.map +1 -1
  28. package/dist/core/ports.d.ts +13 -0
  29. package/dist/core/ports.d.ts.map +1 -0
  30. package/dist/core/ports.js +3 -0
  31. package/dist/core/ports.js.map +1 -0
  32. package/dist/core/signup-policy.d.ts +10 -0
  33. package/dist/core/signup-policy.d.ts.map +1 -0
  34. package/dist/core/signup-policy.js +39 -0
  35. package/dist/core/signup-policy.js.map +1 -0
  36. package/dist/db/admin-grants.d.ts +15 -0
  37. package/dist/db/admin-grants.d.ts.map +1 -0
  38. package/dist/db/admin-grants.js +49 -0
  39. package/dist/db/admin-grants.js.map +1 -0
  40. package/dist/db/db.d.ts +218 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/db.js +2 -0
  43. package/dist/db/db.js.map +1 -1
  44. package/dist/db/schema/admin-grants.d.ts +115 -0
  45. package/dist/db/schema/admin-grants.d.ts.map +1 -0
  46. package/dist/db/schema/admin-grants.js +42 -0
  47. package/dist/db/schema/admin-grants.js.map +1 -0
  48. package/dist/index.d.ts +11 -4
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +6 -3
  51. package/dist/index.js.map +1 -1
  52. package/dist/presentation/dto/user.d.ts +2 -1
  53. package/dist/presentation/dto/user.d.ts.map +1 -1
  54. package/dist/presentation/dto/user.js +2 -1
  55. package/dist/presentation/dto/user.js.map +1 -1
  56. package/dist/presentation/inter-module.d.ts.map +1 -1
  57. package/dist/presentation/inter-module.js +26 -2
  58. package/dist/presentation/inter-module.js.map +1 -1
  59. package/dist/presentation/routes/index.d.ts +2 -1
  60. package/dist/presentation/routes/index.d.ts.map +1 -1
  61. package/dist/presentation/routes/index.js +2 -2
  62. package/dist/presentation/routes/index.js.map +1 -1
  63. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  64. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  65. package/dist/presentation/routes/registration/signup.js +14 -3
  66. package/dist/presentation/routes/registration/signup.js.map +1 -1
  67. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  68. package/dist/presentation/routes/session/me.js +5 -1
  69. package/dist/presentation/routes/session/me.js.map +1 -1
  70. package/dist/tsconfig.test.tsbuildinfo +1 -1
  71. package/drizzle/0001_brainy_shriek.sql +14 -0
  72. package/drizzle/meta/0001_snapshot.json +702 -0
  73. package/drizzle/meta/_journal.json +7 -0
  74. package/package.json +5 -5
  75. package/src/config.test.ts +39 -0
  76. package/src/config.ts +30 -0
  77. package/src/core/admin-role-model.ts +25 -0
  78. package/src/core/admin-role.test.ts +39 -0
  79. package/src/core/admin-role.ts +25 -0
  80. package/src/core/auth.test.ts +110 -0
  81. package/src/core/auth.ts +78 -21
  82. package/src/core/entities/admin-grant.ts +10 -0
  83. package/src/core/errors.ts +24 -0
  84. package/src/core/jwt.test.ts +1 -0
  85. package/src/core/ports.ts +7 -0
  86. package/src/core/signup-policy.test.ts +78 -0
  87. package/src/core/signup-policy.ts +42 -0
  88. package/src/db/admin-grants.test.ts +57 -0
  89. package/src/db/admin-grants.ts +83 -0
  90. package/src/db/db.ts +2 -0
  91. package/src/db/schema/admin-grants.ts +49 -0
  92. package/src/index.test.ts +53 -0
  93. package/src/index.ts +28 -5
  94. package/src/presentation/dto/user.ts +7 -2
  95. package/src/presentation/inter-module.test.ts +47 -0
  96. package/src/presentation/inter-module.ts +22 -1
  97. package/src/presentation/routes/index.ts +3 -1
  98. package/src/presentation/routes/registration/signup.test.ts +26 -0
  99. package/src/presentation/routes/registration/signup.ts +22 -2
  100. package/src/presentation/routes/session/login.test.ts +1 -0
  101. package/src/presentation/routes/session/me.test.ts +18 -2
  102. package/src/presentation/routes/session/me.ts +5 -1
  103. package/test/globalSetup.ts +1 -1
  104. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,7 @@
1
+ export interface SignupPolicy {
2
+ isSignupAllowed(params: {
3
+ email: string;
4
+ emailVerified: boolean;
5
+ source: string;
6
+ }): Promise<{allowed: true} | {allowed: false; message?: string}>;
7
+ }
@@ -0,0 +1,78 @@
1
+ import {config} from '#config.js';
2
+ import {
3
+ createEnvironmentSignupPolicy,
4
+ DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
5
+ } from './signup-policy.js';
6
+
7
+ vi.mock('#config.js', () => ({
8
+ config: {
9
+ AUTH_SIGNUP_GATE_ENABLED: true,
10
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: ' Shipfox.io, acme.com ',
11
+ AUTH_SIGNUP_ALLOWED_EMAILS: ' Alice@Example.com, ',
12
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
13
+ },
14
+ }));
15
+
16
+ describe('createEnvironmentSignupPolicy', () => {
17
+ afterEach(() => {
18
+ vi.mocked(config).AUTH_SIGNUP_GATE_ENABLED = true;
19
+ vi.mocked(config).AUTH_SIGNUP_NOT_ALLOWED_MESSAGE = undefined;
20
+ });
21
+
22
+ test('allows exact addresses and domains after normalization', async () => {
23
+ const policy = createEnvironmentSignupPolicy();
24
+
25
+ await expect(
26
+ policy.isSignupAllowed({
27
+ email: ' alice@example.com ',
28
+ emailVerified: false,
29
+ source: 'password',
30
+ }),
31
+ ).resolves.toEqual({allowed: true});
32
+ await expect(
33
+ policy.isSignupAllowed({
34
+ email: 'member@SHIPFOX.IO',
35
+ emailVerified: true,
36
+ source: 'oauth-google',
37
+ }),
38
+ ).resolves.toEqual({allowed: true});
39
+ });
40
+
41
+ test('does not match subdomains', async () => {
42
+ const policy = createEnvironmentSignupPolicy();
43
+
44
+ await expect(
45
+ policy.isSignupAllowed({
46
+ email: 'member@eu.shipfox.io',
47
+ emailVerified: true,
48
+ source: 'oauth-google',
49
+ }),
50
+ ).resolves.toEqual({allowed: false, message: DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE});
51
+ });
52
+
53
+ test('returns the configured denial message for an unmatched address', async () => {
54
+ vi.mocked(config).AUTH_SIGNUP_NOT_ALLOWED_MESSAGE = 'Ask your administrator for access.';
55
+ const policy = createEnvironmentSignupPolicy();
56
+
57
+ await expect(
58
+ policy.isSignupAllowed({
59
+ email: 'member@other.example',
60
+ emailVerified: true,
61
+ source: 'oauth-google',
62
+ }),
63
+ ).resolves.toEqual({allowed: false, message: 'Ask your administrator for access.'});
64
+ });
65
+
66
+ test('allows every address when the gate is disabled', async () => {
67
+ vi.mocked(config).AUTH_SIGNUP_GATE_ENABLED = false;
68
+ const policy = createEnvironmentSignupPolicy();
69
+
70
+ await expect(
71
+ policy.isSignupAllowed({
72
+ email: 'member@other.example',
73
+ emailVerified: false,
74
+ source: 'password',
75
+ }),
76
+ ).resolves.toEqual({allowed: true});
77
+ });
78
+ });
@@ -0,0 +1,42 @@
1
+ import {config} from '#config.js';
2
+ import type {SignupPolicy} from './ports.js';
3
+
4
+ export const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
5
+ 'This Shipfox deployment does not accept new accounts right now.';
6
+
7
+ /**
8
+ * Builds the default signup policy from the Auth module's environment settings.
9
+ *
10
+ * The allowlist is resolved once at startup. Environment values are normalized
11
+ * before matching, but the configured denial message is returned unchanged.
12
+ */
13
+ export function createEnvironmentSignupPolicy(): SignupPolicy {
14
+ const allowedEmails = parseAllowlist(config.AUTH_SIGNUP_ALLOWED_EMAILS);
15
+ const allowedDomains = parseAllowlist(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS);
16
+
17
+ return {
18
+ isSignupAllowed({email}) {
19
+ if (!config.AUTH_SIGNUP_GATE_ENABLED) return Promise.resolve({allowed: true});
20
+
21
+ const normalizedEmail = normalize(email);
22
+ if (allowedEmails.has(normalizedEmail)) return Promise.resolve({allowed: true});
23
+
24
+ const atIndex = normalizedEmail.lastIndexOf('@');
25
+ const domain = atIndex === -1 ? '' : normalizedEmail.slice(atIndex + 1);
26
+ if (allowedDomains.has(domain)) return Promise.resolve({allowed: true});
27
+
28
+ return Promise.resolve({
29
+ allowed: false,
30
+ message: config.AUTH_SIGNUP_NOT_ALLOWED_MESSAGE ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
31
+ });
32
+ },
33
+ };
34
+ }
35
+
36
+ function parseAllowlist(value: string): ReadonlySet<string> {
37
+ return new Set(value.split(',').map(normalize).filter(Boolean));
38
+ }
39
+
40
+ function normalize(value: string): string {
41
+ return value.trim().toLowerCase();
42
+ }
@@ -0,0 +1,57 @@
1
+ import {eq} from 'drizzle-orm';
2
+ import {LastAdminOwnerError} from '#core/errors.js';
3
+ import {userFactory} from '#test/index.js';
4
+ import {
5
+ createAdminGrant,
6
+ findCurrentAdminRole,
7
+ listAdminGrants,
8
+ revokeAdminGrant,
9
+ } from './admin-grants.js';
10
+ import {db} from './db.js';
11
+ import {users} from './schema/users.js';
12
+
13
+ describe('admin grants db', () => {
14
+ test('evaluates the highest active grant independently of workspace roles', async () => {
15
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
16
+ const observer = await createAdminGrant({userId: user.id, role: 'admin-observer'});
17
+ await createAdminGrant({userId: user.id, role: 'admin-operator'});
18
+
19
+ expect(observer.userId).toBe(user.id);
20
+ expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-operator');
21
+ expect((await listAdminGrants()).filter((grant) => grant.userId === user.id)).toHaveLength(2);
22
+ });
23
+
24
+ test('does not evaluate revoked grants or grants for suspended users', async () => {
25
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
26
+ const grant = await createAdminGrant({userId: user.id, role: 'admin-owner'});
27
+
28
+ await expect(revokeAdminGrant({grantId: grant.id})).rejects.toBeInstanceOf(LastAdminOwnerError);
29
+ expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-owner');
30
+
31
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
32
+ expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
33
+ });
34
+
35
+ test('prevents revoking the final active owner but permits replacement first', async () => {
36
+ const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
37
+ const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
38
+ const firstGrant = await createAdminGrant({userId: firstOwner.id, role: 'admin-owner'});
39
+ const secondGrant = await createAdminGrant({userId: secondOwner.id, role: 'admin-owner'});
40
+
41
+ await expect(revokeAdminGrant({grantId: firstGrant.id})).resolves.toMatchObject({
42
+ id: firstGrant.id,
43
+ revokedAt: expect.any(Date),
44
+ });
45
+ await expect(revokeAdminGrant({grantId: secondGrant.id})).rejects.toBeInstanceOf(
46
+ LastAdminOwnerError,
47
+ );
48
+ expect(await findCurrentAdminRole({userId: secondOwner.id})).toBe('admin-owner');
49
+ });
50
+
51
+ test('enforces one active grant of each fixed role per user', async () => {
52
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
53
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
54
+
55
+ await expect(createAdminGrant({userId: user.id, role: 'admin-observer'})).rejects.toThrow();
56
+ });
57
+ });
@@ -0,0 +1,83 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import {and, asc, eq, isNull, sql} from 'drizzle-orm';
3
+ import {highestAdminRole} from '#core/admin-role-model.js';
4
+ import type {AdminGrant} from '#core/entities/admin-grant.js';
5
+ import {LastAdminOwnerError} from '#core/errors.js';
6
+ import {db} from './db.js';
7
+ import {adminGrants, toAdminGrant} from './schema/admin-grants.js';
8
+ import {users} from './schema/users.js';
9
+
10
+ export interface CreateAdminGrantParams {
11
+ userId: string;
12
+ role: AdminRole;
13
+ }
14
+
15
+ export async function createAdminGrant(params: CreateAdminGrantParams): Promise<AdminGrant> {
16
+ const rows = await db()
17
+ .insert(adminGrants)
18
+ .values({userId: params.userId, role: params.role})
19
+ .returning();
20
+ const row = rows[0];
21
+ if (!row) throw new Error('Insert returned no rows');
22
+ return toAdminGrant(row);
23
+ }
24
+
25
+ export async function listAdminGrants(): Promise<AdminGrant[]> {
26
+ const rows = await db().select().from(adminGrants).orderBy(asc(adminGrants.createdAt));
27
+ return rows.map(toAdminGrant);
28
+ }
29
+
30
+ export async function findCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
31
+ const rows = await db()
32
+ .select({role: adminGrants.role})
33
+ .from(adminGrants)
34
+ .innerJoin(users, eq(adminGrants.userId, users.id))
35
+ .where(
36
+ and(
37
+ eq(adminGrants.userId, params.userId),
38
+ isNull(adminGrants.revokedAt),
39
+ eq(users.status, 'active'),
40
+ ),
41
+ );
42
+
43
+ return highestAdminRole(rows.map(({role}) => role));
44
+ }
45
+
46
+ export async function revokeAdminGrant(params: {grantId: string}): Promise<AdminGrant | undefined> {
47
+ return await db().transaction(async (tx) => {
48
+ // All owner grant changes share one lock so concurrent revocations cannot
49
+ // both observe the same final owner and leave the instance ownerless.
50
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
51
+
52
+ const rows = await tx
53
+ .select()
54
+ .from(adminGrants)
55
+ .where(and(eq(adminGrants.id, params.grantId), isNull(adminGrants.revokedAt)))
56
+ .limit(1);
57
+ const grant = rows[0];
58
+ if (!grant) return undefined;
59
+
60
+ if (grant.role === 'admin-owner') {
61
+ const activeOwners = await tx
62
+ .select({id: adminGrants.id})
63
+ .from(adminGrants)
64
+ .innerJoin(users, eq(adminGrants.userId, users.id))
65
+ .where(
66
+ and(
67
+ eq(adminGrants.role, 'admin-owner'),
68
+ isNull(adminGrants.revokedAt),
69
+ eq(users.status, 'active'),
70
+ ),
71
+ );
72
+ if (activeOwners.length <= 1) throw new LastAdminOwnerError();
73
+ }
74
+
75
+ const updated = await tx
76
+ .update(adminGrants)
77
+ .set({revokedAt: new Date(), updatedAt: new Date()})
78
+ .where(and(eq(adminGrants.id, grant.id), isNull(adminGrants.revokedAt)))
79
+ .returning();
80
+ const row = updated[0];
81
+ return row ? toAdminGrant(row) : undefined;
82
+ });
83
+ }
package/src/db/db.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
2
  import {pgClient} from '@shipfox/node-postgres';
3
+ import {adminGrants} from './schema/admin-grants.js';
3
4
  import {authOutbox} from './schema/outbox.js';
4
5
  import {passwordResets} from './schema/password-resets.js';
5
6
  import {authRateLimits} from './schema/rate-limits.js';
@@ -7,6 +8,7 @@ import {refreshTokens} from './schema/refresh-tokens.js';
7
8
  import {users} from './schema/users.js';
8
9
 
9
10
  export const schema = {
11
+ adminGrants,
10
12
  users,
11
13
  passwordResets,
12
14
  refreshTokens,
@@ -0,0 +1,49 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
3
+ import {index, pgEnum, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
4
+ import type {AdminGrant} from '#core/entities/admin-grant.js';
5
+ import {pgTable} from './common.js';
6
+ import {users} from './users.js';
7
+
8
+ export const adminRoleEnum = pgEnum('auth_admin_role', [
9
+ 'admin-observer',
10
+ 'admin-operator',
11
+ 'admin-owner',
12
+ ]);
13
+
14
+ export const adminGrants = pgTable(
15
+ 'admin_grants',
16
+ {
17
+ id: uuidv7PrimaryKey(),
18
+ userId: uuid('user_id')
19
+ .notNull()
20
+ .references(() => users.id, {onDelete: 'cascade'}),
21
+ role: adminRoleEnum('role').notNull(),
22
+ revokedAt: timestamp('revoked_at', {withTimezone: true}),
23
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
24
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
25
+ },
26
+ (table) => [
27
+ index('auth_admin_grants_user_id_idx').on(table.userId),
28
+ index('auth_admin_grants_active_owners_idx')
29
+ .on(table.userId)
30
+ .where(sql`${table.role} = 'admin-owner' AND ${table.revokedAt} IS NULL`),
31
+ uniqueIndex('auth_admin_grants_active_user_role_unique')
32
+ .on(table.userId, table.role)
33
+ .where(sql`${table.revokedAt} IS NULL`),
34
+ ],
35
+ );
36
+
37
+ export type AdminGrantDb = typeof adminGrants.$inferSelect;
38
+ export type AdminGrantCreateDb = typeof adminGrants.$inferInsert;
39
+
40
+ export function toAdminGrant(row: AdminGrantDb): AdminGrant {
41
+ return {
42
+ id: row.id,
43
+ userId: row.userId,
44
+ role: row.role,
45
+ revokedAt: row.revokedAt,
46
+ createdAt: row.createdAt,
47
+ updatedAt: row.updatedAt,
48
+ };
49
+ }
package/src/index.test.ts CHANGED
@@ -6,6 +6,22 @@ import {
6
6
  import {createAuthModule} from './index.js';
7
7
  import {passwordLoginMethods} from './login-methods.js';
8
8
 
9
+ type SignupPolicyLike = {
10
+ isSignupAllowed(params: {
11
+ email: string;
12
+ emailVerified: boolean;
13
+ source: string;
14
+ }): Promise<{allowed: true} | {allowed: false; message?: string}>;
15
+ };
16
+
17
+ const buildAuthRoutes = vi.hoisted(() =>
18
+ vi.fn((_passwordEnabled: boolean, _workspaces: unknown, _signupPolicy?: SignupPolicyLike) => ({
19
+ prefix: '/auth',
20
+ plugins: [],
21
+ routes: [],
22
+ })),
23
+ );
24
+
9
25
  vi.mock('#config.js', () => ({
10
26
  config: {
11
27
  AUTH_JWT_EXPIRES_IN: '15m',
@@ -14,15 +30,24 @@ vi.mock('#config.js', () => ({
14
30
  AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
15
31
  AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
16
32
  AUTH_PASSWORD_ENABLED: true,
33
+ AUTH_SIGNUP_GATE_ENABLED: true,
34
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: 'example.com',
35
+ AUTH_SIGNUP_ALLOWED_EMAILS: '',
36
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
17
37
  CLIENT_BASE_URL: 'https://app.example.test',
18
38
  },
19
39
  }));
20
40
 
41
+ vi.mock('#presentation/routes/index.js', () => ({buildAuthRoutes}));
42
+
21
43
  vi.mock('@shipfox/node-mailer', () => ({
22
44
  mailer: {send: vi.fn()},
23
45
  }));
24
46
 
25
47
  describe('authModule', () => {
48
+ const signupPolicy = {
49
+ isSignupAllowed: async () => ({allowed: true as const}),
50
+ };
26
51
  const authModule = createAuthModule({
27
52
  workspaces: {
28
53
  listMembershipsForTokenClaims: vi.fn(),
@@ -31,6 +56,7 @@ describe('authModule', () => {
31
56
  acceptInvitation: vi.fn(),
32
57
  requireActiveMembership: vi.fn(),
33
58
  },
59
+ signupPolicy,
34
60
  });
35
61
  test('declares password login only when password login is enabled', () => {
36
62
  expect(passwordLoginMethods(true)).toEqual([{id: 'password'}]);
@@ -38,6 +64,33 @@ describe('authModule', () => {
38
64
  expect(authModule.loginMethods).toEqual([{id: 'password'}]);
39
65
  });
40
66
 
67
+ test('uses the environment signup policy when none is provided', async () => {
68
+ buildAuthRoutes.mockClear();
69
+ const module = createAuthModule({
70
+ workspaces: {
71
+ listMembershipsForTokenClaims: vi.fn(),
72
+ getWorkspaceCreator: vi.fn(),
73
+ preflightInvitationAcceptance: vi.fn(),
74
+ acceptInvitation: vi.fn(),
75
+ requireActiveMembership: vi.fn(),
76
+ },
77
+ });
78
+ expect(module.routes).toHaveLength(1);
79
+ const signupPolicy = buildAuthRoutes.mock.calls[0]?.[2];
80
+
81
+ expect(signupPolicy).toEqual(expect.objectContaining({isSignupAllowed: expect.any(Function)}));
82
+ await expect(
83
+ signupPolicy?.isSignupAllowed({
84
+ email: 'person@other.example',
85
+ emailVerified: false,
86
+ source: 'password',
87
+ }),
88
+ ).resolves.toEqual({
89
+ allowed: false,
90
+ message: 'This Shipfox deployment does not accept new accounts right now.',
91
+ });
92
+ });
93
+
41
94
  test('registers auth email outbox publisher and subscribers', () => {
42
95
  const publisher = authModule.publishers?.find((pub) => pub.name === 'auth');
43
96
  const events = authModule.subscribers?.map((subscriber) => subscriber.event);
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  import type {ShipfoxModule} from '@shipfox/node-module';
7
7
  import {subscriberFactory} from '@shipfox/node-module';
8
8
  import {config} from '#config.js';
9
+ import type {SignupPolicy} from '#core/ports.js';
10
+ import {createEnvironmentSignupPolicy} from '#core/signup-policy.js';
9
11
  import {db} from '#db/db.js';
10
12
  import {migrationsPath} from '#db/migrations.js';
11
13
  import {authOutbox} from '#db/schema/outbox.js';
@@ -18,7 +20,15 @@ import {buildAuthRoutes} from '#presentation/routes/index.js';
18
20
  import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
19
21
  import {passwordLoginMethods} from './login-methods.js';
20
22
 
21
- export type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
23
+ export type {AdminRole, JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
24
+ export {
25
+ ADMIN_ROLES,
26
+ getCurrentAdminRole,
27
+ hasMinimumAdminRole,
28
+ highestAdminRole,
29
+ requireAdminRole,
30
+ revokeAdminGrant,
31
+ } from '#core/admin-role.js';
22
32
  export type {
23
33
  CreateSessionForUserError,
24
34
  CreateSessionForUserParams,
@@ -28,11 +38,15 @@ export type {
28
38
  export {createSessionForUser, provisionUser} from '#core/auth.js';
29
39
  export type {EmailOwner, FindUserByEmailParams} from '#core/email-owner.js';
30
40
  export {findUserByEmail} from '#core/email-owner.js';
41
+ export type {AdminGrant} from '#core/entities/admin-grant.js';
31
42
  export type {User, UserStatus} from '#core/entities/user.js';
32
43
  export {
44
+ AdminRoleRequiredError,
33
45
  AuthDependencyUnavailableError,
34
46
  EmailNotVerifiedError,
35
47
  InvalidCredentialsError,
48
+ LastAdminOwnerError,
49
+ SignupNotAllowedError,
36
50
  UserNotFoundError,
37
51
  } from '#core/errors.js';
38
52
  export {
@@ -40,10 +54,15 @@ export {
40
54
  jobLeaseParamsFrom,
41
55
  verifyJobLeaseToken,
42
56
  } from '#core/job-lease-token.js';
57
+ export type {SignupPolicy} from '#core/ports.js';
43
58
  export {
44
59
  issueRunnerSessionToken,
45
60
  verifyRunnerSessionToken,
46
61
  } from '#core/runner-session-token.js';
62
+ export {
63
+ createEnvironmentSignupPolicy,
64
+ DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
65
+ } from '#core/signup-policy.js';
47
66
  export {
48
67
  type AuthenticatedSessionContext,
49
68
  createJwtAuthMethod,
@@ -62,17 +81,21 @@ export {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-a
62
81
 
63
82
  const subscriber = subscriberFactory<AuthEventMap>();
64
83
 
84
+ export interface CreateAuthModuleOptions {
85
+ workspaces: import('@shipfox/api-workspaces-dto/inter-module').WorkspacesInterModuleClient;
86
+ signupPolicy?: SignupPolicy;
87
+ }
88
+
65
89
  export function createAuthModule({
66
90
  workspaces,
67
- }: {
68
- workspaces: import('@shipfox/api-workspaces-dto/inter-module').WorkspacesInterModuleClient;
69
- }): ShipfoxModule {
91
+ signupPolicy = createEnvironmentSignupPolicy(),
92
+ }: CreateAuthModuleOptions): ShipfoxModule {
70
93
  return {
71
94
  name: 'auth',
72
95
  database: {db, migrationsPath, databaseNamespace: 'auth'},
73
96
  auth: [createJwtAuthMethod(), createLeaseTokenAuthMethod(), createRunnerSessionAuthMethod()],
74
97
  loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
75
- routes: [buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces)],
98
+ routes: [buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy)],
76
99
  e2eRoutes: [createAuthE2eRoutes(workspaces)],
77
100
  publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authEventSchemas}],
78
101
  subscribers: [subscriber(AUTH_PASSWORD_RESET_SEND_REQUESTED, onPasswordResetSendRequested)],
@@ -1,4 +1,4 @@
1
- import type {LoginResponseDto, UserDto} from '@shipfox/api-auth-dto';
1
+ import type {AdminRole, LoginResponseDto, UserDto} from '@shipfox/api-auth-dto';
2
2
  import type {User} from '#core/entities/user.js';
3
3
 
4
4
  export function toUserDto(user: User): UserDto {
@@ -13,9 +13,14 @@ export function toUserDto(user: User): UserDto {
13
13
  };
14
14
  }
15
15
 
16
- export function toAuthSessionDto(result: {token: string; user: User}): LoginResponseDto {
16
+ export function toAuthSessionDto(result: {
17
+ token: string;
18
+ user: User;
19
+ adminRole?: AdminRole | null;
20
+ }): LoginResponseDto {
17
21
  return {
18
22
  token: result.token,
19
23
  user: toUserDto(result.user),
24
+ admin_role: result.adminRole ?? null,
20
25
  };
21
26
  }
@@ -0,0 +1,47 @@
1
+ import {authInterModuleContract} from '@shipfox/api-auth-dto/inter-module';
2
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
3
+ import {createInMemoryInterModuleTransport} from '@shipfox/node-module/inter-module';
4
+ import {createAdminGrant} from '#db/admin-grants.js';
5
+ import {userFactory} from '#test/index.js';
6
+ import {createAuthInterModulePresentation} from './inter-module.js';
7
+
8
+ function createClient() {
9
+ const transport = createInMemoryInterModuleTransport();
10
+ const client = transport.createClient(authInterModuleContract);
11
+ transport.register(createAuthInterModulePresentation());
12
+ transport.seal();
13
+ return client;
14
+ }
15
+
16
+ describe('Auth inter-module administration role presentation', () => {
17
+ test('returns the current role from Auth storage', async () => {
18
+ const client = createClient();
19
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
20
+ await createAdminGrant({userId: user.id, role: 'admin-owner'});
21
+
22
+ await expect(client.getCurrentAdminRole({userId: user.id})).resolves.toEqual({
23
+ role: 'admin-owner',
24
+ });
25
+ await expect(
26
+ client.requireAdminRole({userId: user.id, minimumRole: 'admin-operator'}),
27
+ ).resolves.toEqual({role: 'admin-owner'});
28
+ });
29
+
30
+ test('maps an insufficient current role to the declared known error', async () => {
31
+ const client = createClient();
32
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
33
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
34
+
35
+ const error = await client
36
+ .requireAdminRole({userId: user.id, minimumRole: 'admin-owner'})
37
+ .catch((caught: unknown) => caught);
38
+
39
+ expect(isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error)).toBe(
40
+ true,
41
+ );
42
+ if (isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error)) {
43
+ expect(error.code).toBe('admin-role-required');
44
+ expect(error.details).toEqual({requiredRole: 'admin-owner'});
45
+ }
46
+ });
47
+ });
@@ -1,5 +1,11 @@
1
1
  import {authInterModuleContract} from '@shipfox/api-auth-dto/inter-module';
2
- import {defineInterModulePresentation, type InterModulePresentation} from '@shipfox/inter-module';
2
+ import {
3
+ createInterModuleKnownError,
4
+ defineInterModulePresentation,
5
+ type InterModulePresentation,
6
+ } from '@shipfox/inter-module';
7
+ import {getCurrentAdminRole, requireAdminRole} from '#core/admin-role.js';
8
+ import {AdminRoleRequiredError} from '#core/errors.js';
3
9
  import {issueJobLeaseToken} from '#core/job-lease-token.js';
4
10
  import {issueRunnerSessionToken} from '#core/runner-session-token.js';
5
11
 
@@ -9,5 +15,20 @@ export function createAuthInterModulePresentation(): InterModulePresentation<
9
15
  return defineInterModulePresentation(authInterModuleContract, {
10
16
  mintRunnerSessionToken: async (claims) => ({token: await issueRunnerSessionToken(claims)}),
11
17
  mintJobLeaseToken: async (claims) => ({token: await issueJobLeaseToken(claims)}),
18
+ getCurrentAdminRole: async ({userId}) => ({role: await getCurrentAdminRole({userId})}),
19
+ requireAdminRole: async ({userId, minimumRole}) => {
20
+ try {
21
+ return {role: await requireAdminRole({userId, minimumRole})};
22
+ } catch (error) {
23
+ if (error instanceof AdminRoleRequiredError) {
24
+ throw createInterModuleKnownError(
25
+ authInterModuleContract.methods.requireAdminRole,
26
+ 'admin-role-required',
27
+ {requiredRole: error.minimumRole},
28
+ );
29
+ }
30
+ throw error;
31
+ }
32
+ },
12
33
  });
13
34
  }
@@ -1,5 +1,6 @@
1
1
  import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
2
2
  import type {RouteGroup} from '@shipfox/node-fastify';
3
+ import type {SignupPolicy} from '#core/ports.js';
3
4
  import {authCookiePlugin} from '#presentation/auth/refresh-cookie.js';
4
5
  import {createVerifyEmailConfirmRoute} from './email-verification/verify-email-confirm.js';
5
6
  import {verifyEmailResendRoute} from './email-verification/verify-email-resend.js';
@@ -15,10 +16,11 @@ import {createRefreshRoute} from './session/refresh.js';
15
16
  export function buildAuthRoutes(
16
17
  passwordEnabled: boolean,
17
18
  workspaces: WorkspacesInterModuleClient,
19
+ signupPolicy?: SignupPolicy,
18
20
  ): RouteGroup {
19
21
  const passwordRoutes = passwordEnabled
20
22
  ? [
21
- createSignupRoute(workspaces),
23
+ createSignupRoute(workspaces, signupPolicy),
22
24
  createVerifyEmailConfirmRoute(workspaces),
23
25
  verifyEmailResendRoute,
24
26
  createLoginRoute(workspaces),
@@ -1,6 +1,9 @@
1
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
1
2
  import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
2
3
  import {createInterModuleKnownError} from '@shipfox/inter-module';
4
+ import {ClientError} from '@shipfox/node-fastify';
3
5
  import type {FastifyInstance} from 'fastify';
6
+ import {SignupNotAllowedError} from '#core/errors.js';
4
7
  import {verifyUserToken} from '#core/jwt.js';
5
8
  import {
6
9
  acceptWorkspaceInvitationMock,
@@ -14,6 +17,7 @@ import {
14
17
  signup,
15
18
  uniqueEmail,
16
19
  } from '#test/routes.js';
20
+ import {createSignupRoute} from './signup.js';
17
21
 
18
22
  describe('POST /auth/signup', () => {
19
23
  let app: FastifyInstance;
@@ -27,6 +31,28 @@ describe('POST /auth/signup', () => {
27
31
  resetCapturedMail();
28
32
  });
29
33
 
34
+ test('maps a denied signup to a bounded client error', () => {
35
+ const errorHandler = createSignupRoute({} as WorkspacesInterModuleClient).errorHandler as (
36
+ error: unknown,
37
+ ) => never;
38
+ const message = 'x'.repeat(501);
39
+ let mapped: unknown;
40
+
41
+ try {
42
+ errorHandler(new SignupNotAllowedError(message));
43
+ } catch (error) {
44
+ mapped = error;
45
+ }
46
+
47
+ expect(mapped).toBeInstanceOf(ClientError);
48
+ expect(mapped).toMatchObject({
49
+ message: 'x'.repeat(500),
50
+ status: 403,
51
+ code: 'signup-not-allowed',
52
+ details: {message: 'x'.repeat(500)},
53
+ });
54
+ });
55
+
30
56
  afterAll(async () => {
31
57
  await app.close();
32
58
  });