@shipfox/api-auth 18.0.0 → 20.0.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 (151) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +24 -9
  4. package/dist/core/administration.d.ts +15 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +44 -2
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +5 -3
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/cimd.d.ts +39 -0
  12. package/dist/core/cimd.d.ts.map +1 -0
  13. package/dist/core/cimd.js +392 -0
  14. package/dist/core/cimd.js.map +1 -0
  15. package/dist/core/entities/agent-access.d.ts +75 -0
  16. package/dist/core/entities/agent-access.d.ts.map +1 -0
  17. package/dist/core/entities/agent-access.js +3 -0
  18. package/dist/core/entities/agent-access.js.map +1 -0
  19. package/dist/core/errors.d.ts +17 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +32 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/core/oauth-client-resolver.d.ts +54 -0
  24. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  25. package/dist/core/oauth-client-resolver.js +154 -0
  26. package/dist/core/oauth-client-resolver.js.map +1 -0
  27. package/dist/core/oauth-client.d.ts +39 -0
  28. package/dist/core/oauth-client.d.ts.map +1 -0
  29. package/dist/core/oauth-client.js +236 -0
  30. package/dist/core/oauth-client.js.map +1 -0
  31. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  32. package/dist/db/admin-user-moderation.js +30 -26
  33. package/dist/db/admin-user-moderation.js.map +1 -1
  34. package/dist/db/admin-user-summary.d.ts +22 -0
  35. package/dist/db/admin-user-summary.d.ts.map +1 -1
  36. package/dist/db/admin-user-summary.js +75 -1
  37. package/dist/db/admin-user-summary.js.map +1 -1
  38. package/dist/db/admin-users.d.ts +11 -0
  39. package/dist/db/admin-users.d.ts.map +1 -1
  40. package/dist/db/admin-users.js +4 -1
  41. package/dist/db/admin-users.js.map +1 -1
  42. package/dist/db/agent-access-retention.d.ts +10 -0
  43. package/dist/db/agent-access-retention.d.ts.map +1 -0
  44. package/dist/db/agent-access-retention.js +11 -0
  45. package/dist/db/agent-access-retention.js.map +1 -0
  46. package/dist/db/agent-access.d.ts +255 -0
  47. package/dist/db/agent-access.d.ts.map +1 -0
  48. package/dist/db/agent-access.js +705 -0
  49. package/dist/db/agent-access.js.map +1 -0
  50. package/dist/db/db.d.ts +2604 -238
  51. package/dist/db/db.d.ts.map +1 -1
  52. package/dist/db/db.js +7 -0
  53. package/dist/db/db.js.map +1 -1
  54. package/dist/db/schema/agent-access.d.ts +1204 -0
  55. package/dist/db/schema/agent-access.d.ts.map +1 -0
  56. package/dist/db/schema/agent-access.js +267 -0
  57. package/dist/db/schema/agent-access.js.map +1 -0
  58. package/dist/db/schema/users.d.ts.map +1 -1
  59. package/dist/db/schema/users.js +8 -3
  60. package/dist/db/schema/users.js.map +1 -1
  61. package/dist/index.d.ts +11 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +26 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/metrics/instance.d.ts +1 -1
  66. package/dist/metrics/instance.d.ts.map +1 -1
  67. package/dist/metrics/instance.js.map +1 -1
  68. package/dist/presentation/routes/administration.d.ts.map +1 -1
  69. package/dist/presentation/routes/administration.js +123 -20
  70. package/dist/presentation/routes/administration.js.map +1 -1
  71. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  72. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  73. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  74. package/dist/presentation/routes/oauth.d.ts +9 -0
  75. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  76. package/dist/presentation/routes/oauth.js +150 -0
  77. package/dist/presentation/routes/oauth.js.map +1 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  79. package/dist/presentation/routes/rate-limit.js +34 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -1
  81. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  82. package/dist/presentation/routes/registration/signup.js +59 -50
  83. package/dist/presentation/routes/registration/signup.js.map +1 -1
  84. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  85. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  86. package/dist/temporal/activities/agent-access-retention.js +58 -0
  87. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  88. package/dist/temporal/activities/index.d.ts +5 -0
  89. package/dist/temporal/activities/index.d.ts.map +1 -0
  90. package/dist/temporal/activities/index.js +8 -0
  91. package/dist/temporal/activities/index.js.map +1 -0
  92. package/dist/temporal/constants.d.ts +2 -0
  93. package/dist/temporal/constants.d.ts.map +1 -0
  94. package/dist/temporal/constants.js +3 -0
  95. package/dist/temporal/constants.js.map +1 -0
  96. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  97. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  98. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  99. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  100. package/dist/temporal/workflows/index.bundle.js +25227 -0
  101. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  102. package/dist/temporal/workflows/index.d.ts +2 -0
  103. package/dist/temporal/workflows/index.d.ts.map +1 -0
  104. package/dist/temporal/workflows/index.js +3 -0
  105. package/dist/temporal/workflows/index.js.map +1 -0
  106. package/dist/tsconfig.test.tsbuildinfo +1 -1
  107. package/drizzle/0003_brainy_luckman.sql +6 -0
  108. package/drizzle/0004_yummy_runaways.sql +110 -0
  109. package/drizzle/meta/0003_snapshot.json +844 -0
  110. package/drizzle/meta/0004_snapshot.json +1751 -0
  111. package/drizzle/meta/_journal.json +14 -0
  112. package/package.json +11 -8
  113. package/src/core/admin-role.test.ts +70 -0
  114. package/src/core/administration.ts +78 -1
  115. package/src/core/auth.ts +3 -5
  116. package/src/core/cimd.test.ts +213 -0
  117. package/src/core/cimd.ts +532 -0
  118. package/src/core/entities/agent-access.ts +80 -0
  119. package/src/core/errors.ts +48 -0
  120. package/src/core/oauth-client-resolver.test.ts +198 -0
  121. package/src/core/oauth-client-resolver.ts +253 -0
  122. package/src/core/oauth-client.test.ts +157 -0
  123. package/src/core/oauth-client.ts +331 -0
  124. package/src/db/admin-user-moderation.ts +53 -45
  125. package/src/db/admin-user-summary.test.ts +228 -0
  126. package/src/db/admin-user-summary.ts +143 -1
  127. package/src/db/admin-users.ts +21 -1
  128. package/src/db/agent-access-retention.ts +10 -0
  129. package/src/db/agent-access.test.ts +1064 -0
  130. package/src/db/agent-access.ts +1491 -0
  131. package/src/db/db.ts +14 -0
  132. package/src/db/schema/agent-access.ts +273 -0
  133. package/src/db/schema/users.ts +9 -3
  134. package/src/index.test.ts +27 -0
  135. package/src/index.ts +82 -0
  136. package/src/metrics/instance.ts +6 -1
  137. package/src/presentation/routes/administration.test.ts +359 -2
  138. package/src/presentation/routes/administration.ts +142 -20
  139. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  140. package/src/presentation/routes/oauth.test.ts +133 -0
  141. package/src/presentation/routes/oauth.ts +144 -0
  142. package/src/presentation/routes/rate-limit.ts +16 -0
  143. package/src/presentation/routes/registration/signup.ts +52 -41
  144. package/src/temporal/activities/agent-access-retention.ts +76 -0
  145. package/src/temporal/activities/index.ts +5 -0
  146. package/src/temporal/constants.ts +1 -0
  147. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  148. package/src/temporal/workflows/index.ts +1 -0
  149. package/test/globalSetup.ts +1 -1
  150. package/test/routes.ts +3 -1
  151. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,133 @@
1
+ import {createApp, type FastifyInstance} from '@shipfox/node-fastify';
2
+ import {describe, expect, it} from '@shipfox/vitest/vi';
3
+ import {InvalidOAuthConfigurationError} from '#core/errors.js';
4
+ import {createOAuthRoutes} from './oauth.js';
5
+
6
+ let ipCounter = 1;
7
+ const CLIENT_ID_PATTERN = /^client_[0-9a-f-]{36}$/u;
8
+
9
+ function uniqueIp(): string {
10
+ ipCounter += 1;
11
+ return `198.51.100.${ipCounter}`;
12
+ }
13
+
14
+ describe('OAuth route factories', () => {
15
+ let app: FastifyInstance;
16
+
17
+ beforeEach(async () => {
18
+ app = await createApp({
19
+ fastifyOptions: {trustProxy: true},
20
+ routes: [createOAuthRoutes({apiPublicUrl: 'https://api.example.test/'})],
21
+ swagger: false,
22
+ });
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await app.close();
27
+ });
28
+
29
+ it('serves protected-resource and authorization-server metadata', async () => {
30
+ const resource = await app.inject({
31
+ method: 'GET',
32
+ url: '/.well-known/oauth-protected-resource',
33
+ });
34
+ expect(resource.statusCode).toBe(200);
35
+ expect(resource.json()).toEqual({
36
+ resource: 'https://api.example.test/mcp',
37
+ authorization_servers: ['https://api.example.test'],
38
+ scopes_supported: ['read'],
39
+ });
40
+
41
+ const server = await app.inject({
42
+ method: 'GET',
43
+ url: '/.well-known/oauth-authorization-server',
44
+ });
45
+ expect(server.statusCode).toBe(200);
46
+ expect(server.json()).toEqual({
47
+ issuer: 'https://api.example.test',
48
+ authorization_endpoint: 'https://api.example.test/oauth/authorize',
49
+ token_endpoint: 'https://api.example.test/oauth/token',
50
+ registration_endpoint: 'https://api.example.test/oauth/register',
51
+ response_types_supported: ['code'],
52
+ grant_types_supported: ['authorization_code', 'refresh_token'],
53
+ code_challenge_methods_supported: ['S256'],
54
+ token_endpoint_auth_methods_supported: ['none'],
55
+ scopes_supported: ['read'],
56
+ client_id_metadata_document_supported: true,
57
+ });
58
+ });
59
+
60
+ it('creates a public registration with no client secret', async () => {
61
+ const result = await app.inject({
62
+ method: 'POST',
63
+ url: '/oauth/register',
64
+ headers: {'x-forwarded-for': uniqueIp()},
65
+ payload: {
66
+ client_name: 'Desktop agent',
67
+ redirect_uris: ['http://127.0.0.1:43123/callback'],
68
+ },
69
+ });
70
+
71
+ expect(result.statusCode).toBe(201);
72
+ expect(result.json()).toEqual({
73
+ client_id: expect.stringMatching(CLIENT_ID_PATTERN),
74
+ client_name: 'Desktop agent',
75
+ redirect_uris: ['http://127.0.0.1:43123/callback'],
76
+ grant_types: ['authorization_code'],
77
+ response_types: ['code'],
78
+ token_endpoint_auth_method: 'none',
79
+ scope: 'read',
80
+ });
81
+ });
82
+
83
+ it('rejects non-loopback HTTP redirects', async () => {
84
+ const result = await app.inject({
85
+ method: 'POST',
86
+ url: '/oauth/register',
87
+ headers: {'x-forwarded-for': uniqueIp()},
88
+ payload: {
89
+ client_name: 'Desktop agent',
90
+ redirect_uris: ['http://client.example/callback'],
91
+ },
92
+ });
93
+
94
+ expect(result.statusCode).toBe(400);
95
+ });
96
+
97
+ it('rejects an invalid injected public URL before mounting routes', () => {
98
+ expect(() => createOAuthRoutes({apiPublicUrl: 'http://api.example.test'})).toThrow(
99
+ InvalidOAuthConfigurationError,
100
+ );
101
+ });
102
+
103
+ it('returns 429 and Retry-After after the registration IP budget is exhausted', async () => {
104
+ const ip = uniqueIp();
105
+ const payload = {
106
+ client_name: 'Desktop agent',
107
+ redirect_uris: ['https://client.example/callback'],
108
+ };
109
+
110
+ for (let attempt = 0; attempt < 10; attempt += 1) {
111
+ const result = await app.inject({
112
+ method: 'POST',
113
+ url: '/oauth/register',
114
+ headers: {'x-forwarded-for': ip},
115
+ payload,
116
+ });
117
+ expect(result.statusCode).toBe(201);
118
+ }
119
+
120
+ const blocked = await app.inject({
121
+ method: 'POST',
122
+ url: '/oauth/register',
123
+ headers: {'x-forwarded-for': ip},
124
+ payload,
125
+ });
126
+ expect(blocked.statusCode).toBe(429);
127
+ expect(blocked.headers['retry-after']).toEqual(expect.any(String));
128
+ expect(blocked.json()).toEqual({
129
+ code: 'rate-limited',
130
+ details: {retry_after_seconds: expect.any(Number)},
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,144 @@
1
+ import {
2
+ type OAuthDynamicClientRegistrationResponseDto,
3
+ oauthAuthorizationServerMetadataSchema,
4
+ oauthDynamicClientRegistrationRequestSchema,
5
+ oauthDynamicClientRegistrationResponseSchema,
6
+ oauthProtectedResourceMetadataSchema,
7
+ } from '@shipfox/api-auth-dto';
8
+ import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
9
+ import {
10
+ InvalidOAuthClientMetadataError,
11
+ InvalidOAuthConfigurationError,
12
+ OAuthMetadataFetchError,
13
+ OAuthRedirectUriNotRegisteredError,
14
+ } from '#core/errors.js';
15
+ import {validateOAuthPublicOrigin} from '#core/oauth-client.js';
16
+ import {registerOAuthClient} from '#core/oauth-client-resolver.js';
17
+ import {createAuthIpRateLimitPreHandler} from './rate-limit.js';
18
+
19
+ export interface CreateOAuthRoutesOptions {
20
+ /** Validated here and deliberately injected until production composition. */
21
+ apiPublicUrl: string;
22
+ }
23
+
24
+ function apiPublicOrigin(options: CreateOAuthRoutesOptions): string {
25
+ return validateOAuthPublicOrigin(options.apiPublicUrl);
26
+ }
27
+
28
+ function translateOAuthError(error: unknown): never {
29
+ if (
30
+ error instanceof InvalidOAuthClientMetadataError ||
31
+ error instanceof OAuthMetadataFetchError ||
32
+ error instanceof OAuthRedirectUriNotRegisteredError
33
+ ) {
34
+ throw new ClientError('OAuth client metadata is invalid', 'invalid-client-metadata', {
35
+ status: 400,
36
+ });
37
+ }
38
+ if (error instanceof InvalidOAuthConfigurationError) {
39
+ throw new ClientError('OAuth configuration is invalid', 'oauth-configuration-invalid', {
40
+ status: 500,
41
+ });
42
+ }
43
+ throw error;
44
+ }
45
+
46
+ function registrationResponse(
47
+ result: Awaited<ReturnType<typeof registerOAuthClient>>,
48
+ ): OAuthDynamicClientRegistrationResponseDto {
49
+ return {
50
+ client_id: result.metadata.clientId,
51
+ client_name: result.metadata.clientName,
52
+ redirect_uris: result.metadata.redirectUris,
53
+ grant_types: result.metadata.grantTypes,
54
+ response_types: result.metadata.responseTypes,
55
+ token_endpoint_auth_method: result.metadata.tokenEndpointAuthMethod,
56
+ scope: result.metadata.scope,
57
+ };
58
+ }
59
+
60
+ function createProtectedResourceMetadataRoute(origin: string) {
61
+ return defineRoute({
62
+ method: 'GET',
63
+ path: '/.well-known/oauth-protected-resource',
64
+ description: 'Describe the protected MCP resource and its authorization server.',
65
+ schema: {response: {200: oauthProtectedResourceMetadataSchema}},
66
+ handler: () => ({
67
+ resource: `${origin}/mcp`,
68
+ authorization_servers: [origin],
69
+ scopes_supported: ['read'],
70
+ }),
71
+ });
72
+ }
73
+
74
+ function createAuthorizationServerMetadataRoute(origin: string) {
75
+ return defineRoute({
76
+ method: 'GET',
77
+ path: '/.well-known/oauth-authorization-server',
78
+ description: 'Describe the MCP OAuth authorization server.',
79
+ schema: {response: {200: oauthAuthorizationServerMetadataSchema}},
80
+ handler: () => ({
81
+ issuer: origin,
82
+ authorization_endpoint: `${origin}/oauth/authorize`,
83
+ token_endpoint: `${origin}/oauth/token`,
84
+ registration_endpoint: `${origin}/oauth/register`,
85
+ response_types_supported: ['code'],
86
+ grant_types_supported: ['authorization_code', 'refresh_token'],
87
+ code_challenge_methods_supported: ['S256'],
88
+ token_endpoint_auth_methods_supported: ['none'],
89
+ scopes_supported: ['read'],
90
+ client_id_metadata_document_supported: true,
91
+ }),
92
+ });
93
+ }
94
+
95
+ function createDynamicRegistrationRoute() {
96
+ return defineRoute({
97
+ method: 'POST',
98
+ path: '/oauth/register',
99
+ description: 'Register a public OAuth client for MCP access.',
100
+ options: {bodyLimit: 32 * 1024},
101
+ schema: {
102
+ body: oauthDynamicClientRegistrationRequestSchema,
103
+ response: {201: oauthDynamicClientRegistrationResponseSchema},
104
+ },
105
+ preHandler: createAuthIpRateLimitPreHandler('oauth-register'),
106
+ errorHandler: translateOAuthError,
107
+ handler: async (request, reply) => {
108
+ const result = await registerOAuthClient({
109
+ clientName: request.body.client_name,
110
+ redirectUris: request.body.redirect_uris,
111
+ grantTypes: request.body.grant_types,
112
+ responseTypes: request.body.response_types,
113
+ tokenEndpointAuthMethod: request.body.token_endpoint_auth_method,
114
+ scope: request.body.scope,
115
+ });
116
+ reply.code(201);
117
+ return registrationResponse(result);
118
+ },
119
+ });
120
+ }
121
+
122
+ export function createOAuthMetadataRoutes(options: CreateOAuthRoutesOptions): RouteGroup {
123
+ const origin = apiPublicOrigin(options);
124
+ return {
125
+ prefix: '',
126
+ routes: [
127
+ createProtectedResourceMetadataRoute(origin),
128
+ createAuthorizationServerMetadataRoute(origin),
129
+ ],
130
+ };
131
+ }
132
+
133
+ export function createOAuthClientIdentificationRoutes(): RouteGroup {
134
+ return {prefix: '', routes: [createDynamicRegistrationRoute()]};
135
+ }
136
+
137
+ export function createOAuthRoutes(options: CreateOAuthRoutesOptions): RouteGroup {
138
+ const metadataRoutes = createOAuthMetadataRoutes(options);
139
+ const clientIdentificationRoutes = createOAuthClientIdentificationRoutes();
140
+ return {
141
+ prefix: '',
142
+ routes: [...metadataRoutes.routes, ...clientIdentificationRoutes.routes],
143
+ };
144
+ }
@@ -29,6 +29,10 @@ const policies: Record<
29
29
  lookup: {
30
30
  ip: {limit: 60, windowSeconds: 5 * 60},
31
31
  },
32
+ directory: {
33
+ ip: {limit: 60, windowSeconds: 5 * 60},
34
+ actor: {limit: 60, windowSeconds: 5 * 60},
35
+ },
32
36
  impersonate: {
33
37
  // Bounds mint and probing attempts: the IP bucket bounds aggregate source
34
38
  // traffic, and the actor bucket is a per-actor cap that prevents an actor
@@ -39,6 +43,18 @@ const policies: Record<
39
43
  ip: {limit: 20, windowSeconds: 15 * 60},
40
44
  actor: {limit: 20, windowSeconds: 15 * 60},
41
45
  },
46
+ 'oauth-register': {
47
+ ip: {limit: 10, windowSeconds: 60 * 60},
48
+ },
49
+ 'oauth-cimd': {
50
+ ip: {limit: 30, windowSeconds: 60 * 60},
51
+ },
52
+ 'oauth-authorize': {
53
+ ip: {limit: 30, windowSeconds: 5 * 60},
54
+ },
55
+ 'oauth-token': {
56
+ ip: {limit: 60, windowSeconds: 5 * 60},
57
+ },
42
58
  };
43
59
 
44
60
  interface EmailBody {
@@ -15,6 +15,57 @@ import {SIGNUP_DENIAL_MESSAGE_MAX_LENGTH, type SignupPolicy} from '#core/ports.j
15
15
  import {setRefreshTokenCookie} from '#presentation/auth/refresh-cookie.js';
16
16
  import {toUserDto} from '#presentation/dto/user.js';
17
17
 
18
+ function handleEmailChallengeError(error: unknown): void {
19
+ if (!(error instanceof EmailChallengeError)) return;
20
+ throw new ClientError(error.message, `email-challenge-${error.code}`, {
21
+ status: error.code === 'limited' ? 429 : 400,
22
+ ...(error.retryAt ? {details: {retry_at: error.retryAt.toISOString()}} : {}),
23
+ });
24
+ }
25
+
26
+ function handleSignupPolicyError(error: unknown): void {
27
+ if (error instanceof EmailTakenError) {
28
+ throw new ClientError('Email already registered', 'email-taken', {status: 409});
29
+ }
30
+ if (error instanceof SignupNotAllowedError) {
31
+ const message = error.message.slice(0, SIGNUP_DENIAL_MESSAGE_MAX_LENGTH);
32
+ throw new ClientError(message, 'signup-not-allowed', {
33
+ status: 403,
34
+ details: {message, ...(error.format ? {format: error.format} : {})},
35
+ });
36
+ }
37
+ }
38
+
39
+ function handleInvitationError(error: unknown): void {
40
+ if (error instanceof TokenInvalidError) {
41
+ throw new ClientError('Invitation token is invalid', 'invitation-token-invalid', {status: 410});
42
+ }
43
+ if (error instanceof TokenAlreadyUsedError) {
44
+ throw new ClientError('Invitation has already been accepted', 'invitation-token-used', {
45
+ status: 410,
46
+ });
47
+ }
48
+ if (error instanceof TokenExpiredError) {
49
+ throw new ClientError('Invitation has expired', 'invitation-token-expired', {status: 410});
50
+ }
51
+ if (error instanceof InvitationEmailMismatchError) {
52
+ throw new ClientError(
53
+ 'Signup email does not match the invitation',
54
+ 'invitation-email-mismatch',
55
+ {
56
+ status: 403,
57
+ },
58
+ );
59
+ }
60
+ }
61
+
62
+ function handleSignupError(error: unknown): never {
63
+ handleEmailChallengeError(error);
64
+ handleSignupPolicyError(error);
65
+ handleInvitationError(error);
66
+ throw error;
67
+ }
68
+
18
69
  export function createSignupRoute(
19
70
  workspaces: WorkspacesInterModuleClient,
20
71
  signupPolicy?: SignupPolicy,
@@ -30,47 +81,7 @@ export function createSignupRoute(
30
81
  201: signupResponseSchema,
31
82
  },
32
83
  },
33
- errorHandler: (error) => {
34
- if (error instanceof EmailChallengeError) {
35
- throw new ClientError(error.message, `email-challenge-${error.code}`, {
36
- status: error.code === 'limited' ? 429 : 400,
37
- ...(error.retryAt ? {details: {retry_at: error.retryAt.toISOString()}} : {}),
38
- });
39
- }
40
- if (error instanceof EmailTakenError) {
41
- throw new ClientError('Email already registered', 'email-taken', {status: 409});
42
- }
43
- if (error instanceof SignupNotAllowedError) {
44
- const message = error.message.slice(0, SIGNUP_DENIAL_MESSAGE_MAX_LENGTH);
45
- throw new ClientError(message, 'signup-not-allowed', {
46
- status: 403,
47
- details: {message, ...(error.format ? {format: error.format} : {})},
48
- });
49
- }
50
- if (error instanceof TokenInvalidError) {
51
- throw new ClientError('Invitation token is invalid', 'invitation-token-invalid', {
52
- status: 410,
53
- });
54
- }
55
- if (error instanceof TokenAlreadyUsedError) {
56
- throw new ClientError('Invitation has already been accepted', 'invitation-token-used', {
57
- status: 410,
58
- });
59
- }
60
- if (error instanceof TokenExpiredError) {
61
- throw new ClientError('Invitation has expired', 'invitation-token-expired', {
62
- status: 410,
63
- });
64
- }
65
- if (error instanceof InvitationEmailMismatchError) {
66
- throw new ClientError(
67
- 'Signup email does not match the invitation',
68
- 'invitation-email-mismatch',
69
- {status: 403},
70
- );
71
- }
72
- throw error;
73
- },
84
+ errorHandler: handleSignupError,
74
85
  handler: async (request, reply) => {
75
86
  const {email, password, name, invitation_token} = request.body;
76
87
 
@@ -0,0 +1,76 @@
1
+ import {Context} from '@temporalio/activity';
2
+ import {pruneAgentAccessBatch} from '#db/agent-access.js';
3
+ import {
4
+ AGENT_ACCESS_RETENTION_BATCH_LIMIT,
5
+ AGENT_ACCESS_RETENTION_MAX_ITERATIONS,
6
+ AGENT_ACCESS_RETENTION_TIME_BUDGET_MS,
7
+ } from '#db/agent-access-retention.js';
8
+
9
+ export interface AgentAccessRetentionResult {
10
+ deleted: number;
11
+ transitioned: number;
12
+ iterations: number;
13
+ timedOut: boolean;
14
+ }
15
+
16
+ function isAgentAccessRetentionTimeout(error: unknown): boolean {
17
+ if (typeof error !== 'object' || error === null) return false;
18
+ const details = error as {cause?: unknown; code?: unknown};
19
+ if (details.code === '55P03' || details.code === '57014') return true;
20
+ return details.cause !== undefined && details.cause !== error
21
+ ? isAgentAccessRetentionTimeout(details.cause)
22
+ : false;
23
+ }
24
+
25
+ /**
26
+ * Drains bounded retention batches for the current tick. Heartbeats continue
27
+ * while a database batch waits on a lock, and the wall-clock budget keeps the
28
+ * activity inside its five-minute Temporal start-to-close timeout.
29
+ */
30
+ export async function agentAccessRetentionActivity(): Promise<AgentAccessRetentionResult> {
31
+ const ctx = Context.current();
32
+ const startedAt = Date.now();
33
+ const deadline = startedAt + AGENT_ACCESS_RETENTION_TIME_BUDGET_MS;
34
+ const result: AgentAccessRetentionResult = {
35
+ deleted: 0,
36
+ transitioned: 0,
37
+ iterations: 0,
38
+ timedOut: false,
39
+ };
40
+ const heartbeat = () => ctx.heartbeat(result);
41
+ const heartbeatTimer = setInterval(heartbeat, 15_000);
42
+
43
+ try {
44
+ while (result.iterations < AGENT_ACCESS_RETENTION_MAX_ITERATIONS) {
45
+ if (Date.now() >= deadline) {
46
+ result.timedOut = true;
47
+ break;
48
+ }
49
+
50
+ try {
51
+ const batch = await pruneAgentAccessBatch({
52
+ limit: AGENT_ACCESS_RETENTION_BATCH_LIMIT,
53
+ deadlineMs: deadline,
54
+ });
55
+ result.deleted += batch.deleted;
56
+ result.transitioned += batch.transitioned;
57
+ result.iterations += 1;
58
+ heartbeat();
59
+
60
+ if (batch.deleted === 0 && batch.transitioned === 0) break;
61
+ } catch (error) {
62
+ if (!isAgentAccessRetentionTimeout(error)) throw error;
63
+ result.timedOut = true;
64
+ break;
65
+ }
66
+ }
67
+
68
+ if (result.iterations >= AGENT_ACCESS_RETENTION_MAX_ITERATIONS) {
69
+ result.timedOut = true;
70
+ }
71
+ heartbeat();
72
+ return result;
73
+ } finally {
74
+ clearInterval(heartbeatTimer);
75
+ }
76
+ }
@@ -0,0 +1,5 @@
1
+ import {agentAccessRetentionActivity} from './agent-access-retention.js';
2
+
3
+ export function createAuthMaintenanceActivities() {
4
+ return {agentAccessRetentionActivity};
5
+ }
@@ -0,0 +1 @@
1
+ export const AUTH_AGENT_ACCESS_MAINTENANCE_TASK_QUEUE = 'auth-agent-access-maintenance';
@@ -0,0 +1,23 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createAuthMaintenanceActivities} from '../activities/index.js';
3
+
4
+ const {agentAccessRetentionActivity} = proxyActivities<
5
+ ReturnType<typeof createAuthMaintenanceActivities>
6
+ >({
7
+ startToCloseTimeout: '5 minutes',
8
+ heartbeatTimeout: '1 minute',
9
+ // Retry on the next cron tick so a timed-out attempt cannot overlap its own sweep.
10
+ retry: {maximumAttempts: 1},
11
+ });
12
+
13
+ export async function agentAccessRetentionCron(): Promise<void> {
14
+ const result = await agentAccessRetentionActivity();
15
+ if (result.deleted > 0 || result.transitioned > 0 || result.timedOut) {
16
+ log.info('Agent-access retention sweep complete', {
17
+ deleted: result.deleted,
18
+ transitioned: result.transitioned,
19
+ iterations: result.iterations,
20
+ timedOut: result.timedOut,
21
+ });
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ export {agentAccessRetentionCron} from './agent-access-retention-cron.js';
@@ -16,7 +16,7 @@ export async function setup() {
16
16
  await initializeEmailChallengesForTests();
17
17
  await resetEmailChallengesForTests();
18
18
  await db().execute(
19
- sql`TRUNCATE auth_admin_command_results, auth_admin_grants, auth_outbox, auth_password_resets, auth_rate_limits, auth_refresh_tokens, auth_users CASCADE`,
19
+ sql`TRUNCATE auth_agent_pats, auth_agent_refresh_tokens, auth_agent_authorization_codes, auth_agent_grants, auth_agent_authorization_requests, auth_agent_clients, auth_admin_command_results, auth_admin_grants, auth_outbox, auth_password_resets, auth_rate_limits, auth_refresh_tokens, auth_users CASCADE`,
20
20
  );
21
21
 
22
22
  closeDb();
package/test/routes.ts CHANGED
@@ -151,7 +151,9 @@ export function extractToken(url: string): string {
151
151
 
152
152
  export function getSetCookie(res: {headers: OutgoingHttpHeaders}): string {
153
153
  const header = res.headers['set-cookie'];
154
- const value = Array.isArray(header) ? header[0] : typeof header === 'string' ? header : undefined;
154
+ let value: string | undefined;
155
+ if (Array.isArray(header)) value = header[0];
156
+ else if (typeof header === 'string') value = header;
155
157
  expect(value).toBeDefined();
156
158
  return value ?? '';
157
159
  }