@de-otio/chaoskb-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/lib/admin-handler/index.d.ts +22 -0
  2. package/dist/lib/admin-handler/index.d.ts.map +1 -0
  3. package/dist/lib/admin-handler/index.js +92 -0
  4. package/dist/lib/admin-handler/index.js.map +1 -0
  5. package/dist/lib/admin-handler/index.ts +123 -0
  6. package/dist/lib/admin-handler/routes/metrics.d.ts +11 -0
  7. package/dist/lib/admin-handler/routes/metrics.d.ts.map +1 -0
  8. package/dist/lib/admin-handler/routes/metrics.js +200 -0
  9. package/dist/lib/admin-handler/routes/metrics.js.map +1 -0
  10. package/dist/lib/admin-handler/routes/metrics.ts +234 -0
  11. package/dist/lib/admin-handler/routes/overview.d.ts +9 -0
  12. package/dist/lib/admin-handler/routes/overview.d.ts.map +1 -0
  13. package/dist/lib/admin-handler/routes/overview.js +110 -0
  14. package/dist/lib/admin-handler/routes/overview.js.map +1 -0
  15. package/dist/lib/admin-handler/routes/overview.ts +133 -0
  16. package/dist/lib/admin-handler/routes/tenants.d.ts +10 -0
  17. package/dist/lib/admin-handler/routes/tenants.d.ts.map +1 -0
  18. package/dist/lib/admin-handler/routes/tenants.js +108 -0
  19. package/dist/lib/admin-handler/routes/tenants.js.map +1 -0
  20. package/dist/lib/admin-handler/routes/tenants.ts +134 -0
  21. package/dist/lib/chaoskb-stack.d.ts +22 -0
  22. package/dist/lib/chaoskb-stack.d.ts.map +1 -0
  23. package/dist/lib/chaoskb-stack.js +60 -0
  24. package/dist/lib/chaoskb-stack.js.map +1 -0
  25. package/dist/lib/constructs/admin-api.d.ts +16 -0
  26. package/dist/lib/constructs/admin-api.d.ts.map +1 -0
  27. package/dist/lib/constructs/admin-api.js +93 -0
  28. package/dist/lib/constructs/admin-api.js.map +1 -0
  29. package/dist/lib/constructs/admin-dashboard.d.ts +18 -0
  30. package/dist/lib/constructs/admin-dashboard.d.ts.map +1 -0
  31. package/dist/lib/constructs/admin-dashboard.js +172 -0
  32. package/dist/lib/constructs/admin-dashboard.js.map +1 -0
  33. package/dist/lib/constructs/api.d.ts +17 -0
  34. package/dist/lib/constructs/api.d.ts.map +1 -0
  35. package/dist/lib/constructs/api.js +81 -0
  36. package/dist/lib/constructs/api.js.map +1 -0
  37. package/dist/lib/constructs/auth.d.ts +11 -0
  38. package/dist/lib/constructs/auth.d.ts.map +1 -0
  39. package/dist/lib/constructs/auth.js +18 -0
  40. package/dist/lib/constructs/auth.js.map +1 -0
  41. package/dist/lib/constructs/blob-store.d.ts +10 -0
  42. package/dist/lib/constructs/blob-store.d.ts.map +1 -0
  43. package/dist/lib/constructs/blob-store.js +31 -0
  44. package/dist/lib/constructs/blob-store.js.map +1 -0
  45. package/dist/lib/deploy-cli.d.ts +3 -0
  46. package/dist/lib/deploy-cli.d.ts.map +1 -0
  47. package/dist/lib/deploy-cli.js +49 -0
  48. package/dist/lib/deploy-cli.js.map +1 -0
  49. package/dist/lib/handler/index.d.ts +23 -0
  50. package/dist/lib/handler/index.d.ts.map +1 -0
  51. package/dist/lib/handler/index.js +276 -0
  52. package/dist/lib/handler/index.js.map +1 -0
  53. package/dist/lib/handler/index.ts +372 -0
  54. package/dist/lib/handler/logger.d.ts +16 -0
  55. package/dist/lib/handler/logger.d.ts.map +1 -0
  56. package/dist/lib/handler/logger.js +26 -0
  57. package/dist/lib/handler/logger.js.map +1 -0
  58. package/dist/lib/handler/logger.ts +36 -0
  59. package/dist/lib/handler/middleware/input-validation.d.ts +6 -0
  60. package/dist/lib/handler/middleware/input-validation.d.ts.map +1 -0
  61. package/dist/lib/handler/middleware/input-validation.js +36 -0
  62. package/dist/lib/handler/middleware/input-validation.js.map +1 -0
  63. package/dist/lib/handler/middleware/input-validation.ts +44 -0
  64. package/dist/lib/handler/middleware/rate-limit.d.ts +14 -0
  65. package/dist/lib/handler/middleware/rate-limit.d.ts.map +1 -0
  66. package/dist/lib/handler/middleware/rate-limit.js +94 -0
  67. package/dist/lib/handler/middleware/rate-limit.js.map +1 -0
  68. package/dist/lib/handler/middleware/rate-limit.ts +121 -0
  69. package/dist/lib/handler/middleware/ssh-auth.d.ts +48 -0
  70. package/dist/lib/handler/middleware/ssh-auth.d.ts.map +1 -0
  71. package/dist/lib/handler/middleware/ssh-auth.js +256 -0
  72. package/dist/lib/handler/middleware/ssh-auth.js.map +1 -0
  73. package/dist/lib/handler/middleware/ssh-auth.ts +300 -0
  74. package/dist/lib/handler/routes/audit.d.ts +24 -0
  75. package/dist/lib/handler/routes/audit.d.ts.map +1 -0
  76. package/dist/lib/handler/routes/audit.js +94 -0
  77. package/dist/lib/handler/routes/audit.js.map +1 -0
  78. package/dist/lib/handler/routes/audit.ts +101 -0
  79. package/dist/lib/handler/routes/blobs.d.ts +13 -0
  80. package/dist/lib/handler/routes/blobs.d.ts.map +1 -0
  81. package/dist/lib/handler/routes/blobs.js +298 -0
  82. package/dist/lib/handler/routes/blobs.js.map +1 -0
  83. package/dist/lib/handler/routes/blobs.ts +348 -0
  84. package/dist/lib/handler/routes/devices.d.ts +48 -0
  85. package/dist/lib/handler/routes/devices.d.ts.map +1 -0
  86. package/dist/lib/handler/routes/devices.js +394 -0
  87. package/dist/lib/handler/routes/devices.js.map +1 -0
  88. package/dist/lib/handler/routes/devices.ts +458 -0
  89. package/dist/lib/handler/routes/export.d.ts +9 -0
  90. package/dist/lib/handler/routes/export.d.ts.map +1 -0
  91. package/dist/lib/handler/routes/export.js +40 -0
  92. package/dist/lib/handler/routes/export.js.map +1 -0
  93. package/dist/lib/handler/routes/export.ts +55 -0
  94. package/dist/lib/handler/routes/github.d.ts +31 -0
  95. package/dist/lib/handler/routes/github.d.ts.map +1 -0
  96. package/dist/lib/handler/routes/github.js +118 -0
  97. package/dist/lib/handler/routes/github.js.map +1 -0
  98. package/dist/lib/handler/routes/github.ts +162 -0
  99. package/dist/lib/handler/routes/health.d.ts +6 -0
  100. package/dist/lib/handler/routes/health.d.ts.map +1 -0
  101. package/dist/lib/handler/routes/health.js +14 -0
  102. package/dist/lib/handler/routes/health.js.map +1 -0
  103. package/dist/lib/handler/routes/health.ts +10 -0
  104. package/dist/lib/handler/routes/invites.d.ts +24 -0
  105. package/dist/lib/handler/routes/invites.d.ts.map +1 -0
  106. package/dist/lib/handler/routes/invites.js +445 -0
  107. package/dist/lib/handler/routes/invites.js.map +1 -0
  108. package/dist/lib/handler/routes/invites.ts +527 -0
  109. package/dist/lib/handler/routes/notifications.d.ts +39 -0
  110. package/dist/lib/handler/routes/notifications.d.ts.map +1 -0
  111. package/dist/lib/handler/routes/notifications.js +150 -0
  112. package/dist/lib/handler/routes/notifications.js.map +1 -0
  113. package/dist/lib/handler/routes/notifications.ts +163 -0
  114. package/dist/lib/handler/routes/projects.d.ts +24 -0
  115. package/dist/lib/handler/routes/projects.d.ts.map +1 -0
  116. package/dist/lib/handler/routes/projects.js +47 -0
  117. package/dist/lib/handler/routes/projects.js.map +1 -0
  118. package/dist/lib/handler/routes/projects.ts +69 -0
  119. package/dist/lib/handler/routes/register.d.ts +19 -0
  120. package/dist/lib/handler/routes/register.d.ts.map +1 -0
  121. package/dist/lib/handler/routes/register.js +327 -0
  122. package/dist/lib/handler/routes/register.js.map +1 -0
  123. package/dist/lib/handler/routes/register.ts +363 -0
  124. package/dist/lib/handler/routes/restore.d.ts +9 -0
  125. package/dist/lib/handler/routes/restore.d.ts.map +1 -0
  126. package/dist/lib/handler/routes/restore.js +52 -0
  127. package/dist/lib/handler/routes/restore.js.map +1 -0
  128. package/dist/lib/handler/routes/restore.ts +73 -0
  129. package/dist/lib/handler/routes/revocation.d.ts +13 -0
  130. package/dist/lib/handler/routes/revocation.d.ts.map +1 -0
  131. package/dist/lib/handler/routes/revocation.js +63 -0
  132. package/dist/lib/handler/routes/revocation.js.map +1 -0
  133. package/dist/lib/handler/routes/revocation.ts +87 -0
  134. package/dist/lib/handler/routes/rotation.d.ts +24 -0
  135. package/dist/lib/handler/routes/rotation.d.ts.map +1 -0
  136. package/dist/lib/handler/routes/rotation.js +291 -0
  137. package/dist/lib/handler/routes/rotation.js.map +1 -0
  138. package/dist/lib/handler/routes/rotation.ts +336 -0
  139. package/dist/lib/handler/routes/tenants.d.ts +11 -0
  140. package/dist/lib/handler/routes/tenants.d.ts.map +1 -0
  141. package/dist/lib/handler/routes/tenants.js +181 -0
  142. package/dist/lib/handler/routes/tenants.js.map +1 -0
  143. package/dist/lib/handler/routes/tenants.ts +198 -0
  144. package/dist/lib/handler/routes/wrapped-key.d.ts +21 -0
  145. package/dist/lib/handler/routes/wrapped-key.d.ts.map +1 -0
  146. package/dist/lib/handler/routes/wrapped-key.js +76 -0
  147. package/dist/lib/handler/routes/wrapped-key.js.map +1 -0
  148. package/dist/lib/handler/routes/wrapped-key.ts +108 -0
  149. package/dist/lib/index.d.ts +7 -0
  150. package/dist/lib/index.d.ts.map +1 -0
  151. package/dist/lib/index.js +16 -0
  152. package/dist/lib/index.js.map +1 -0
  153. package/dist/vitest.config.d.ts +3 -0
  154. package/dist/vitest.config.d.ts.map +1 -0
  155. package/dist/vitest.config.js +18 -0
  156. package/dist/vitest.config.js.map +1 -0
  157. package/package.json +61 -0
@@ -0,0 +1,372 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { logger } from './logger.js';
4
+ import { authenticateRequest, AuthError } from './middleware/ssh-auth.js';
5
+ import { checkIpRateLimit, rateLimitHeaders } from './middleware/rate-limit.js';
6
+ import { handleHealth } from './routes/health.js';
7
+ import { handleRegister, handleChallenge } from './routes/register.js';
8
+ import {
9
+ handlePutBlob,
10
+ handleGetBlob,
11
+ handleDeleteBlob,
12
+ handleListBlobs,
13
+ handleCountBlobs,
14
+ } from './routes/blobs.js';
15
+ import { handleRestore } from './routes/restore.js';
16
+ import { handleCreateTenant, handleListTenants, handleDeleteTenant } from './routes/tenants.js';
17
+ import { handleExport } from './routes/export.js';
18
+ import { handlePutWrappedKey, handleGetWrappedKey } from './routes/wrapped-key.js';
19
+ import { handleRotateStart, handleRotateConfirm } from './routes/rotation.js';
20
+ import { handleGetAuditLog } from './routes/audit.js';
21
+ import { handleRevokeAll } from './routes/revocation.js';
22
+ import {
23
+ handleCreateLinkCodeFull,
24
+ handleLinkConfirm,
25
+ handleGetLinkCodeStatus,
26
+ handleListDevices,
27
+ handleDeleteDevice,
28
+ } from './routes/devices.js';
29
+ import {
30
+ handleCreateInvite,
31
+ handleListInvites,
32
+ handleAcceptInvite,
33
+ handleDeclineInvite,
34
+ } from './routes/invites.js';
35
+ import { handleListAvailableProjects } from './routes/projects.js';
36
+ import { handleGetNotifications, handleDismissNotification } from './routes/notifications.js';
37
+
38
+ interface LambdaFunctionURLEvent {
39
+ requestContext: {
40
+ http: { method: string; path: string; sourceIp?: string };
41
+ requestId: string;
42
+ };
43
+ headers: Record<string, string>;
44
+ queryStringParameters?: Record<string, string>;
45
+ body?: string;
46
+ isBase64Encoded: boolean;
47
+ }
48
+
49
+ interface LambdaFunctionURLResult {
50
+ statusCode: number;
51
+ headers: Record<string, string>;
52
+ body: string;
53
+ isBase64Encoded?: boolean;
54
+ }
55
+
56
+ const TABLE_NAME = process.env['TABLE_NAME'] ?? '';
57
+ const SIGNUPS_ENABLED_PARAM = process.env['SIGNUPS_ENABLED_PARAM'] ?? '';
58
+
59
+ const client = new DynamoDBClient({});
60
+ const ddb = DynamoDBDocumentClient.from(client, {
61
+ marshallOptions: { removeUndefinedValues: true },
62
+ });
63
+
64
+ const CORS_HEADERS: Record<string, string> = {
65
+ 'Access-Control-Allow-Origin': 'none',
66
+ 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS',
67
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
68
+ };
69
+
70
+ function response(
71
+ statusCode: number,
72
+ body: string,
73
+ headers: Record<string, string> = {},
74
+ isBase64Encoded = false,
75
+ ): LambdaFunctionURLResult {
76
+ return {
77
+ statusCode,
78
+ headers: { ...CORS_HEADERS, ...headers },
79
+ body,
80
+ isBase64Encoded,
81
+ };
82
+ }
83
+
84
+ export const handler = async (event: LambdaFunctionURLEvent): Promise<LambdaFunctionURLResult> => {
85
+ const startTime = Date.now();
86
+ const method = event.requestContext.http.method;
87
+ const path = event.requestContext.http.path;
88
+ const requestId = event.requestContext.requestId;
89
+
90
+ logger.info('Request received', { requestId, operation: `${method} ${path}` });
91
+
92
+ try {
93
+ // OPTIONS (CORS preflight)
94
+ if (method === 'OPTIONS') {
95
+ return response(204, '', {});
96
+ }
97
+
98
+ // Health check — no auth
99
+ if (method === 'GET' && path === '/health') {
100
+ const result = handleHealth();
101
+ return response(result.statusCode, result.body, result.headers);
102
+ }
103
+
104
+ // Registration challenge — no auth, IP rate limited
105
+ if (method === 'GET' && path === '/v1/register/challenge') {
106
+ const sourceIp = event.headers['x-forwarded-for']?.split(',')[0]?.trim()
107
+ ?? event.requestContext.http.sourceIp
108
+ ?? 'unknown';
109
+ const rateCheck = await checkIpRateLimit(sourceIp, 'CHALLENGE', ddb, TABLE_NAME);
110
+ if (!rateCheck.allowed) {
111
+ return response(429, JSON.stringify({ error: 'rate_limited', message: 'Too many requests' }), {
112
+ 'Content-Type': 'application/json',
113
+ ...rateLimitHeaders(rateCheck),
114
+ });
115
+ }
116
+ const result = await handleChallenge(ddb, TABLE_NAME);
117
+ return response(result.statusCode, result.body, result.headers);
118
+ }
119
+
120
+ // Link confirm — no auth, IP rate limited (new device submits its public key)
121
+ if (method === 'POST' && path === '/v1/link-confirm') {
122
+ const sourceIp = event.headers['x-forwarded-for']?.split(',')[0]?.trim()
123
+ ?? event.requestContext.http.sourceIp
124
+ ?? 'unknown';
125
+ const rateCheck = await checkIpRateLimit(sourceIp, 'LINK_CONFIRM', ddb, TABLE_NAME);
126
+ if (!rateCheck.allowed) {
127
+ return response(429, JSON.stringify({ error: 'rate_limited', message: 'Too many requests' }), {
128
+ 'Content-Type': 'application/json',
129
+ ...rateLimitHeaders(rateCheck),
130
+ });
131
+ }
132
+ const result = await handleLinkConfirm(event.body, ddb, TABLE_NAME);
133
+ return response(result.statusCode, result.body, result.headers);
134
+ }
135
+
136
+ // Register — no auth, IP rate limited (1 req/sec)
137
+ if (method === 'POST' && path === '/v1/auth/register') {
138
+ const sourceIp = event.headers['x-forwarded-for']?.split(',')[0]?.trim()
139
+ ?? event.requestContext.http.sourceIp
140
+ ?? 'unknown';
141
+ const rateCheck = await checkIpRateLimit(sourceIp, 'REGISTER', ddb, TABLE_NAME);
142
+ if (!rateCheck.allowed) {
143
+ return response(429, JSON.stringify({ error: 'rate_limited', message: 'Too many requests' }), {
144
+ 'Content-Type': 'application/json',
145
+ ...rateLimitHeaders(rateCheck),
146
+ });
147
+ }
148
+ const result = await handleRegister(event.body, ddb, TABLE_NAME, SIGNUPS_ENABLED_PARAM);
149
+ return response(result.statusCode, result.body, result.headers);
150
+ }
151
+
152
+ // All other routes require authentication
153
+ const auth = await authenticateRequest(event, ddb, TABLE_NAME);
154
+ const tenantId = auth.tenantId;
155
+
156
+ // Revoke all devices (emergency)
157
+ if (path === '/v1/revoke-all' && method === 'POST') {
158
+ const result = await handleRevokeAll(tenantId, ddb, TABLE_NAME);
159
+ return response(result.statusCode, result.body, result.headers);
160
+ }
161
+
162
+ // Audit log
163
+ if (path === '/v1/audit' && method === 'GET') {
164
+ const result = await handleGetAuditLog(tenantId, ddb, TABLE_NAME);
165
+ return response(result.statusCode, result.body, result.headers);
166
+ }
167
+
168
+ // Key rotation routes
169
+ if (path === '/v1/rotate-start' && method === 'POST') {
170
+ const result = await handleRotateStart(tenantId, auth.fingerprint, event.body, ddb, TABLE_NAME);
171
+ return response(result.statusCode, result.body, result.headers);
172
+ }
173
+
174
+ if (path === '/v1/rotate-confirm' && method === 'POST') {
175
+ const result = await handleRotateConfirm(tenantId, auth.fingerprint, ddb, TABLE_NAME);
176
+ return response(result.statusCode, result.body, result.headers);
177
+ }
178
+
179
+ // Device linking routes (authenticated)
180
+ if (path === '/v1/link-code' && method === 'POST') {
181
+ const result = await handleCreateLinkCodeFull(tenantId, event.body, ddb, TABLE_NAME);
182
+ return response(result.statusCode, result.body, result.headers);
183
+ }
184
+
185
+ const linkStatusMatch = path.match(/^\/v1\/link-code\/([^/]+)\/status$/);
186
+ if (linkStatusMatch && method === 'GET') {
187
+ const codeHash = linkStatusMatch[1];
188
+ const result = await handleGetLinkCodeStatus(tenantId, codeHash, ddb, TABLE_NAME);
189
+ return response(result.statusCode, result.body, result.headers);
190
+ }
191
+
192
+ // Device management routes (authenticated)
193
+ if (path === '/v1/devices' && method === 'GET') {
194
+ const result = await handleListDevices(tenantId, ddb, TABLE_NAME);
195
+ return response(result.statusCode, result.body, result.headers);
196
+ }
197
+
198
+ const deviceDeleteMatch = path.match(/^\/v1\/devices\/([^/]+)$/);
199
+ if (deviceDeleteMatch && method === 'DELETE') {
200
+ const fingerprint = deviceDeleteMatch[1];
201
+ const result = await handleDeleteDevice(tenantId, fingerprint, ddb, TABLE_NAME);
202
+ return response(result.statusCode, result.body, result.headers);
203
+ }
204
+
205
+ // Wrapped key routes
206
+ if (path === '/v1/wrapped-key' && method === 'PUT') {
207
+ const result = await handlePutWrappedKey(
208
+ tenantId,
209
+ auth.fingerprint,
210
+ event.body,
211
+ event.isBase64Encoded,
212
+ ddb,
213
+ TABLE_NAME,
214
+ );
215
+ return response(result.statusCode, result.body, result.headers);
216
+ }
217
+
218
+ if (path === '/v1/wrapped-key' && method === 'GET') {
219
+ const result = await handleGetWrappedKey(tenantId, auth.fingerprint, ddb, TABLE_NAME);
220
+ if (result.statusCode === 200 && result.headers['Content-Type'] === 'application/octet-stream') {
221
+ return response(result.statusCode, result.body, result.headers, true);
222
+ }
223
+ return response(result.statusCode, result.body, result.headers);
224
+ }
225
+
226
+ // Blob routes
227
+ const blobMatch = path.match(/^\/v1\/blobs\/([^/]+)$/);
228
+ const restoreMatch = path.match(/^\/v1\/blobs\/([^/]+)\/restore$/);
229
+
230
+ if (restoreMatch && method === 'POST') {
231
+ const blobId = restoreMatch[1];
232
+ const result = await handleRestore(blobId, tenantId, ddb, TABLE_NAME);
233
+ return response(result.statusCode, result.body, result.headers);
234
+ }
235
+
236
+ if (path === '/v1/blobs/count' && method === 'GET') {
237
+ const result = await handleCountBlobs(tenantId, ddb, TABLE_NAME);
238
+ return response(result.statusCode, result.body, result.headers);
239
+ }
240
+
241
+ if (blobMatch) {
242
+ const blobId = blobMatch[1];
243
+
244
+ if (method === 'PUT') {
245
+ const contentType = event.headers['content-type'] || event.headers['Content-Type'] || '';
246
+ const result = await handlePutBlob(
247
+ blobId,
248
+ tenantId,
249
+ event.body,
250
+ event.isBase64Encoded,
251
+ contentType,
252
+ ddb,
253
+ TABLE_NAME,
254
+ );
255
+ if (result.statusCode === 200 && result.headers['Content-Type'] === 'application/octet-stream') {
256
+ return response(result.statusCode, result.body, result.headers, true);
257
+ }
258
+ return response(result.statusCode, result.body, result.headers);
259
+ }
260
+
261
+ if (method === 'GET') {
262
+ const result = await handleGetBlob(blobId, tenantId, ddb, TABLE_NAME);
263
+ if (result.statusCode === 200 && result.headers['Content-Type'] === 'application/octet-stream') {
264
+ return response(result.statusCode, result.body, result.headers, true);
265
+ }
266
+ return response(result.statusCode, result.body, result.headers);
267
+ }
268
+
269
+ if (method === 'DELETE') {
270
+ const result = await handleDeleteBlob(blobId, tenantId, ddb, TABLE_NAME);
271
+ return response(result.statusCode, result.body, result.headers);
272
+ }
273
+ }
274
+
275
+ if (path === '/v1/blobs' && method === 'GET') {
276
+ const since = event.queryStringParameters?.['since'];
277
+ const result = await handleListBlobs(tenantId, since, ddb, TABLE_NAME);
278
+ return response(result.statusCode, result.body, result.headers);
279
+ }
280
+
281
+ // Tenant routes
282
+ const tenantDeleteMatch = path.match(/^\/v1\/tenants\/([^/]+)$/);
283
+
284
+ if (path === '/v1/tenants' && method === 'POST') {
285
+ const result = await handleCreateTenant(event.body, tenantId, ddb, TABLE_NAME);
286
+ return response(result.statusCode, result.body, result.headers);
287
+ }
288
+
289
+ if (path === '/v1/tenants' && method === 'GET') {
290
+ const result = await handleListTenants(tenantId, ddb, TABLE_NAME);
291
+ return response(result.statusCode, result.body, result.headers);
292
+ }
293
+
294
+ if (tenantDeleteMatch && method === 'DELETE') {
295
+ const projectTenantId = tenantDeleteMatch[1];
296
+ const result = await handleDeleteTenant(projectTenantId, tenantId, ddb, TABLE_NAME);
297
+ return response(result.statusCode, result.body, result.headers);
298
+ }
299
+
300
+ // Invite routes
301
+ const inviteActionMatch = path.match(/^\/v1\/invites\/([^/]+)\/(accept|decline)$/);
302
+
303
+ if (path === '/v1/invites' && method === 'POST') {
304
+ const result = await handleCreateInvite(tenantId, auth.fingerprint, event.body, ddb, TABLE_NAME);
305
+ return response(result.statusCode, result.body, result.headers);
306
+ }
307
+
308
+ if (path === '/v1/invites' && method === 'GET') {
309
+ const result = await handleListInvites(tenantId, auth.fingerprint, ddb, TABLE_NAME);
310
+ return response(result.statusCode, result.body, result.headers);
311
+ }
312
+
313
+ if (inviteActionMatch && method === 'POST') {
314
+ const inviteId = inviteActionMatch[1];
315
+ const action = inviteActionMatch[2];
316
+
317
+ if (action === 'accept') {
318
+ const result = await handleAcceptInvite(tenantId, auth.fingerprint, inviteId, ddb, TABLE_NAME);
319
+ return response(result.statusCode, result.body, result.headers);
320
+ }
321
+
322
+ if (action === 'decline') {
323
+ const result = await handleDeclineInvite(tenantId, auth.fingerprint, inviteId, event.body, ddb, TABLE_NAME);
324
+ return response(result.statusCode, result.body, result.headers);
325
+ }
326
+ }
327
+
328
+ // Notifications
329
+ if (path === '/v1/notifications' && method === 'GET') {
330
+ const result = await handleGetNotifications(tenantId, ddb, TABLE_NAME);
331
+ return response(result.statusCode, result.body, result.headers);
332
+ }
333
+
334
+ const notificationDismissMatch = path.match(/^\/v1\/notifications\/(.+)\/dismiss$/);
335
+ if (notificationDismissMatch && method === 'POST') {
336
+ const notificationId = decodeURIComponent(notificationDismissMatch[1]);
337
+ const result = await handleDismissNotification(tenantId, notificationId, ddb, TABLE_NAME);
338
+ return response(result.statusCode, result.body, result.headers);
339
+ }
340
+
341
+ // Shared projects
342
+ if (path === '/v1/projects/available' && method === 'GET') {
343
+ const result = await handleListAvailableProjects(tenantId, ddb, TABLE_NAME);
344
+ return response(result.statusCode, result.body, result.headers);
345
+ }
346
+
347
+ // Export
348
+ if (path === '/v1/export' && method === 'GET') {
349
+ const result = await handleExport(tenantId, ddb, TABLE_NAME);
350
+ return response(result.statusCode, result.body, result.headers);
351
+ }
352
+
353
+ // Not found
354
+ return response(404, JSON.stringify({ error: 'not_found', message: `No route for ${method} ${path}` }), {
355
+ 'Content-Type': 'application/json',
356
+ });
357
+ } catch (err: unknown) {
358
+ const durationMs = Date.now() - startTime;
359
+
360
+ if (err instanceof AuthError) {
361
+ logger.warn('Auth failed', { requestId, durationMs, error: err.message });
362
+ return response(err.statusCode, JSON.stringify({ error: 'auth_error', message: err.message }), {
363
+ 'Content-Type': 'application/json',
364
+ });
365
+ }
366
+
367
+ logger.error('Unhandled error', { requestId, durationMs, error: String(err) });
368
+ return response(500, JSON.stringify({ error: 'internal_error', message: 'Internal server error' }), {
369
+ 'Content-Type': 'application/json',
370
+ });
371
+ }
372
+ };
@@ -0,0 +1,16 @@
1
+ export interface LogFields {
2
+ tenantId?: string;
3
+ operation?: string;
4
+ blobCount?: number;
5
+ durationMs?: number;
6
+ requestId?: string;
7
+ [key: string]: unknown;
8
+ }
9
+ export declare class Logger {
10
+ private log;
11
+ info(message: string, fields?: LogFields): void;
12
+ warn(message: string, fields?: LogFields): void;
13
+ error(message: string, fields?: LogFields): void;
14
+ }
15
+ export declare const logger: Logger;
16
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../../lib/handler/logger.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAID,qBAAa,MAAM;IACjB,OAAO,CAAC,GAAG;IAUX,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI;IAI/C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI;IAI/C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,IAAI;CAGjD;AAED,eAAO,MAAM,MAAM,QAAe,CAAC"}
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logger = exports.Logger = void 0;
4
+ class Logger {
5
+ log(level, message, fields) {
6
+ const entry = {
7
+ timestamp: new Date().toISOString(),
8
+ level,
9
+ message,
10
+ ...fields,
11
+ };
12
+ console.log(JSON.stringify(entry));
13
+ }
14
+ info(message, fields) {
15
+ this.log('INFO', message, fields);
16
+ }
17
+ warn(message, fields) {
18
+ this.log('WARN', message, fields);
19
+ }
20
+ error(message, fields) {
21
+ this.log('ERROR', message, fields);
22
+ }
23
+ }
24
+ exports.Logger = Logger;
25
+ exports.logger = new Logger();
26
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../../../lib/handler/logger.ts"],"names":[],"mappings":";;;AAWA,MAAa,MAAM;IACT,GAAG,CAAC,KAAe,EAAE,OAAe,EAAE,MAAkB;QAC9D,MAAM,KAAK,GAAG;YACZ,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;YACnC,KAAK;YACL,OAAO;YACP,GAAG,MAAM;SACV,CAAC;QACF,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;IACrC,CAAC;IAED,IAAI,CAAC,OAAe,EAAE,MAAkB;QACtC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IAED,IAAI,CAAC,OAAe,EAAE,MAAkB;QACtC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IAED,KAAK,CAAC,OAAe,EAAE,MAAkB;QACvC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IACrC,CAAC;CACF;AAtBD,wBAsBC;AAEY,QAAA,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC"}
@@ -0,0 +1,36 @@
1
+ export interface LogFields {
2
+ tenantId?: string;
3
+ operation?: string;
4
+ blobCount?: number;
5
+ durationMs?: number;
6
+ requestId?: string;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ type LogLevel = 'INFO' | 'WARN' | 'ERROR';
11
+
12
+ export class Logger {
13
+ private log(level: LogLevel, message: string, fields?: LogFields): void {
14
+ const entry = {
15
+ timestamp: new Date().toISOString(),
16
+ level,
17
+ message,
18
+ ...fields,
19
+ };
20
+ console.log(JSON.stringify(entry));
21
+ }
22
+
23
+ info(message: string, fields?: LogFields): void {
24
+ this.log('INFO', message, fields);
25
+ }
26
+
27
+ warn(message: string, fields?: LogFields): void {
28
+ this.log('WARN', message, fields);
29
+ }
30
+
31
+ error(message: string, fields?: LogFields): void {
32
+ this.log('ERROR', message, fields);
33
+ }
34
+ }
35
+
36
+ export const logger = new Logger();
@@ -0,0 +1,6 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ error?: string;
4
+ }
5
+ export declare function validateBlobUpload(body: Buffer, contentType: string): ValidationResult;
6
+ //# sourceMappingURL=input-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-validation.d.ts","sourceRoot":"","sources":["../../../../lib/handler/middleware/input-validation.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAKD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,gBAAgB,CAmCtF"}
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateBlobUpload = validateBlobUpload;
4
+ const MAX_BLOB_SIZE = 1_048_576; // 1 MB
5
+ const MIN_BLOB_SIZE = 1;
6
+ function validateBlobUpload(body, contentType) {
7
+ if (contentType !== 'application/octet-stream') {
8
+ return { valid: false, error: 'Content-Type must be application/octet-stream' };
9
+ }
10
+ if (body.length < MIN_BLOB_SIZE) {
11
+ return { valid: false, error: 'Blob must be at least 1 byte' };
12
+ }
13
+ if (body.length > MAX_BLOB_SIZE) {
14
+ return { valid: false, error: `Blob exceeds maximum size of ${MAX_BLOB_SIZE} bytes` };
15
+ }
16
+ // Parse as JSON and check envelope structure
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(body.toString('utf-8'));
20
+ }
21
+ catch {
22
+ return { valid: false, error: 'Blob must be valid JSON' };
23
+ }
24
+ if (typeof parsed !== 'object' || parsed === null) {
25
+ return { valid: false, error: 'Blob must be a JSON object' };
26
+ }
27
+ const envelope = parsed;
28
+ if (!('v' in envelope)) {
29
+ return { valid: false, error: 'Missing required field: v' };
30
+ }
31
+ if (envelope['v'] !== 1) {
32
+ return { valid: false, error: `Unsupported envelope version: ${envelope['v']}` };
33
+ }
34
+ return { valid: true };
35
+ }
36
+ //# sourceMappingURL=input-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-validation.js","sourceRoot":"","sources":["../../../../lib/handler/middleware/input-validation.ts"],"names":[],"mappings":";;AAQA,gDAmCC;AAtCD,MAAM,aAAa,GAAG,SAAS,CAAC,CAAC,OAAO;AACxC,MAAM,aAAa,GAAG,CAAC,CAAC;AAExB,SAAgB,kBAAkB,CAAC,IAAY,EAAE,WAAmB;IAClE,IAAI,WAAW,KAAK,0BAA0B,EAAE,CAAC;QAC/C,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,+CAA+C,EAAE,CAAC;IAClF,CAAC;IAED,IAAI,IAAI,CAAC,MAAM,GAAG,aAAa,EAAE,CAAC;QAChC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAC;IACjE,CAAC;IAED,IAAI,IAAI,CAAC,MAAM,GAAG,aAAa,EAAE,CAAC;QAChC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,gCAAgC,aAAa,QAAQ,EAAE,CAAC;IACxF,CAAC;IAED,6CAA6C;IAC7C,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC;IAC5D,CAAC;IAED,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QAClD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,4BAA4B,EAAE,CAAC;IAC/D,CAAC;IAED,MAAM,QAAQ,GAAG,MAAiC,CAAC;IACnD,IAAI,CAAC,CAAC,GAAG,IAAI,QAAQ,CAAC,EAAE,CAAC;QACvB,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,2BAA2B,EAAE,CAAC;IAC9D,CAAC;IAED,IAAI,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,iCAAiC,QAAQ,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;IACnF,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACzB,CAAC"}
@@ -0,0 +1,44 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ error?: string;
4
+ }
5
+
6
+ const MAX_BLOB_SIZE = 1_048_576; // 1 MB
7
+ const MIN_BLOB_SIZE = 1;
8
+
9
+ export function validateBlobUpload(body: Buffer, contentType: string): ValidationResult {
10
+ if (contentType !== 'application/octet-stream') {
11
+ return { valid: false, error: 'Content-Type must be application/octet-stream' };
12
+ }
13
+
14
+ if (body.length < MIN_BLOB_SIZE) {
15
+ return { valid: false, error: 'Blob must be at least 1 byte' };
16
+ }
17
+
18
+ if (body.length > MAX_BLOB_SIZE) {
19
+ return { valid: false, error: `Blob exceeds maximum size of ${MAX_BLOB_SIZE} bytes` };
20
+ }
21
+
22
+ // Parse as JSON and check envelope structure
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(body.toString('utf-8'));
26
+ } catch {
27
+ return { valid: false, error: 'Blob must be valid JSON' };
28
+ }
29
+
30
+ if (typeof parsed !== 'object' || parsed === null) {
31
+ return { valid: false, error: 'Blob must be a JSON object' };
32
+ }
33
+
34
+ const envelope = parsed as Record<string, unknown>;
35
+ if (!('v' in envelope)) {
36
+ return { valid: false, error: 'Missing required field: v' };
37
+ }
38
+
39
+ if (envelope['v'] !== 1) {
40
+ return { valid: false, error: `Unsupported envelope version: ${envelope['v']}` };
41
+ }
42
+
43
+ return { valid: true };
44
+ }
@@ -0,0 +1,14 @@
1
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
+ export interface RateLimitResult {
3
+ allowed: boolean;
4
+ remaining: number;
5
+ retryAfter?: number;
6
+ }
7
+ export declare function checkRateLimit(tenantId: string, operation: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<RateLimitResult>;
8
+ /**
9
+ * Rate limit by source IP for unauthenticated endpoints (registration, contact).
10
+ * Default: 1 request per second per IP. LINK_CONFIRM: 1 request per 5 seconds.
11
+ */
12
+ export declare function checkIpRateLimit(sourceIp: string, operation: string, ddb: DynamoDBDocumentClient, tableName: string, limit?: number): Promise<RateLimitResult>;
13
+ export declare function rateLimitHeaders(result: RateLimitResult): Record<string, string>;
14
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../../lib/handler/middleware/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAiB,MAAM,uBAAuB,CAAC;AAE9E,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAWD,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAqC1B;AAOD;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,EACjB,KAAK,SAAI,GACR,OAAO,CAAC,eAAe,CAAC,CAkC1B;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQhF"}
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkRateLimit = checkRateLimit;
4
+ exports.checkIpRateLimit = checkIpRateLimit;
5
+ exports.rateLimitHeaders = rateLimitHeaders;
6
+ const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
7
+ const LIMITS = {
8
+ PUT: 100,
9
+ GET: 1000,
10
+ DELETE: 100,
11
+ LIST: 100,
12
+ };
13
+ const WINDOW_SECONDS = 60;
14
+ async function checkRateLimit(tenantId, operation, ddb, tableName) {
15
+ const limit = LIMITS[operation] ?? 300;
16
+ const now = Math.floor(Date.now() / 1000);
17
+ const windowKey = Math.floor(now / WINDOW_SECONDS);
18
+ const ttl = windowKey * WINDOW_SECONDS + WINDOW_SECONDS + 120; // window + 2 min buffer
19
+ const result = await ddb.send(new lib_dynamodb_1.UpdateCommand({
20
+ TableName: tableName,
21
+ Key: {
22
+ PK: `RATE#${tenantId}`,
23
+ SK: `${operation}#${windowKey}`,
24
+ },
25
+ UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :one, #ttl = :ttl',
26
+ ExpressionAttributeNames: {
27
+ '#count': 'count',
28
+ '#ttl': 'ttl',
29
+ },
30
+ ExpressionAttributeValues: {
31
+ ':zero': 0,
32
+ ':one': 1,
33
+ ':ttl': ttl,
34
+ },
35
+ ReturnValues: 'UPDATED_NEW',
36
+ }));
37
+ const currentCount = result.Attributes?.['count'] ?? 1;
38
+ const remaining = Math.max(0, limit - currentCount);
39
+ if (currentCount > limit) {
40
+ const windowEnd = (windowKey + 1) * WINDOW_SECONDS;
41
+ const retryAfter = Math.max(1, windowEnd - now);
42
+ return { allowed: false, remaining: 0, retryAfter };
43
+ }
44
+ return { allowed: true, remaining };
45
+ }
46
+ // Per-IP window sizes: LINK_CONFIRM uses 5-second windows, others use 1-second
47
+ const IP_WINDOW_SECONDS = {
48
+ LINK_CONFIRM: 5,
49
+ };
50
+ /**
51
+ * Rate limit by source IP for unauthenticated endpoints (registration, contact).
52
+ * Default: 1 request per second per IP. LINK_CONFIRM: 1 request per 5 seconds.
53
+ */
54
+ async function checkIpRateLimit(sourceIp, operation, ddb, tableName, limit = 1) {
55
+ const now = Math.floor(Date.now() / 1000);
56
+ const windowSec = IP_WINDOW_SECONDS[operation] ?? 1;
57
+ const windowKey = Math.floor(now / windowSec);
58
+ const ttl = now + 120;
59
+ const result = await ddb.send(new lib_dynamodb_1.UpdateCommand({
60
+ TableName: tableName,
61
+ Key: {
62
+ PK: `RATE#IP#${sourceIp}`,
63
+ SK: `${operation}#${windowKey}`,
64
+ },
65
+ UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :one, #ttl = :ttl',
66
+ ExpressionAttributeNames: {
67
+ '#count': 'count',
68
+ '#ttl': 'ttl',
69
+ },
70
+ ExpressionAttributeValues: {
71
+ ':zero': 0,
72
+ ':one': 1,
73
+ ':ttl': ttl,
74
+ },
75
+ ReturnValues: 'UPDATED_NEW',
76
+ }));
77
+ const currentCount = result.Attributes?.['count'] ?? 1;
78
+ if (currentCount > limit) {
79
+ const windowEnd = (windowKey + 1) * windowSec;
80
+ const retryAfter = Math.max(1, windowEnd - now);
81
+ return { allowed: false, remaining: 0, retryAfter };
82
+ }
83
+ return { allowed: true, remaining: Math.max(0, limit - currentCount) };
84
+ }
85
+ function rateLimitHeaders(result) {
86
+ const headers = {
87
+ 'X-RateLimit-Remaining': String(result.remaining),
88
+ };
89
+ if (!result.allowed && result.retryAfter !== undefined) {
90
+ headers['Retry-After'] = String(result.retryAfter);
91
+ }
92
+ return headers;
93
+ }
94
+ //# sourceMappingURL=rate-limit.js.map