fluxmail 0.4.1 → 0.5.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 (108) hide show
  1. package/README.md +6 -4
  2. package/dist/accounts/defaultGoogleOAuth.d.ts +4 -0
  3. package/dist/accounts/defaultGoogleOAuth.d.ts.map +1 -0
  4. package/dist/accounts/defaultGoogleOAuth.js +7 -0
  5. package/dist/accounts/defaultGoogleOAuth.js.map +1 -0
  6. package/dist/accounts/gmailConnection.js +2 -2
  7. package/dist/accounts/gmailConnection.js.map +1 -1
  8. package/dist/accounts/googleAuth.d.ts +10 -7
  9. package/dist/accounts/googleAuth.d.ts.map +1 -1
  10. package/dist/accounts/googleAuth.js +48 -17
  11. package/dist/accounts/googleAuth.js.map +1 -1
  12. package/dist/accounts/microsoftAuth.js +1 -1
  13. package/dist/accounts/microsoftAuth.js.map +1 -1
  14. package/dist/accounts/registry.d.ts +9 -8
  15. package/dist/accounts/registry.d.ts.map +1 -1
  16. package/dist/accounts/registry.js +166 -96
  17. package/dist/accounts/registry.js.map +1 -1
  18. package/dist/auth.d.ts +106 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +452 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/authorization.d.ts +9 -0
  23. package/dist/authorization.d.ts.map +1 -0
  24. package/dist/authorization.js +23 -0
  25. package/dist/authorization.js.map +1 -0
  26. package/dist/cli.d.ts +7 -1
  27. package/dist/cli.d.ts.map +1 -1
  28. package/dist/cli.js +734 -358
  29. package/dist/cli.js.map +1 -1
  30. package/dist/cliInstances.d.ts +37 -0
  31. package/dist/cliInstances.d.ts.map +1 -0
  32. package/dist/cliInstances.js +170 -0
  33. package/dist/cliInstances.js.map +1 -0
  34. package/dist/config.d.ts +16 -10
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +125 -35
  37. package/dist/config.js.map +1 -1
  38. package/dist/context.d.ts.map +1 -1
  39. package/dist/context.js +8 -4
  40. package/dist/context.js.map +1 -1
  41. package/dist/http/admin.d.ts +57 -3
  42. package/dist/http/admin.d.ts.map +1 -1
  43. package/dist/http/admin.js +188 -67
  44. package/dist/http/admin.js.map +1 -1
  45. package/dist/http/app.d.ts.map +1 -1
  46. package/dist/http/app.js +78 -54
  47. package/dist/http/app.js.map +1 -1
  48. package/dist/http/identity.d.ts +114 -0
  49. package/dist/http/identity.d.ts.map +1 -0
  50. package/dist/http/identity.js +931 -0
  51. package/dist/http/identity.js.map +1 -0
  52. package/dist/http/operationRoutes.d.ts +8 -0
  53. package/dist/http/operationRoutes.d.ts.map +1 -0
  54. package/dist/http/operationRoutes.js +13 -0
  55. package/dist/http/operationRoutes.js.map +1 -0
  56. package/dist/http/rest.d.ts +11 -3
  57. package/dist/http/rest.d.ts.map +1 -1
  58. package/dist/http/rest.js +138 -66
  59. package/dist/http/rest.js.map +1 -1
  60. package/dist/index.d.ts +4 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4 -3
  63. package/dist/index.js.map +1 -1
  64. package/dist/mcp/buildServer.d.ts +1 -1
  65. package/dist/mcp/buildServer.d.ts.map +1 -1
  66. package/dist/mcp/buildServer.js +14 -19
  67. package/dist/mcp/buildServer.js.map +1 -1
  68. package/dist/permissions.d.ts +1 -1
  69. package/dist/permissions.d.ts.map +1 -1
  70. package/dist/permissions.js +7 -1
  71. package/dist/permissions.js.map +1 -1
  72. package/dist/scheduler/sendScheduler.d.ts +1 -2
  73. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  74. package/dist/scheduler/sendScheduler.js +1 -2
  75. package/dist/scheduler/sendScheduler.js.map +1 -1
  76. package/dist/service/emailService.d.ts +8 -16
  77. package/dist/service/emailService.d.ts.map +1 -1
  78. package/dist/service/emailService.js +25 -38
  79. package/dist/service/emailService.js.map +1 -1
  80. package/dist/storage/adminAudit.d.ts +15 -2
  81. package/dist/storage/adminAudit.d.ts.map +1 -1
  82. package/dist/storage/adminAudit.js +22 -27
  83. package/dist/storage/adminAudit.js.map +1 -1
  84. package/dist/storage/apiKeys.d.ts +8 -8
  85. package/dist/storage/apiKeys.d.ts.map +1 -1
  86. package/dist/storage/apiKeys.js +16 -11
  87. package/dist/storage/apiKeys.js.map +1 -1
  88. package/dist/storage/db.d.ts +593 -31
  89. package/dist/storage/db.d.ts.map +1 -1
  90. package/dist/storage/db.js +330 -45
  91. package/dist/storage/db.js.map +1 -1
  92. package/dist/storage/fileLock.d.ts +7 -0
  93. package/dist/storage/fileLock.d.ts.map +1 -0
  94. package/dist/storage/fileLock.js +183 -0
  95. package/dist/storage/fileLock.js.map +1 -0
  96. package/dist/storage/gmailConnectionGrants.d.ts +3 -4
  97. package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
  98. package/dist/storage/gmailConnectionGrants.js +6 -6
  99. package/dist/storage/gmailConnectionGrants.js.map +1 -1
  100. package/dist/storage/members.d.ts +11 -0
  101. package/dist/storage/members.d.ts.map +1 -1
  102. package/dist/storage/members.js +70 -6
  103. package/dist/storage/members.js.map +1 -1
  104. package/dist/telemetry.d.ts +14 -0
  105. package/dist/telemetry.d.ts.map +1 -1
  106. package/dist/telemetry.js +22 -0
  107. package/dist/telemetry.js.map +1 -1
  108. package/package.json +6 -5
@@ -0,0 +1,931 @@
1
+ import { createRoute, z } from '@hono/zod-openapi';
2
+ import { EmailError, isEmailError } from '@fluxmail/core';
3
+ import { changePassword, enrollMember, issueMemberAuthToken, listMemberSessions, loginWithPassword, resetPassword, revokeSession, } from '../auth.js';
4
+ import { canAccessAccount, canAdminister, canManageOwnedAccount, canSeeAccountMetadata } from '../authorization.js';
5
+ import { ADMIN_CAPABILITIES, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, customPermissionPolicy, permissionPolicyForProfile, } from '../permissions.js';
6
+ import { createApiKey, listApiKeys, revokeApiKey } from '../storage/apiKeys.js';
7
+ import { listAdminAuditEvents, recordAdminAuditEvent } from '../storage/adminAudit.js';
8
+ import { memberCredentials } from '../storage/db.js';
9
+ import { addMember, findMember, listMembers, removeMember, updateMember } from '../storage/members.js';
10
+ import { eq } from 'drizzle-orm';
11
+ import { prepareHostedGmailConnection, prepareHostedOutlookConnection } from '../accounts/gmailConnection.js';
12
+ import { requestClientAddress } from './admin.js';
13
+ import { operationIdForRequest } from './operationRoutes.js';
14
+ export const identityOperationRoutes = [
15
+ { method: 'post', path: '/api/v1/auth/login', operationId: 'login' },
16
+ { method: 'post', path: '/api/v1/auth/enroll', operationId: 'enrollMember' },
17
+ { method: 'post', path: '/api/v1/auth/password-reset', operationId: 'resetPassword' },
18
+ { method: 'post', path: '/api/v1/auth/logout', operationId: 'logout' },
19
+ { method: 'get', path: '/api/v1/me', operationId: 'getCurrentMember' },
20
+ { method: 'patch', path: '/api/v1/me', operationId: 'updateCurrentMember' },
21
+ { method: 'put', path: '/api/v1/me/password', operationId: 'changePassword' },
22
+ { method: 'get', path: '/api/v1/me/sessions', operationId: 'listSessions' },
23
+ { method: 'delete', path: '/api/v1/me/sessions/{sessionId}', operationId: 'revokeSession' },
24
+ { method: 'get', path: '/api/v1/me/api-keys', operationId: 'listOwnApiKeys' },
25
+ { method: 'post', path: '/api/v1/me/api-keys', operationId: 'createOwnApiKey' },
26
+ { method: 'delete', path: '/api/v1/me/api-keys/{keyId}', operationId: 'revokeOwnApiKey' },
27
+ { method: 'post', path: '/api/v1/accounts/connections', operationId: 'connectOwnAccount' },
28
+ { method: 'delete', path: '/api/v1/accounts/{accountId}/connection', operationId: 'removeOwnAccount' },
29
+ { method: 'patch', path: '/api/v1/accounts/{accountId}/imap/folders', operationId: 'updateOwnedImapFolders' },
30
+ { method: 'get', path: '/api/v1/admin/members', operationId: 'listMembers' },
31
+ { method: 'post', path: '/api/v1/admin/members', operationId: 'createMember' },
32
+ { method: 'patch', path: '/api/v1/admin/members/{memberId}', operationId: 'updateMember' },
33
+ { method: 'delete', path: '/api/v1/admin/members/{memberId}', operationId: 'deleteMember' },
34
+ { method: 'post', path: '/api/v1/admin/members/{memberId}/invitation', operationId: 'inviteMember' },
35
+ {
36
+ method: 'post',
37
+ path: '/api/v1/admin/members/{memberId}/password-reset',
38
+ operationId: 'createMemberPasswordReset',
39
+ },
40
+ { method: 'get', path: '/api/v1/admin/members/{memberId}/sessions', operationId: 'listMemberSessions' },
41
+ {
42
+ method: 'delete',
43
+ path: '/api/v1/admin/members/{memberId}/sessions/{sessionId}',
44
+ operationId: 'revokeMemberSession',
45
+ },
46
+ { method: 'get', path: '/api/v1/admin/accounts', operationId: 'listAdminAccounts' },
47
+ { method: 'patch', path: '/api/v1/admin/accounts/{accountId}', operationId: 'updateAccountAccess' },
48
+ { method: 'delete', path: '/api/v1/admin/accounts/{accountId}', operationId: 'deleteAdminAccount' },
49
+ { method: 'get', path: '/api/v1/admin/audit-events', operationId: 'listAuditEvents' },
50
+ ];
51
+ export function identityOperationId(method, path) {
52
+ return operationIdForRequest(identityOperationRoutes, method, path);
53
+ }
54
+ const protectedRoute = { security: [{ bearerAuth: [] }] };
55
+ const sessionRoute = { security: [{ memberSessionAuth: [] }] };
56
+ const genericData = z.object({ data: z.unknown() }).passthrough();
57
+ const genericError = z.object({ error: z.object({ code: z.string(), message: z.string() }) });
58
+ const responses = {
59
+ 200: { content: { 'application/json': { schema: genericData } }, description: 'Success' },
60
+ 400: { content: { 'application/json': { schema: genericError } }, description: 'Invalid request' },
61
+ 401: { content: { 'application/json': { schema: genericError } }, description: 'Authentication required' },
62
+ 403: { content: { 'application/json': { schema: genericError } }, description: 'Permission denied' },
63
+ 404: { content: { 'application/json': { schema: genericError } }, description: 'Not found' },
64
+ 409: { content: { 'application/json': { schema: genericError } }, description: 'Conflict' },
65
+ 413: { content: { 'application/json': { schema: genericError } }, description: 'Request body too large' },
66
+ 429: { content: { 'application/json': { schema: genericError } }, description: 'Too many attempts' },
67
+ };
68
+ const providerResponses = {
69
+ 422: { content: { 'application/json': { schema: genericError } }, description: 'Unsupported capability' },
70
+ 503: { content: { 'application/json': { schema: genericError } }, description: 'Provider unavailable' },
71
+ };
72
+ const loginBody = z
73
+ .object({ email: z.string().email(), password: z.string(), deviceName: z.string().trim().min(1).max(200) })
74
+ .strict();
75
+ const tokenPasswordBody = z
76
+ .object({ token: z.string().trim().min(1), password: z.string(), deviceName: z.string().trim().min(1).max(200) })
77
+ .strict();
78
+ const memberParams = z.object({ memberId: z.string().trim().min(1) });
79
+ const sessionParams = z.object({ sessionId: z.string().trim().min(1) });
80
+ const memberSessionParams = z.object({ memberId: z.string().trim().min(1), sessionId: z.string().trim().min(1) });
81
+ const keyParams = z.object({ keyId: z.string().trim().min(1) });
82
+ const accountParams = z.object({ accountId: z.string().trim().min(1) });
83
+ const CapabilitySchema = z.enum([...MCP_CAPABILITIES, ...ADMIN_CAPABILITIES]);
84
+ const AdminCapabilitySchema = z.enum(ADMIN_CAPABILITIES);
85
+ const ownApiKeyBody = z
86
+ .object({
87
+ name: z.string().trim().min(1).max(200),
88
+ permissionProfile: z.enum(NAMED_PERMISSION_PROFILES).optional(),
89
+ capabilities: z
90
+ .array(CapabilitySchema)
91
+ .min(1)
92
+ .max(MCP_CAPABILITIES.length + ADMIN_CAPABILITIES.length)
93
+ .optional(),
94
+ supplementalCapabilities: z.array(AdminCapabilitySchema).max(ADMIN_CAPABILITIES.length).optional(),
95
+ accountIds: z.array(z.string()).nullable().optional(),
96
+ })
97
+ .strict()
98
+ .superRefine((value, ctx) => {
99
+ if (value.capabilities && (value.permissionProfile || value.supplementalCapabilities)) {
100
+ ctx.addIssue({
101
+ code: z.ZodIssueCode.custom,
102
+ message: 'capabilities cannot be combined with a named profile.',
103
+ });
104
+ }
105
+ });
106
+ const folderRoles = ['sent', 'drafts', 'trash', 'archive', 'spam'];
107
+ const mailServerSettings = z
108
+ .object({
109
+ host: z.string().trim().min(1).max(255),
110
+ port: z.number().int().min(1).max(65_535),
111
+ security: z.enum(['tls', 'starttls']),
112
+ user: z.string().min(1).max(320),
113
+ password: z.string().min(1).max(4096),
114
+ })
115
+ .strict();
116
+ const folderOverrides = z
117
+ .object({
118
+ sent: z.string().min(1).max(1024).optional(),
119
+ drafts: z.string().min(1).max(1024).optional(),
120
+ trash: z.string().min(1).max(1024).optional(),
121
+ archive: z.string().min(1).max(1024).optional(),
122
+ spam: z.string().min(1).max(1024).optional(),
123
+ })
124
+ .strict();
125
+ const folderPatch = z
126
+ .object({
127
+ sent: z.string().min(1).max(1024).nullable().optional(),
128
+ drafts: z.string().min(1).max(1024).nullable().optional(),
129
+ trash: z.string().min(1).max(1024).nullable().optional(),
130
+ archive: z.string().min(1).max(1024).nullable().optional(),
131
+ spam: z.string().min(1).max(1024).nullable().optional(),
132
+ })
133
+ .strict();
134
+ function jsonError(c, error) {
135
+ if (isEmailError(error)) {
136
+ const statuses = {
137
+ invalid_request: 400,
138
+ permission_denied: 403,
139
+ entitlement_exceeded: 403,
140
+ not_found: 404,
141
+ auth_expired: 409,
142
+ unsupported_capability: 422,
143
+ rate_limited: 429,
144
+ provider_unavailable: 503,
145
+ };
146
+ return c.json({ error: { code: error.code, message: error.message } }, statuses[error.code] ?? 500);
147
+ }
148
+ return c.json({ error: { code: 'internal', message: 'The request could not be completed.' } }, 500);
149
+ }
150
+ function requireSession(principal) {
151
+ if (principal.kind !== 'session') {
152
+ throw new EmailError('permission_denied', 'This operation requires an interactive member session.');
153
+ }
154
+ return principal;
155
+ }
156
+ function requireAdministrator(principal, capability) {
157
+ if (!canAdminister(principal, capability)) {
158
+ throw new EmailError('permission_denied', 'Administrator access is required.');
159
+ }
160
+ }
161
+ function memberData(member) {
162
+ return {
163
+ id: member.id,
164
+ name: member.name,
165
+ email: member.email,
166
+ role: member.role,
167
+ status: member.status,
168
+ accountCount: member.accountCount,
169
+ apiKeyCount: member.apiKeyCount,
170
+ createdAt: member.createdAt,
171
+ };
172
+ }
173
+ function audit(deps, principal, operation, outcome, target, errorCode) {
174
+ try {
175
+ recordAdminAuditEvent(deps.db, {
176
+ operation,
177
+ outcome,
178
+ actorMemberId: principal?.memberId ?? (target?.type === 'member' ? target.id : null),
179
+ actorKeyId: principal?.kind === 'api_key' ? principal.keyId : undefined,
180
+ actorSessionId: principal?.kind === 'session' ? principal.sessionId : undefined,
181
+ ...(target ? { resourceType: target.type, resourceId: target.id } : {}),
182
+ ...(errorCode ? { errorCode } : {}),
183
+ });
184
+ }
185
+ catch {
186
+ // Audit storage must not replace the primary response.
187
+ }
188
+ }
189
+ function authResult(result) {
190
+ return {
191
+ token: result.session.token,
192
+ member: memberData(result.member),
193
+ session: result.session.info,
194
+ };
195
+ }
196
+ export function registerIdentityRoutes(typedApp, deps) {
197
+ const app = typedApp;
198
+ const loginRoute = createRoute({
199
+ method: 'post',
200
+ path: '/api/v1/auth/login',
201
+ operationId: 'login',
202
+ summary: 'Log in with a member email and password',
203
+ request: { body: { required: true, content: { 'application/json': { schema: loginBody } } } },
204
+ responses,
205
+ });
206
+ app.openapi(loginRoute, async (c) => {
207
+ try {
208
+ const result = await loginWithPassword(deps.db, {
209
+ ...c.req.valid('json'),
210
+ ipAddress: requestClientAddress(c.req.raw, { remoteAddress: c.env?.incoming?.socket.remoteAddress }, deps.config.trustProxy),
211
+ });
212
+ audit(deps, undefined, 'auth.login', 'success', { type: 'member', id: result.member.id });
213
+ return c.json({ data: authResult(result) }, 200);
214
+ }
215
+ catch (error) {
216
+ audit(deps, undefined, 'auth.login', 'error', undefined, isEmailError(error) ? error.code : 'internal');
217
+ return jsonError(c, error);
218
+ }
219
+ });
220
+ for (const [path, operationId, kind] of [
221
+ ['/api/v1/auth/enroll', 'enrollMember', 'enrollment'],
222
+ ['/api/v1/auth/password-reset', 'resetPassword', 'password_reset'],
223
+ ]) {
224
+ const route = createRoute({
225
+ method: 'post',
226
+ path,
227
+ operationId,
228
+ summary: kind === 'enrollment' ? 'Enroll a member' : 'Redeem a password reset',
229
+ request: { body: { required: true, content: { 'application/json': { schema: tokenPasswordBody } } } },
230
+ responses,
231
+ });
232
+ app.openapi(route, async (c) => {
233
+ try {
234
+ const input = c.req.valid('json');
235
+ const result = kind === 'enrollment' ? await enrollMember(deps.db, input) : await resetPassword(deps.db, input);
236
+ audit(deps, undefined, `auth.${kind}`, 'success', { type: 'member', id: result.member.id });
237
+ return c.json({ data: authResult(result) }, 200);
238
+ }
239
+ catch (error) {
240
+ audit(deps, undefined, `auth.${kind}`, 'error', undefined, isEmailError(error) ? error.code : 'internal');
241
+ return jsonError(c, error);
242
+ }
243
+ });
244
+ }
245
+ const meRoute = createRoute({
246
+ method: 'get',
247
+ path: '/api/v1/me',
248
+ operationId: 'getCurrentMember',
249
+ summary: 'Get the authenticated member',
250
+ ...protectedRoute,
251
+ responses,
252
+ });
253
+ app.openapi(meRoute, (c) => {
254
+ const principal = c.get('restAuth');
255
+ return c.json({ data: memberData(findMember(deps.db, principal.memberId)) }, 200);
256
+ });
257
+ const updateMeRoute = createRoute({
258
+ method: 'patch',
259
+ path: '/api/v1/me',
260
+ operationId: 'updateCurrentMember',
261
+ summary: 'Update the current member profile',
262
+ request: {
263
+ body: {
264
+ required: true,
265
+ content: { 'application/json': { schema: z.object({ name: z.string().trim().min(1) }).strict() } },
266
+ },
267
+ },
268
+ ...sessionRoute,
269
+ responses,
270
+ });
271
+ app.openapi(updateMeRoute, (c) => {
272
+ try {
273
+ const principal = requireSession(c.get('restAuth'));
274
+ const member = updateMember(deps.db, principal.memberId, c.req.valid('json'));
275
+ audit(deps, principal, 'member.profile.update', 'success', { type: 'member', id: member.id });
276
+ return c.json({ data: memberData(member) }, 200);
277
+ }
278
+ catch (error) {
279
+ return jsonError(c, error);
280
+ }
281
+ });
282
+ const logoutRoute = createRoute({
283
+ method: 'post',
284
+ path: '/api/v1/auth/logout',
285
+ operationId: 'logout',
286
+ summary: 'Revoke the current member session',
287
+ ...sessionRoute,
288
+ responses,
289
+ });
290
+ app.openapi(logoutRoute, (c) => {
291
+ try {
292
+ const principal = requireSession(c.get('restAuth'));
293
+ revokeSession(deps.db, principal.memberId, principal.sessionId);
294
+ audit(deps, principal, 'auth.logout', 'success');
295
+ return c.json({ data: { revoked: principal.sessionId } }, 200);
296
+ }
297
+ catch (error) {
298
+ return jsonError(c, error);
299
+ }
300
+ });
301
+ const passwordRoute = createRoute({
302
+ method: 'put',
303
+ path: '/api/v1/me/password',
304
+ operationId: 'changePassword',
305
+ summary: 'Change the current member password',
306
+ request: {
307
+ body: {
308
+ required: true,
309
+ content: {
310
+ 'application/json': {
311
+ schema: z.object({ currentPassword: z.string(), newPassword: z.string() }).strict(),
312
+ },
313
+ },
314
+ },
315
+ },
316
+ ...sessionRoute,
317
+ responses,
318
+ });
319
+ app.openapi(passwordRoute, async (c) => {
320
+ try {
321
+ const principal = requireSession(c.get('restAuth'));
322
+ await changePassword(deps.db, {
323
+ memberId: principal.memberId,
324
+ keepSessionId: principal.sessionId,
325
+ ...c.req.valid('json'),
326
+ });
327
+ audit(deps, principal, 'auth.password.change', 'success');
328
+ return c.json({ data: { changed: true } }, 200);
329
+ }
330
+ catch (error) {
331
+ return jsonError(c, error);
332
+ }
333
+ });
334
+ const listSessionsRoute = createRoute({
335
+ method: 'get',
336
+ path: '/api/v1/me/sessions',
337
+ operationId: 'listSessions',
338
+ summary: 'List current member sessions',
339
+ ...sessionRoute,
340
+ responses,
341
+ });
342
+ app.openapi(listSessionsRoute, (c) => {
343
+ try {
344
+ const principal = requireSession(c.get('restAuth'));
345
+ return c.json({ data: listMemberSessions(deps.db, principal.memberId, principal.sessionId) }, 200);
346
+ }
347
+ catch (error) {
348
+ return jsonError(c, error);
349
+ }
350
+ });
351
+ const deleteSessionRoute = createRoute({
352
+ method: 'delete',
353
+ path: '/api/v1/me/sessions/{sessionId}',
354
+ operationId: 'revokeSession',
355
+ summary: 'Revoke a member session',
356
+ request: { params: sessionParams },
357
+ ...sessionRoute,
358
+ responses,
359
+ });
360
+ app.openapi(deleteSessionRoute, (c) => {
361
+ try {
362
+ const principal = requireSession(c.get('restAuth'));
363
+ const { sessionId } = c.req.valid('param');
364
+ if (!revokeSession(deps.db, principal.memberId, sessionId))
365
+ throw new EmailError('not_found', 'Session not found.');
366
+ audit(deps, principal, 'auth.session.revoke', 'success', { type: 'session', id: sessionId });
367
+ return c.json({ data: { revoked: sessionId } }, 200);
368
+ }
369
+ catch (error) {
370
+ return jsonError(c, error);
371
+ }
372
+ });
373
+ const listOwnKeysRoute = createRoute({
374
+ method: 'get',
375
+ path: '/api/v1/me/api-keys',
376
+ operationId: 'listOwnApiKeys',
377
+ summary: 'List API keys owned by the current member',
378
+ ...sessionRoute,
379
+ responses,
380
+ });
381
+ app.openapi(listOwnKeysRoute, (c) => {
382
+ try {
383
+ const principal = requireSession(c.get('restAuth'));
384
+ return c.json({ data: listApiKeys(deps.db).filter((key) => key.memberId === principal.memberId) }, 200);
385
+ }
386
+ catch (error) {
387
+ return jsonError(c, error);
388
+ }
389
+ });
390
+ const createOwnKeyRoute = createRoute({
391
+ method: 'post',
392
+ path: '/api/v1/me/api-keys',
393
+ operationId: 'createOwnApiKey',
394
+ summary: 'Create an API key for the current member',
395
+ request: {
396
+ body: {
397
+ required: true,
398
+ content: {
399
+ 'application/json': {
400
+ schema: ownApiKeyBody,
401
+ },
402
+ },
403
+ },
404
+ },
405
+ ...sessionRoute,
406
+ responses: { ...responses, 201: responses[200] },
407
+ });
408
+ app.openapi(createOwnKeyRoute, (c) => {
409
+ try {
410
+ const principal = requireSession(c.get('restAuth'));
411
+ const input = c.req.valid('json');
412
+ const permissions = input.capabilities
413
+ ? customPermissionPolicy(input.capabilities)
414
+ : permissionPolicyForProfile(input.permissionProfile ?? 'full', input.supplementalCapabilities);
415
+ for (const accountId of input.accountIds ?? []) {
416
+ if (!deps.registry)
417
+ throw new EmailError('unsupported_capability', 'Account management is unavailable.');
418
+ const account = deps.registry.getAccount(accountId);
419
+ if (!canAccessAccount(principal, account)) {
420
+ throw new EmailError('permission_denied', `Mailbox ${accountId} is not available to this member.`);
421
+ }
422
+ }
423
+ const created = createApiKey(deps.db, input.name, principal.memberId, permissions, input.accountIds ?? null);
424
+ audit(deps, principal, 'api_key.create', 'success', { type: 'api_key', id: created.info.id });
425
+ return c.json({ data: { ...created.info, key: created.key } }, 201);
426
+ }
427
+ catch (error) {
428
+ return jsonError(c, error);
429
+ }
430
+ });
431
+ const deleteOwnKeyRoute = createRoute({
432
+ method: 'delete',
433
+ path: '/api/v1/me/api-keys/{keyId}',
434
+ operationId: 'revokeOwnApiKey',
435
+ summary: 'Revoke an API key owned by the current member',
436
+ request: { params: keyParams },
437
+ ...sessionRoute,
438
+ responses,
439
+ });
440
+ app.openapi(deleteOwnKeyRoute, (c) => {
441
+ try {
442
+ const principal = requireSession(c.get('restAuth'));
443
+ const { keyId } = c.req.valid('param');
444
+ const owned = listApiKeys(deps.db).some((key) => key.id === keyId && key.memberId === principal.memberId);
445
+ if (!owned || !revokeApiKey(deps.db, keyId))
446
+ throw new EmailError('not_found', 'API key not found.');
447
+ audit(deps, principal, 'api_key.revoke', 'success', { type: 'api_key', id: keyId });
448
+ return c.json({ data: { revoked: keyId } }, 200);
449
+ }
450
+ catch (error) {
451
+ return jsonError(c, error);
452
+ }
453
+ });
454
+ const connectionRoute = createRoute({
455
+ method: 'post',
456
+ path: '/api/v1/accounts/connections',
457
+ operationId: 'connectOwnAccount',
458
+ summary: 'Connect or reauthorize a mailbox account',
459
+ request: {
460
+ body: {
461
+ required: true,
462
+ content: {
463
+ 'application/json': {
464
+ schema: z
465
+ .object({
466
+ provider: z.enum(['gmail', 'outlook', 'imap']),
467
+ reauthorizeAccountId: z.string().optional(),
468
+ email: z.string().email().optional(),
469
+ displayName: z.string().optional(),
470
+ imap: mailServerSettings.optional(),
471
+ smtp: mailServerSettings.optional(),
472
+ saveSent: z.boolean().optional(),
473
+ folderOverrides: folderOverrides.optional(),
474
+ })
475
+ .strict(),
476
+ },
477
+ },
478
+ },
479
+ },
480
+ ...sessionRoute,
481
+ responses: { ...responses, ...providerResponses, 201: responses[200] },
482
+ });
483
+ app.openapi(connectionRoute, async (c) => {
484
+ const principal = c.get('restAuth');
485
+ try {
486
+ requireSession(principal);
487
+ if (!deps.registry)
488
+ throw new EmailError('unsupported_capability', 'Account management is unavailable.');
489
+ const input = c.req.valid('json');
490
+ let existing = input.reauthorizeAccountId ? deps.registry.getAccount(input.reauthorizeAccountId) : undefined;
491
+ if (!existing && input.provider === 'imap' && input.email) {
492
+ existing = deps.registry
493
+ .listAccounts()
494
+ .find((account) => account.email.toLowerCase() === input.email.toLowerCase());
495
+ }
496
+ if (existing && !canManageOwnedAccount(principal, existing)) {
497
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can reauthorize it.');
498
+ }
499
+ if (existing && existing.provider !== input.provider) {
500
+ throw new EmailError('invalid_request', `Account ${existing.id} uses ${existing.provider}, not ${input.provider}.`);
501
+ }
502
+ if (!existing)
503
+ deps.registry.assertCanAddAccount();
504
+ const intent = existing
505
+ ? { reauthorizeAccountId: existing.id }
506
+ : { ownerMemberId: principal.memberId, sharedWithAll: false, grantedMemberIds: [] };
507
+ if (input.provider === 'gmail' || input.provider === 'outlook') {
508
+ const prepared = input.provider === 'gmail'
509
+ ? prepareHostedGmailConnection(deps.db, deps.config, intent)
510
+ : prepareHostedOutlookConnection(deps.db, deps.config, intent);
511
+ audit(deps, principal, 'account.connection.prepare', 'success');
512
+ return c.json({
513
+ data: {
514
+ provider: input.provider,
515
+ connectionUrl: prepared.connectionUrl,
516
+ expiresAt: new Date(prepared.expiresAt).toISOString(),
517
+ },
518
+ }, 201);
519
+ }
520
+ if (!input.email || !input.imap || !input.smtp) {
521
+ throw new EmailError('invalid_request', 'email, imap, and smtp are required for an IMAP connection.');
522
+ }
523
+ const previous = existing?.provider === 'imap' ? deps.registry.loadImapCredentials(existing.id) : undefined;
524
+ const credentials = {
525
+ imap: input.imap,
526
+ smtp: input.smtp,
527
+ saveSent: input.saveSent ?? previous?.saveSent ?? true,
528
+ folderOverrides: { ...previous?.folderOverrides, ...input.folderOverrides },
529
+ };
530
+ const warnings = await deps.registry.testImapCredentials(input.email, credentials, input.displayName);
531
+ const account = deps.registry.addImapAccount(input.email, credentials, input.displayName, existing ? undefined : principal.memberId, existing?.id, existing ? undefined : { sharedWithAll: false });
532
+ audit(deps, principal, 'account.connection.save', 'success', { type: 'account', id: account.id });
533
+ return c.json({ data: { account, warnings } }, 201);
534
+ }
535
+ catch (error) {
536
+ return jsonError(c, error);
537
+ }
538
+ });
539
+ const removeOwnAccountRoute = createRoute({
540
+ method: 'delete',
541
+ path: '/api/v1/accounts/{accountId}/connection',
542
+ operationId: 'removeOwnAccount',
543
+ summary: 'Remove a mailbox account owned by the current member',
544
+ request: { params: accountParams },
545
+ ...sessionRoute,
546
+ responses,
547
+ });
548
+ app.openapi(removeOwnAccountRoute, (c) => {
549
+ const principal = c.get('restAuth');
550
+ try {
551
+ requireSession(principal);
552
+ if (!deps.registry)
553
+ throw new EmailError('unsupported_capability', 'Account management is unavailable.');
554
+ const account = deps.registry.getAccount(c.req.valid('param').accountId);
555
+ if (!canManageOwnedAccount(principal, account)) {
556
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can remove it.');
557
+ }
558
+ deps.registry.removeAccount(account.id);
559
+ audit(deps, principal, 'account.remove', 'success', { type: 'account', id: account.id });
560
+ return c.json({ data: { removed: account.id } }, 200);
561
+ }
562
+ catch (error) {
563
+ return jsonError(c, error);
564
+ }
565
+ });
566
+ const configureOwnFoldersRoute = createRoute({
567
+ method: 'patch',
568
+ path: '/api/v1/accounts/{accountId}/imap/folders',
569
+ operationId: 'updateOwnedImapFolders',
570
+ summary: 'Update folder settings for an owned IMAP mailbox',
571
+ request: {
572
+ params: accountParams,
573
+ body: { required: true, content: { 'application/json': { schema: folderPatch } } },
574
+ },
575
+ ...sessionRoute,
576
+ responses: { ...responses, ...providerResponses },
577
+ });
578
+ app.openapi(configureOwnFoldersRoute, async (c) => {
579
+ const principal = c.get('restAuth');
580
+ try {
581
+ requireSession(principal);
582
+ if (!deps.registry)
583
+ throw new EmailError('unsupported_capability', 'Account management is unavailable.');
584
+ const account = deps.registry.getAccount(c.req.valid('param').accountId);
585
+ if (!canManageOwnedAccount(principal, account)) {
586
+ throw new EmailError('permission_denied', 'Only the mailbox owner or an administrator can configure it.');
587
+ }
588
+ if (account.provider !== 'imap')
589
+ throw new EmailError('invalid_request', 'Folder overrides are only available for IMAP mailboxes.');
590
+ const patch = c.req.valid('json');
591
+ const current = deps.registry.loadImapCredentials(account.id);
592
+ const nextOverrides = { ...current.folderOverrides };
593
+ for (const role of folderRoles) {
594
+ if (!(role in patch))
595
+ continue;
596
+ const value = patch[role];
597
+ if (value === null)
598
+ delete nextOverrides[role];
599
+ else if (value !== undefined)
600
+ nextOverrides[role] = value;
601
+ }
602
+ const proposed = { ...current, folderOverrides: nextOverrides };
603
+ const warnings = await deps.registry.testImapFolderOverrides(account.email, proposed, account.displayName);
604
+ const invalid = warnings.find((warning) => warning.reason === 'stale_override' && typeof patch[warning.role] === 'string');
605
+ if (invalid)
606
+ throw new EmailError('invalid_request', invalid.message);
607
+ deps.registry.saveImapCredentials(account.id, proposed);
608
+ audit(deps, principal, 'account.folders.update', 'success', { type: 'account', id: account.id });
609
+ return c.json({ data: { accountId: account.id, folderOverrides: nextOverrides, warnings } }, 200);
610
+ }
611
+ catch (error) {
612
+ return jsonError(c, error);
613
+ }
614
+ });
615
+ const listMembersRoute = createRoute({
616
+ method: 'get',
617
+ path: '/api/v1/admin/members',
618
+ operationId: 'listMembers',
619
+ summary: 'List members',
620
+ ...protectedRoute,
621
+ responses,
622
+ });
623
+ app.openapi(listMembersRoute, (c) => {
624
+ try {
625
+ requireAdministrator(c.get('restAuth'), 'admin.members');
626
+ return c.json({ data: listMembers(deps.db).map(memberData) }, 200);
627
+ }
628
+ catch (error) {
629
+ return jsonError(c, error);
630
+ }
631
+ });
632
+ const createMemberRoute = createRoute({
633
+ method: 'post',
634
+ path: '/api/v1/admin/members',
635
+ operationId: 'createMember',
636
+ summary: 'Create and invite a member',
637
+ request: {
638
+ body: {
639
+ required: true,
640
+ content: {
641
+ 'application/json': {
642
+ schema: z
643
+ .object({ name: z.string().trim().min(1), email: z.string().email(), role: z.enum(['admin', 'member']) })
644
+ .strict(),
645
+ },
646
+ },
647
+ },
648
+ },
649
+ ...protectedRoute,
650
+ responses: { ...responses, 201: responses[200] },
651
+ });
652
+ app.openapi(createMemberRoute, (c) => {
653
+ const principal = c.get('restAuth');
654
+ try {
655
+ requireAdministrator(principal, 'admin.members');
656
+ const member = addMember(deps.db, { ...c.req.valid('json'), status: 'pending' });
657
+ const invitation = issueMemberAuthToken(deps.db, {
658
+ memberId: member.id,
659
+ kind: 'enrollment',
660
+ createdByMemberId: principal.memberId,
661
+ });
662
+ audit(deps, principal, 'member.create', 'success', { type: 'member', id: member.id });
663
+ return c.json({ data: { ...memberData(member), invitation } }, 201);
664
+ }
665
+ catch (error) {
666
+ return jsonError(c, error);
667
+ }
668
+ });
669
+ const patchMemberRoute = createRoute({
670
+ method: 'patch',
671
+ path: '/api/v1/admin/members/{memberId}',
672
+ operationId: 'updateMember',
673
+ summary: 'Update a member',
674
+ request: {
675
+ params: memberParams,
676
+ body: {
677
+ required: true,
678
+ content: {
679
+ 'application/json': {
680
+ schema: z
681
+ .object({
682
+ name: z.string().trim().min(1).optional(),
683
+ email: z.string().email().optional(),
684
+ role: z.enum(['admin', 'member']).optional(),
685
+ status: z.enum(['active', 'suspended']).optional(),
686
+ })
687
+ .strict(),
688
+ },
689
+ },
690
+ },
691
+ },
692
+ ...protectedRoute,
693
+ responses,
694
+ });
695
+ app.openapi(patchMemberRoute, (c) => {
696
+ const principal = c.get('restAuth');
697
+ try {
698
+ requireAdministrator(principal, 'admin.members');
699
+ const { memberId } = c.req.valid('param');
700
+ const update = c.req.valid('json');
701
+ if (update.status === 'active') {
702
+ const member = findMember(deps.db, memberId);
703
+ const credential = deps.db
704
+ .select()
705
+ .from(memberCredentials)
706
+ .where(eq(memberCredentials.memberId, member.id))
707
+ .get();
708
+ if (!credential)
709
+ throw new EmailError('invalid_request', 'The member must enroll before becoming active.');
710
+ }
711
+ const member = updateMember(deps.db, memberId, update);
712
+ audit(deps, principal, 'member.update', 'success', { type: 'member', id: member.id });
713
+ return c.json({ data: memberData(member) }, 200);
714
+ }
715
+ catch (error) {
716
+ return jsonError(c, error);
717
+ }
718
+ });
719
+ const deleteMemberRoute = createRoute({
720
+ method: 'delete',
721
+ path: '/api/v1/admin/members/{memberId}',
722
+ operationId: 'deleteMember',
723
+ summary: 'Remove a member',
724
+ request: { params: memberParams },
725
+ ...protectedRoute,
726
+ responses,
727
+ });
728
+ app.openapi(deleteMemberRoute, (c) => {
729
+ const principal = c.get('restAuth');
730
+ try {
731
+ requireAdministrator(principal, 'admin.members');
732
+ const member = findMember(deps.db, c.req.valid('param').memberId);
733
+ removeMember(deps.db, member.id);
734
+ audit(deps, principal, 'member.remove', 'success', { type: 'member', id: member.id });
735
+ return c.json({ data: { removed: member.id } }, 200);
736
+ }
737
+ catch (error) {
738
+ return jsonError(c, error);
739
+ }
740
+ });
741
+ for (const [suffix, kind] of [
742
+ ['invitation', 'enrollment'],
743
+ ['password-reset', 'password_reset'],
744
+ ]) {
745
+ const route = createRoute({
746
+ method: 'post',
747
+ path: `/api/v1/admin/members/{memberId}/${suffix}`,
748
+ operationId: kind === 'enrollment' ? 'inviteMember' : 'createMemberPasswordReset',
749
+ summary: kind === 'enrollment' ? 'Issue a member invitation' : 'Issue a member password reset',
750
+ request: { params: memberParams },
751
+ ...protectedRoute,
752
+ responses: { ...responses, 201: responses[200] },
753
+ });
754
+ app.openapi(route, (c) => {
755
+ const principal = c.get('restAuth');
756
+ try {
757
+ requireAdministrator(principal, 'admin.members');
758
+ const member = findMember(deps.db, c.req.valid('param').memberId);
759
+ const token = issueMemberAuthToken(deps.db, {
760
+ memberId: member.id,
761
+ kind,
762
+ createdByMemberId: principal.memberId,
763
+ });
764
+ audit(deps, principal, `member.${kind}`, 'success', { type: 'member', id: member.id });
765
+ return c.json({ data: token }, 201);
766
+ }
767
+ catch (error) {
768
+ return jsonError(c, error);
769
+ }
770
+ });
771
+ }
772
+ const listMemberSessionsRoute = createRoute({
773
+ method: 'get',
774
+ path: '/api/v1/admin/members/{memberId}/sessions',
775
+ operationId: 'listMemberSessions',
776
+ summary: "List a member's sessions",
777
+ request: { params: memberParams },
778
+ ...protectedRoute,
779
+ responses,
780
+ });
781
+ app.openapi(listMemberSessionsRoute, (c) => {
782
+ try {
783
+ requireAdministrator(c.get('restAuth'), 'admin.members');
784
+ const member = findMember(deps.db, c.req.valid('param').memberId);
785
+ return c.json({ data: listMemberSessions(deps.db, member.id) }, 200);
786
+ }
787
+ catch (error) {
788
+ return jsonError(c, error);
789
+ }
790
+ });
791
+ const revokeMemberSessionRoute = createRoute({
792
+ method: 'delete',
793
+ path: '/api/v1/admin/members/{memberId}/sessions/{sessionId}',
794
+ operationId: 'revokeMemberSession',
795
+ summary: "Revoke a member's session",
796
+ request: { params: memberSessionParams },
797
+ ...protectedRoute,
798
+ responses,
799
+ });
800
+ app.openapi(revokeMemberSessionRoute, (c) => {
801
+ const principal = c.get('restAuth');
802
+ try {
803
+ requireAdministrator(principal, 'admin.members');
804
+ const { memberId, sessionId } = c.req.valid('param');
805
+ const member = findMember(deps.db, memberId);
806
+ if (!revokeSession(deps.db, member.id, sessionId))
807
+ throw new EmailError('not_found', 'Session not found.');
808
+ audit(deps, principal, 'member.session.revoke', 'success', { type: 'session', id: sessionId });
809
+ return c.json({ data: { revoked: sessionId } }, 200);
810
+ }
811
+ catch (error) {
812
+ return jsonError(c, error);
813
+ }
814
+ });
815
+ const listAdminAccountsRoute = createRoute({
816
+ method: 'get',
817
+ path: '/api/v1/admin/accounts',
818
+ operationId: 'listAdminAccounts',
819
+ summary: 'List all mailbox account metadata',
820
+ ...protectedRoute,
821
+ responses,
822
+ });
823
+ app.openapi(listAdminAccountsRoute, (c) => {
824
+ try {
825
+ const principal = c.get('restAuth');
826
+ requireAdministrator(principal, 'admin.accounts');
827
+ return c.json({ data: (deps.registry?.listAccounts() ?? []).filter((account) => canSeeAccountMetadata(principal, account)) }, 200);
828
+ }
829
+ catch (error) {
830
+ return jsonError(c, error);
831
+ }
832
+ });
833
+ const patchAdminAccountRoute = createRoute({
834
+ method: 'patch',
835
+ path: '/api/v1/admin/accounts/{accountId}',
836
+ operationId: 'updateAccountAccess',
837
+ summary: 'Update mailbox ownership and access',
838
+ request: {
839
+ params: accountParams,
840
+ body: {
841
+ required: true,
842
+ content: {
843
+ 'application/json': {
844
+ schema: z
845
+ .object({
846
+ ownerMemberId: z.string().optional(),
847
+ sharedWithAll: z.boolean().optional(),
848
+ grantedMemberIds: z.array(z.string()).optional(),
849
+ })
850
+ .strict(),
851
+ },
852
+ },
853
+ },
854
+ },
855
+ ...protectedRoute,
856
+ responses,
857
+ });
858
+ app.openapi(patchAdminAccountRoute, (c) => {
859
+ const principal = c.get('restAuth');
860
+ try {
861
+ requireAdministrator(principal, 'admin.accounts');
862
+ if (!deps.registry)
863
+ throw new EmailError('unsupported_capability', 'Account management is unavailable.');
864
+ const { accountId } = c.req.valid('param');
865
+ const input = c.req.valid('json');
866
+ let account = deps.registry.getAccount(accountId);
867
+ if (!canSeeAccountMetadata(principal, account))
868
+ throw new EmailError('not_found', 'Mailbox not found.');
869
+ if (input.ownerMemberId)
870
+ account = deps.registry.assignAccountOwner(account.id, findMember(deps.db, input.ownerMemberId).id);
871
+ if (input.sharedWithAll !== undefined || input.grantedMemberIds !== undefined) {
872
+ account = deps.registry.setAccountAccess(account.id, {
873
+ sharedWithAll: input.sharedWithAll ?? account.sharedWithAll,
874
+ grantedMemberIds: (input.grantedMemberIds ?? account.grantedMemberIds).map((ref) => findMember(deps.db, ref).id),
875
+ });
876
+ }
877
+ audit(deps, principal, 'account.access.update', 'success', { type: 'account', id: account.id });
878
+ return c.json({ data: account }, 200);
879
+ }
880
+ catch (error) {
881
+ return jsonError(c, error);
882
+ }
883
+ });
884
+ const deleteAdminAccountRoute = createRoute({
885
+ method: 'delete',
886
+ path: '/api/v1/admin/accounts/{accountId}',
887
+ operationId: 'deleteAdminAccount',
888
+ summary: 'Remove a mailbox account',
889
+ request: { params: accountParams },
890
+ ...protectedRoute,
891
+ responses,
892
+ });
893
+ app.openapi(deleteAdminAccountRoute, (c) => {
894
+ const principal = c.get('restAuth');
895
+ try {
896
+ requireAdministrator(principal, 'admin.accounts');
897
+ if (!deps.registry)
898
+ throw new EmailError('unsupported_capability', 'Account management is unavailable.');
899
+ const { accountId } = c.req.valid('param');
900
+ const account = deps.registry.getAccount(accountId);
901
+ if (!canSeeAccountMetadata(principal, account))
902
+ throw new EmailError('not_found', 'Mailbox not found.');
903
+ deps.registry.removeAccount(accountId);
904
+ audit(deps, principal, 'account.remove', 'success', { type: 'account', id: accountId });
905
+ return c.json({ data: { removed: accountId } }, 200);
906
+ }
907
+ catch (error) {
908
+ return jsonError(c, error);
909
+ }
910
+ });
911
+ const auditRoute = createRoute({
912
+ method: 'get',
913
+ path: '/api/v1/admin/audit-events',
914
+ operationId: 'listAuditEvents',
915
+ summary: 'List security audit events',
916
+ request: { query: z.object({ limit: z.string().regex(/^\d+$/).optional() }) },
917
+ ...protectedRoute,
918
+ responses,
919
+ });
920
+ app.openapi(auditRoute, (c) => {
921
+ try {
922
+ requireAdministrator(c.get('restAuth'), 'admin.audit');
923
+ const limit = Number(c.req.valid('query').limit ?? 100);
924
+ return c.json({ data: listAdminAuditEvents(deps.db, limit) }, 200);
925
+ }
926
+ catch (error) {
927
+ return jsonError(c, error);
928
+ }
929
+ });
930
+ }
931
+ //# sourceMappingURL=identity.js.map