@shipfox/api-auth 9.1.0 → 9.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.
Files changed (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +44 -2
  4. package/dist/config.d.ts +5 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +26 -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/administration.d.ts +27 -0
  17. package/dist/core/administration.d.ts.map +1 -0
  18. package/dist/core/administration.js +122 -0
  19. package/dist/core/administration.js.map +1 -0
  20. package/dist/core/auth.d.ts +9 -0
  21. package/dist/core/auth.d.ts.map +1 -1
  22. package/dist/core/auth.js +68 -20
  23. package/dist/core/auth.js.map +1 -1
  24. package/dist/core/entities/admin-grant.d.ts +10 -0
  25. package/dist/core/entities/admin-grant.d.ts.map +1 -0
  26. package/dist/core/entities/admin-grant.js +3 -0
  27. package/dist/core/entities/admin-grant.js.map +1 -0
  28. package/dist/core/errors.d.ts +25 -0
  29. package/dist/core/errors.d.ts.map +1 -1
  30. package/dist/core/errors.js +49 -0
  31. package/dist/core/errors.js.map +1 -1
  32. package/dist/core/ports.d.ts +13 -0
  33. package/dist/core/ports.d.ts.map +1 -0
  34. package/dist/core/ports.js +3 -0
  35. package/dist/core/ports.js.map +1 -0
  36. package/dist/core/signup-policy.d.ts +10 -0
  37. package/dist/core/signup-policy.d.ts.map +1 -0
  38. package/dist/core/signup-policy.js +39 -0
  39. package/dist/core/signup-policy.js.map +1 -0
  40. package/dist/db/admin-grants.d.ts +33 -0
  41. package/dist/db/admin-grants.d.ts.map +1 -0
  42. package/dist/db/admin-grants.js +193 -0
  43. package/dist/db/admin-grants.js.map +1 -0
  44. package/dist/db/db.d.ts +474 -0
  45. package/dist/db/db.d.ts.map +1 -1
  46. package/dist/db/db.js +4 -0
  47. package/dist/db/db.js.map +1 -1
  48. package/dist/db/schema/admin-command-results.d.ts +142 -0
  49. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  50. package/dist/db/schema/admin-command-results.js +21 -0
  51. package/dist/db/schema/admin-command-results.js.map +1 -0
  52. package/dist/db/schema/admin-grants.d.ts +115 -0
  53. package/dist/db/schema/admin-grants.d.ts.map +1 -0
  54. package/dist/db/schema/admin-grants.js +42 -0
  55. package/dist/db/schema/admin-grants.js.map +1 -0
  56. package/dist/index.d.ts +12 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +15 -4
  59. package/dist/index.js.map +1 -1
  60. package/dist/metrics/instance.d.ts +1 -1
  61. package/dist/metrics/instance.d.ts.map +1 -1
  62. package/dist/metrics/instance.js.map +1 -1
  63. package/dist/presentation/dto/user.d.ts +2 -1
  64. package/dist/presentation/dto/user.d.ts.map +1 -1
  65. package/dist/presentation/dto/user.js +2 -1
  66. package/dist/presentation/dto/user.js.map +1 -1
  67. package/dist/presentation/inter-module.d.ts.map +1 -1
  68. package/dist/presentation/inter-module.js +26 -2
  69. package/dist/presentation/inter-module.js.map +1 -1
  70. package/dist/presentation/routes/administration.d.ts +3 -0
  71. package/dist/presentation/routes/administration.d.ts.map +1 -0
  72. package/dist/presentation/routes/administration.js +186 -0
  73. package/dist/presentation/routes/administration.js.map +1 -0
  74. package/dist/presentation/routes/index.d.ts +2 -1
  75. package/dist/presentation/routes/index.d.ts.map +1 -1
  76. package/dist/presentation/routes/index.js +2 -2
  77. package/dist/presentation/routes/index.js.map +1 -1
  78. package/dist/presentation/routes/rate-limit.d.ts +1 -0
  79. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  80. package/dist/presentation/routes/rate-limit.js +20 -1
  81. package/dist/presentation/routes/rate-limit.js.map +1 -1
  82. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  83. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  84. package/dist/presentation/routes/registration/signup.js +14 -3
  85. package/dist/presentation/routes/registration/signup.js.map +1 -1
  86. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  87. package/dist/presentation/routes/session/me.js +5 -1
  88. package/dist/presentation/routes/session/me.js.map +1 -1
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/drizzle/0001_brainy_shriek.sql +14 -0
  91. package/drizzle/0002_burly_whizzer.sql +12 -0
  92. package/drizzle/meta/0001_snapshot.json +702 -0
  93. package/drizzle/meta/0002_snapshot.json +791 -0
  94. package/drizzle/meta/_journal.json +14 -0
  95. package/package.json +10 -9
  96. package/src/config.test.ts +39 -0
  97. package/src/config.ts +34 -0
  98. package/src/core/admin-role-model.ts +25 -0
  99. package/src/core/admin-role.test.ts +39 -0
  100. package/src/core/admin-role.ts +25 -0
  101. package/src/core/administration.ts +172 -0
  102. package/src/core/auth-default-signup-policy.test.ts +65 -0
  103. package/src/core/auth.test.ts +115 -0
  104. package/src/core/auth.ts +77 -21
  105. package/src/core/entities/admin-grant.ts +10 -0
  106. package/src/core/errors.ts +59 -0
  107. package/src/core/jwt.test.ts +1 -0
  108. package/src/core/ports.ts +7 -0
  109. package/src/core/signup-policy.test.ts +78 -0
  110. package/src/core/signup-policy.ts +42 -0
  111. package/src/db/admin-grants.test.ts +57 -0
  112. package/src/db/admin-grants.ts +340 -0
  113. package/src/db/db.ts +4 -0
  114. package/src/db/schema/admin-command-results.ts +41 -0
  115. package/src/db/schema/admin-grants.ts +49 -0
  116. package/src/index.test.ts +58 -1
  117. package/src/index.ts +47 -6
  118. package/src/metrics/instance.ts +1 -1
  119. package/src/presentation/auth/refresh-cookie.test.ts +4 -0
  120. package/src/presentation/dto/user.ts +7 -2
  121. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  122. package/src/presentation/inter-module.test.ts +47 -0
  123. package/src/presentation/inter-module.ts +22 -1
  124. package/src/presentation/routes/administration.test.ts +253 -0
  125. package/src/presentation/routes/administration.ts +195 -0
  126. package/src/presentation/routes/index.test.ts +1 -0
  127. package/src/presentation/routes/index.ts +3 -1
  128. package/src/presentation/routes/rate-limit.ts +23 -2
  129. package/src/presentation/routes/registration/signup.test.ts +26 -0
  130. package/src/presentation/routes/registration/signup.ts +22 -2
  131. package/src/presentation/routes/session/login.test.ts +1 -0
  132. package/src/presentation/routes/session/me.test.ts +18 -2
  133. package/src/presentation/routes/session/me.ts +5 -1
  134. package/test/globalSetup.ts +6 -2
  135. package/test/routes.ts +7 -1
  136. package/tsconfig.build.tsbuildinfo +1 -1
@@ -8,6 +8,20 @@
8
8
  "when": 1784102113104,
9
9
  "tag": "0000_initial",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1785065259979,
16
+ "tag": "0001_brainy_shriek",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1785075183023,
23
+ "tag": "0002_burly_whizzer",
24
+ "breakpoints": true
11
25
  }
12
26
  ]
13
27
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "9.1.0",
4
+ "version": "9.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,20 +26,21 @@
26
26
  "@node-rs/argon2": "^2.0.2",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-auth-context": "9.0.3",
30
- "@shipfox/api-auth-dto": "9.0.2",
31
- "@shipfox/api-email-challenges": "1.1.3",
32
- "@shipfox/api-workspaces-dto": "9.0.2",
29
+ "@shipfox/api-common-dto": "9.2.0",
30
+ "@shipfox/api-auth-context": "9.3.0",
31
+ "@shipfox/api-auth-dto": "9.3.0",
32
+ "@shipfox/api-email-challenges": "1.1.5",
33
+ "@shipfox/api-workspaces-dto": "9.3.0",
33
34
  "@shipfox/inter-module": "0.2.2",
34
35
  "@shipfox/config": "1.2.4",
35
36
  "@shipfox/node-drizzle": "0.3.4",
36
37
  "@shipfox/node-auth-root-key": "0.2.3",
37
- "@shipfox/node-fastify": "0.3.3",
38
+ "@shipfox/node-fastify": "0.3.4",
38
39
  "@shipfox/node-jwt": "0.3.2",
39
40
  "@shipfox/node-email": "0.3.4",
40
- "@shipfox/node-mailer": "0.2.3",
41
- "@shipfox/node-module": "1.0.2",
42
- "@shipfox/node-opentelemetry": "0.6.2",
41
+ "@shipfox/node-mailer": "0.2.4",
42
+ "@shipfox/node-module": "1.0.3",
43
+ "@shipfox/node-opentelemetry": "0.6.3",
43
44
  "@shipfox/node-outbox": "0.2.6",
44
45
  "@shipfox/node-postgres": "0.4.4",
45
46
  "@shipfox/node-rate-limit": "0.3.2",
@@ -0,0 +1,39 @@
1
+ describe('signup gate configuration', () => {
2
+ afterEach(() => {
3
+ vi.unstubAllEnvs();
4
+ vi.resetModules();
5
+ });
6
+
7
+ test('uses the Auth-prefixed defaults', async () => {
8
+ vi.resetModules();
9
+
10
+ const {config} = await import('#config.js');
11
+
12
+ expect(config.AUTH_SIGNUP_GATE_ENABLED).toBe(false);
13
+ expect(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS).toBe('');
14
+ expect(config.AUTH_SIGNUP_ALLOWED_EMAILS).toBe('');
15
+ expect(config.AUTH_SIGNUP_NOT_ALLOWED_MESSAGE).toBeUndefined();
16
+ });
17
+
18
+ test('fails startup when the enabled gate has no allowlist', async () => {
19
+ vi.stubEnv('AUTH_SIGNUP_GATE_ENABLED', 'true');
20
+ vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS', ', ');
21
+ vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAILS', ' , ');
22
+ vi.resetModules();
23
+
24
+ await expect(import('#config.js')).rejects.toThrow(
25
+ 'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS',
26
+ );
27
+ });
28
+
29
+ test('accepts an enabled gate with either allowlist', async () => {
30
+ vi.stubEnv('AUTH_SIGNUP_GATE_ENABLED', 'true');
31
+ vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS', 'shipfox.io');
32
+ vi.resetModules();
33
+
34
+ const {config} = await import('#config.js');
35
+
36
+ expect(config.AUTH_SIGNUP_GATE_ENABLED).toBe(true);
37
+ expect(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS).toBe('shipfox.io');
38
+ });
39
+ });
package/src/config.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import {bool, createConfig, num, str} from '@shipfox/config';
2
2
 
3
3
  export const config = createConfig({
4
+ ADMIN_BOOTSTRAP_TOKEN: str({
5
+ desc: 'Deployment secret accepted once to create the first administrator owner. Set it before bootstrap and remove or rotate it after successful bootstrap.',
6
+ default: undefined,
7
+ }),
4
8
  AUTH_JWT_EXPIRES_IN: str({
5
9
  desc: 'How long an access token stays valid. Accepts a duration string such as 15m, 1h, or 7d.',
6
10
  default: '15m',
@@ -29,8 +33,38 @@ export const config = createConfig({
29
33
  desc: 'Whether password login is available. Use true or false. Defaults to true. When false, password and email-verification routes are not registered, and server startup requires another module to contribute a login method.',
30
34
  default: true,
31
35
  }),
36
+ AUTH_SIGNUP_GATE_ENABLED: bool({
37
+ desc: 'Whether new account creation is restricted to the configured signup email allowlist. Defaults to false, which allows every signup.',
38
+ default: false,
39
+ }),
40
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: str({
41
+ desc: 'Comma-separated email domains allowed to create accounts, such as shipfox.io,acme.com. Required when AUTH_SIGNUP_GATE_ENABLED is true unless AUTH_SIGNUP_ALLOWED_EMAILS is set.',
42
+ default: '',
43
+ }),
44
+ AUTH_SIGNUP_ALLOWED_EMAILS: str({
45
+ desc: 'Comma-separated exact email addresses allowed to create accounts. Required when AUTH_SIGNUP_GATE_ENABLED is true unless AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS is set.',
46
+ default: '',
47
+ }),
48
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: str({
49
+ desc: 'Description returned when the signup gate blocks an account. Optional. The default is This Shipfox deployment does not accept new accounts right now.',
50
+ default: undefined,
51
+ }),
32
52
  CLIENT_BASE_URL: str({
33
53
  desc: 'Base URL of the client app. Used to build links in emails such as password resets.',
34
54
  default: 'http://localhost:5173',
35
55
  }),
36
56
  });
57
+
58
+ if (
59
+ config.AUTH_SIGNUP_GATE_ENABLED &&
60
+ !hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS) &&
61
+ !hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAILS)
62
+ ) {
63
+ throw new Error(
64
+ 'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS to be set.',
65
+ );
66
+ }
67
+
68
+ function hasSignupAllowlistEntry(value: string): boolean {
69
+ return value.split(',').some((entry) => entry.trim().length > 0);
70
+ }
@@ -0,0 +1,25 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+
3
+ export const ADMIN_ROLES: readonly AdminRole[] = [
4
+ 'admin-observer',
5
+ 'admin-operator',
6
+ 'admin-owner',
7
+ ];
8
+
9
+ const ADMIN_ROLE_RANK: Record<AdminRole, number> = {
10
+ 'admin-observer': 1,
11
+ 'admin-operator': 2,
12
+ 'admin-owner': 3,
13
+ };
14
+
15
+ export function hasMinimumAdminRole(role: AdminRole, minimumRole: AdminRole): boolean {
16
+ return ADMIN_ROLE_RANK[role] >= ADMIN_ROLE_RANK[minimumRole];
17
+ }
18
+
19
+ export function highestAdminRole(roles: readonly AdminRole[]): AdminRole | null {
20
+ return roles.reduce<AdminRole | null>(
21
+ (highest, role) =>
22
+ highest === null || ADMIN_ROLE_RANK[role] > ADMIN_ROLE_RANK[highest] ? role : highest,
23
+ null,
24
+ );
25
+ }
@@ -0,0 +1,39 @@
1
+ import {createAdminGrant} from '#db/admin-grants.js';
2
+ import {userFactory} from '#test/index.js';
3
+ import {hasMinimumAdminRole, highestAdminRole, requireAdminRole} from './admin-role.js';
4
+ import type {AdminRoleRequiredError} from './errors.js';
5
+
6
+ describe('admin role policy', () => {
7
+ test.each([
8
+ ['admin-observer', 'admin-observer', true],
9
+ ['admin-observer', 'admin-operator', false],
10
+ ['admin-operator', 'admin-observer', true],
11
+ ['admin-operator', 'admin-owner', false],
12
+ ['admin-owner', 'admin-observer', true],
13
+ ['admin-owner', 'admin-operator', true],
14
+ ['admin-owner', 'admin-owner', true],
15
+ ] as const)('%s satisfies %s: %s', (role, minimumRole, expected) => {
16
+ expect(hasMinimumAdminRole(role, minimumRole)).toBe(expected);
17
+ });
18
+
19
+ test('selects the highest role from fixed grants', () => {
20
+ expect(highestAdminRole(['admin-observer', 'admin-owner', 'admin-operator'])).toBe(
21
+ 'admin-owner',
22
+ );
23
+ expect(highestAdminRole([])).toBeNull();
24
+ });
25
+
26
+ test('evaluates the current role from Auth storage for every required minimum', async () => {
27
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
28
+ await createAdminGrant({userId: user.id, role: 'admin-operator'});
29
+
30
+ await expect(requireAdminRole({userId: user.id, minimumRole: 'admin-observer'})).resolves.toBe(
31
+ 'admin-operator',
32
+ );
33
+ await expect(requireAdminRole({userId: user.id, minimumRole: 'admin-owner'})).rejects.toEqual(
34
+ expect.objectContaining<Partial<AdminRoleRequiredError>>({
35
+ minimumRole: 'admin-owner',
36
+ }),
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,25 @@
1
+ import type {AdminRole} from '@shipfox/api-auth-dto';
2
+ import {findCurrentAdminRole, revokeAdminGrant as revokeAdminGrantInDb} from '#db/admin-grants.js';
3
+ import {hasMinimumAdminRole} from './admin-role-model.js';
4
+ import {AdminRoleRequiredError} from './errors.js';
5
+
6
+ export {ADMIN_ROLES, hasMinimumAdminRole, highestAdminRole} from './admin-role-model.js';
7
+
8
+ export async function getCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
9
+ return await findCurrentAdminRole(params);
10
+ }
11
+
12
+ export async function requireAdminRole(params: {
13
+ userId: string;
14
+ minimumRole: AdminRole;
15
+ }): Promise<AdminRole> {
16
+ const role = await getCurrentAdminRole({userId: params.userId});
17
+ if (!role || !hasMinimumAdminRole(role, params.minimumRole)) {
18
+ throw new AdminRoleRequiredError(params.minimumRole);
19
+ }
20
+ return role;
21
+ }
22
+
23
+ export async function revokeAdminGrant(params: {grantId: string}) {
24
+ return await revokeAdminGrantInDb(params);
25
+ }
@@ -0,0 +1,172 @@
1
+ import {createHash, timingSafeEqual} from 'node:crypto';
2
+ import type {AdminRole} from '@shipfox/api-auth-dto';
3
+ import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
4
+ import {config} from '#config.js';
5
+ import {
6
+ bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
7
+ grantAdminRoleWithAudit,
8
+ listAdminGrants,
9
+ revokeAdminGrantWithAudit,
10
+ } from '#db/admin-grants.js';
11
+ import {requireAdminRole} from './admin-role.js';
12
+ import type {AdminGrant} from './entities/admin-grant.js';
13
+ import {
14
+ AdminBootstrapClosedError,
15
+ AdminGrantAlreadyExistsError,
16
+ AdminGrantNotFoundError,
17
+ AdminIdempotencyKeyReuseError,
18
+ InvalidAdminBootstrapTokenError,
19
+ LastAdminOwnerError,
20
+ UserNotFoundError,
21
+ } from './errors.js';
22
+
23
+ const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
24
+ const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
25
+ const GRANT_COMMAND = 'auth.admin_grant.grant';
26
+ const REVOKE_COMMAND = 'auth.admin_grant.revoke';
27
+
28
+ export function hashAdministrationValue(value: string): string {
29
+ return createHash('sha256').update(value).digest('hex');
30
+ }
31
+
32
+ export function administrationCommandFingerprint(command: string, input: unknown): string {
33
+ return hashAdministrationValue(`${command}:${JSON.stringify(input)}`);
34
+ }
35
+
36
+ function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
37
+ if (!expected) return false;
38
+ const candidateHash = Buffer.from(hashAdministrationValue(candidate), 'hex');
39
+ const expectedHash = Buffer.from(hashAdministrationValue(expected), 'hex');
40
+ return timingSafeEqual(candidateHash, expectedHash);
41
+ }
42
+
43
+ function administrationEvent(params: {
44
+ actorId: string;
45
+ actorRole: AdminRole;
46
+ command: string;
47
+ targetType: string;
48
+ targetId: string;
49
+ reason: string;
50
+ correlationId: string;
51
+ idempotencyKeyFingerprint: string;
52
+ }) {
53
+ return createAdministrationActionEvent({
54
+ actorId: params.actorId,
55
+ actorRole: params.actorRole,
56
+ requiredRole: ADMIN_OWNER_ROLE,
57
+ command: params.command,
58
+ targetType: params.targetType,
59
+ targetId: params.targetId,
60
+ reason: params.reason,
61
+ result: 'succeeded',
62
+ correlationId: params.correlationId,
63
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
64
+ occurredAt: new Date().toISOString(),
65
+ });
66
+ }
67
+
68
+ export interface AdministrationMutationContext {
69
+ actorId: string;
70
+ idempotencyKey: string;
71
+ correlationId: string;
72
+ }
73
+
74
+ export async function bootstrapFirstAdminOwner(
75
+ params: AdministrationMutationContext & {bootstrapToken: string},
76
+ ): Promise<AdminGrant> {
77
+ if (!bootstrapTokenMatches(params.bootstrapToken, config.ADMIN_BOOTSTRAP_TOKEN)) {
78
+ throw new InvalidAdminBootstrapTokenError();
79
+ }
80
+
81
+ return await bootstrapFirstAdminOwnerInDb({
82
+ userId: params.actorId,
83
+ actorId: params.actorId,
84
+ idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
85
+ requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
86
+ actorId: params.actorId,
87
+ }),
88
+ event: administrationEvent({
89
+ actorId: params.actorId,
90
+ actorRole: ADMIN_OWNER_ROLE,
91
+ command: BOOTSTRAP_COMMAND,
92
+ targetType: 'user',
93
+ targetId: params.actorId,
94
+ reason: 'Initial administrator owner bootstrap',
95
+ correlationId: params.correlationId,
96
+ idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
97
+ }),
98
+ });
99
+ }
100
+
101
+ export async function listAdministratorGrants(params: {actorId: string}): Promise<AdminGrant[]> {
102
+ await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OWNER_ROLE});
103
+ return await listAdminGrants();
104
+ }
105
+
106
+ export async function grantAdministratorRole(
107
+ params: AdministrationMutationContext & {userId: string; role: AdminRole; reason: string},
108
+ ): Promise<AdminGrant> {
109
+ const actorRole = await requireAdminRole({
110
+ userId: params.actorId,
111
+ minimumRole: ADMIN_OWNER_ROLE,
112
+ });
113
+ return await grantAdminRoleWithAudit({
114
+ userId: params.userId,
115
+ role: params.role,
116
+ actorId: params.actorId,
117
+ idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
118
+ requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
119
+ userId: params.userId,
120
+ role: params.role,
121
+ reason: params.reason,
122
+ }),
123
+ event: administrationEvent({
124
+ actorId: params.actorId,
125
+ actorRole,
126
+ command: GRANT_COMMAND,
127
+ targetType: 'user',
128
+ targetId: params.userId,
129
+ reason: params.reason,
130
+ correlationId: params.correlationId,
131
+ idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
132
+ }),
133
+ });
134
+ }
135
+
136
+ export async function revokeAdministratorGrant(
137
+ params: AdministrationMutationContext & {grantId: string; reason: string},
138
+ ): Promise<AdminGrant> {
139
+ const actorRole = await requireAdminRole({
140
+ userId: params.actorId,
141
+ minimumRole: ADMIN_OWNER_ROLE,
142
+ });
143
+ return await revokeAdminGrantWithAudit({
144
+ grantId: params.grantId,
145
+ actorId: params.actorId,
146
+ idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
147
+ requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
148
+ grantId: params.grantId,
149
+ reason: params.reason,
150
+ }),
151
+ event: administrationEvent({
152
+ actorId: params.actorId,
153
+ actorRole,
154
+ command: REVOKE_COMMAND,
155
+ targetType: 'admin-grant',
156
+ targetId: params.grantId,
157
+ reason: params.reason,
158
+ correlationId: params.correlationId,
159
+ idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
160
+ }),
161
+ });
162
+ }
163
+
164
+ export {
165
+ AdminBootstrapClosedError,
166
+ AdminGrantAlreadyExistsError,
167
+ AdminGrantNotFoundError,
168
+ AdminIdempotencyKeyReuseError,
169
+ InvalidAdminBootstrapTokenError,
170
+ LastAdminOwnerError,
171
+ UserNotFoundError,
172
+ };
@@ -0,0 +1,65 @@
1
+ import {provisionUser, signup} from '#core/auth.js';
2
+
3
+ const testConfig = vi.hoisted(() => ({
4
+ AUTH_JWT_EXPIRES_IN: '15m',
5
+ AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
6
+ AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
7
+ AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
8
+ AUTH_SIGNUP_GATE_ENABLED: true,
9
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: 'allowed.example',
10
+ AUTH_SIGNUP_ALLOWED_EMAILS: '',
11
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined as string | undefined,
12
+ CLIENT_BASE_URL: 'https://app.example.test',
13
+ }));
14
+
15
+ vi.mock('#config.js', () => ({config: testConfig}));
16
+
17
+ describe('low-level account creation defaults', () => {
18
+ test('signup uses the environment policy when no policy is supplied', async () => {
19
+ const email = `signup-default-${crypto.randomUUID()}@allowed.example`;
20
+
21
+ const user = await signup({email, password: 'correct horse battery staple'});
22
+
23
+ expect(user.email).toBe(email);
24
+ });
25
+
26
+ test('signup rejects an unlisted email when no policy is supplied', async () => {
27
+ const email = `signup-default-${crypto.randomUUID()}@blocked.example`;
28
+
29
+ await expect(signup({email, password: 'correct horse battery staple'})).rejects.toMatchObject({
30
+ name: 'SignupNotAllowedError',
31
+ });
32
+ });
33
+
34
+ test('provisionUser uses the environment policy when no policy is supplied', async () => {
35
+ const email = `provision-default-${crypto.randomUUID()}@allowed.example`;
36
+
37
+ const user = await provisionUser({email});
38
+
39
+ expect(user.email).toBe(email);
40
+ });
41
+
42
+ test('provisionUser rejects an unlisted email when no policy is supplied', async () => {
43
+ const email = `provision-default-${crypto.randomUUID()}@blocked.example`;
44
+
45
+ await expect(provisionUser({email})).rejects.toMatchObject({
46
+ name: 'SignupNotAllowedError',
47
+ });
48
+ });
49
+
50
+ test('an explicit policy overrides the environment policy for both paths', async () => {
51
+ const signupEmail = `signup-override-${crypto.randomUUID()}@blocked.example`;
52
+ const provisionEmail = `provision-override-${crypto.randomUUID()}@blocked.example`;
53
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
54
+ const signupPolicy = {isSignupAllowed};
55
+
56
+ await signup({
57
+ email: signupEmail,
58
+ password: 'correct horse battery staple',
59
+ signupPolicy,
60
+ });
61
+ await provisionUser({email: provisionEmail, signupPolicy});
62
+
63
+ expect(isSignupAllowed).toHaveBeenCalledTimes(2);
64
+ });
65
+ });
@@ -61,6 +61,10 @@ vi.mock('#config.js', () => ({
61
61
  AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
62
62
  AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
63
63
  AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
64
+ AUTH_SIGNUP_GATE_ENABLED: false,
65
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: '',
66
+ AUTH_SIGNUP_ALLOWED_EMAILS: '',
67
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
64
68
  CLIENT_BASE_URL: testConfig.clientBaseUrl,
65
69
  },
66
70
  mailer: testConfig.mailer,
@@ -77,6 +81,7 @@ const workspaces = {
77
81
  preflightInvitationAcceptance: vi.fn(),
78
82
  acceptInvitation: vi.fn(),
79
83
  requireActiveMembership: vi.fn(),
84
+ getWorkspaceOperatingState: vi.fn(),
80
85
  };
81
86
 
82
87
  const login = (params: {email: string; password: string}) => coreLogin({...params, workspaces});
@@ -160,6 +165,71 @@ describe('auth core', () => {
160
165
  await expect(promise).rejects.toBeInstanceOf(EmailTakenError);
161
166
  });
162
167
 
168
+ test('signup normalizes the email before checking the policy', async () => {
169
+ const email = `signup-policy-${crypto.randomUUID()}@example.com`;
170
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
171
+
172
+ await signup({
173
+ email: ` ${email.toUpperCase()} `,
174
+ password: 'correct horse battery staple',
175
+ signupPolicy: {isSignupAllowed},
176
+ });
177
+
178
+ expect(isSignupAllowed).toHaveBeenCalledWith({
179
+ email,
180
+ emailVerified: false,
181
+ source: 'password',
182
+ });
183
+ });
184
+
185
+ test('signup denies a new user when the policy does not allow it', async () => {
186
+ const email = `signup-policy-denied-${crypto.randomUUID()}@example.com`;
187
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
188
+
189
+ await expect(
190
+ signup({
191
+ email,
192
+ password: 'correct horse battery staple',
193
+ signupPolicy: {isSignupAllowed},
194
+ }),
195
+ ).rejects.toEqual(
196
+ expect.objectContaining({
197
+ name: 'SignupNotAllowedError',
198
+ message: 'This Shipfox deployment does not accept new accounts right now.',
199
+ }),
200
+ );
201
+ expect(await findUserByEmail({email})).toBeUndefined();
202
+ });
203
+
204
+ test('signup fails closed when the policy throws', async () => {
205
+ const email = `signup-policy-error-${crypto.randomUUID()}@example.com`;
206
+ const policyError = new Error('policy unavailable');
207
+ const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
208
+
209
+ await expect(
210
+ signup({
211
+ email,
212
+ password: 'correct horse battery staple',
213
+ signupPolicy: {isSignupAllowed},
214
+ }),
215
+ ).rejects.toBe(policyError);
216
+ expect(await findUserByEmail({email})).toBeUndefined();
217
+ });
218
+
219
+ test('signup does not check the policy for an existing user', async () => {
220
+ const existing = await userFactory.create({emailVerifiedAt: new Date()});
221
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
222
+
223
+ await expect(
224
+ signup({
225
+ email: ` ${existing.email.toUpperCase()} `,
226
+ password: 'correct horse battery staple',
227
+ signupPolicy: {isSignupAllowed},
228
+ }),
229
+ ).rejects.toBeInstanceOf(EmailTakenError);
230
+ expect(isSignupAllowed).not.toHaveBeenCalled();
231
+ });
232
+
163
233
  test('signup with an invitation writes the signed-up event with its user insert', async () => {
164
234
  const email = `signup-invitation-${crypto.randomUUID()}@example.com`;
165
235
  const userId = crypto.randomUUID();
@@ -175,6 +245,9 @@ describe('auth core', () => {
175
245
  name: 'Invited User',
176
246
  invitationToken: `invite-${crypto.randomUUID()}`,
177
247
  workspaces,
248
+ signupPolicy: {
249
+ isSignupAllowed: vi.fn().mockRejectedValue(new Error('policy unavailable')),
250
+ },
178
251
  });
179
252
 
180
253
  const events = await outboxEventsTo(email, AUTH_USER_SIGNED_UP);
@@ -199,24 +272,66 @@ describe('auth core', () => {
199
272
  expect(user.status).toBe('active');
200
273
  });
201
274
 
275
+ test('provisionUser normalizes the email before checking the policy', async () => {
276
+ const email = `provision-policy-${crypto.randomUUID()}@example.com`;
277
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
278
+
279
+ await provisionUser({
280
+ email: ` ${email.toUpperCase()} `,
281
+ signupPolicy: {isSignupAllowed},
282
+ });
283
+
284
+ expect(isSignupAllowed).toHaveBeenCalledWith({
285
+ email,
286
+ emailVerified: true,
287
+ source: 'external-identity',
288
+ });
289
+ });
290
+
291
+ test('provisionUser denies a new user when the policy does not allow it', async () => {
292
+ const email = `provision-policy-denied-${crypto.randomUUID()}@example.com`;
293
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false, message: 'Closed beta'});
294
+
295
+ await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toEqual(
296
+ expect.objectContaining({
297
+ name: 'SignupNotAllowedError',
298
+ message: 'Closed beta',
299
+ }),
300
+ );
301
+ expect(await findUserByEmail({email})).toBeUndefined();
302
+ });
303
+
304
+ test('provisionUser fails closed when the policy throws', async () => {
305
+ const email = `provision-policy-error-${crypto.randomUUID()}@example.com`;
306
+ const policyError = new Error('policy unavailable');
307
+ const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
308
+
309
+ await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toBe(policyError);
310
+ expect(await findUserByEmail({email})).toBeUndefined();
311
+ });
312
+
202
313
  test('provisionUser returns existing unverified and suspended users unchanged', async () => {
203
314
  const unverified = await userFactory.create();
204
315
  const suspended = await userFactory.create({emailVerifiedAt: new Date()});
205
316
  await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
206
317
  const storedUnverified = await findUserById({id: unverified.id});
207
318
  const storedSuspended = await findUserById({id: suspended.id});
319
+ const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
208
320
 
209
321
  const existingUnverified = await provisionUser({
210
322
  email: ` ${unverified.email.toUpperCase()} `,
211
323
  name: 'Replacement Name',
324
+ signupPolicy: {isSignupAllowed},
212
325
  });
213
326
  const existingSuspended = await provisionUser({
214
327
  email: ` ${suspended.email.toUpperCase()} `,
215
328
  name: 'Replacement Name',
329
+ signupPolicy: {isSignupAllowed},
216
330
  });
217
331
 
218
332
  expect(existingUnverified).toEqual(storedUnverified);
219
333
  expect(existingSuspended).toEqual(storedSuspended);
334
+ expect(isSignupAllowed).not.toHaveBeenCalled();
220
335
  });
221
336
 
222
337
  test('provisionUser returns one unchanged user for concurrent callbacks', async () => {