@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,527 @@
1
+ import * as crypto from 'crypto';
2
+ import {
3
+ DynamoDBDocumentClient,
4
+ PutCommand,
5
+ GetCommand,
6
+ UpdateCommand,
7
+ QueryCommand,
8
+ } from '@aws-sdk/lib-dynamodb';
9
+ import { logger } from '../logger.js';
10
+ import { logAuditEvent } from './audit.js';
11
+
12
+ interface HandlerResponse {
13
+ statusCode: number;
14
+ body: string;
15
+ headers: Record<string, string>;
16
+ }
17
+
18
+ const JSON_HEADERS = { 'Content-Type': 'application/json' };
19
+
20
+ const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
21
+ const FOURTEEN_DAYS_SECONDS = 14 * 24 * 60 * 60;
22
+ const TWENTY_FOUR_HOURS_SECONDS = 24 * 60 * 60;
23
+
24
+ const HOUR_SECONDS = 3600;
25
+ const DAY_SECONDS = 86400;
26
+ const HOURLY_INVITE_LIMIT = 10;
27
+ const DAILY_INVITE_LIMIT = 50;
28
+ const MAX_PENDING_INVITES = 20;
29
+
30
+ /**
31
+ * Check sender rate limits for invite creation.
32
+ * Uses DynamoDB counters with hourly and daily windows.
33
+ */
34
+ async function checkInviteRateLimit(
35
+ senderFingerprint: string,
36
+ ddb: DynamoDBDocumentClient,
37
+ tableName: string,
38
+ ): Promise<{ allowed: boolean; message?: string }> {
39
+ const now = Math.floor(Date.now() / 1000);
40
+ const hourWindow = Math.floor(now / HOUR_SECONDS);
41
+ const dayWindow = Math.floor(now / DAY_SECONDS);
42
+
43
+ // Check hourly limit
44
+ const hourResult = await ddb.send(
45
+ new UpdateCommand({
46
+ TableName: tableName,
47
+ Key: {
48
+ PK: `RATE#INVITE#${senderFingerprint}`,
49
+ SK: `HOUR#${hourWindow}`,
50
+ },
51
+ UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :one, #ttl = :ttl',
52
+ ExpressionAttributeNames: {
53
+ '#count': 'count',
54
+ '#ttl': 'ttl',
55
+ },
56
+ ExpressionAttributeValues: {
57
+ ':zero': 0,
58
+ ':one': 1,
59
+ ':ttl': hourWindow * HOUR_SECONDS + HOUR_SECONDS + 120,
60
+ },
61
+ ReturnValues: 'UPDATED_NEW',
62
+ }),
63
+ );
64
+
65
+ const hourlyCount = (hourResult.Attributes?.['count'] as number) ?? 1;
66
+ if (hourlyCount > HOURLY_INVITE_LIMIT) {
67
+ return { allowed: false, message: 'Hourly invite limit exceeded (max 10/hour)' };
68
+ }
69
+
70
+ // Check daily limit
71
+ const dayResult = await ddb.send(
72
+ new UpdateCommand({
73
+ TableName: tableName,
74
+ Key: {
75
+ PK: `RATE#INVITE#${senderFingerprint}`,
76
+ SK: `DAY#${dayWindow}`,
77
+ },
78
+ UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :one, #ttl = :ttl',
79
+ ExpressionAttributeNames: {
80
+ '#count': 'count',
81
+ '#ttl': 'ttl',
82
+ },
83
+ ExpressionAttributeValues: {
84
+ ':zero': 0,
85
+ ':one': 1,
86
+ ':ttl': dayWindow * DAY_SECONDS + DAY_SECONDS + 120,
87
+ },
88
+ ReturnValues: 'UPDATED_NEW',
89
+ }),
90
+ );
91
+
92
+ const dailyCount = (dayResult.Attributes?.['count'] as number) ?? 1;
93
+ if (dailyCount > DAILY_INVITE_LIMIT) {
94
+ return { allowed: false, message: 'Daily invite limit exceeded (max 50/day)' };
95
+ }
96
+
97
+ return { allowed: true };
98
+ }
99
+
100
+ /**
101
+ * Check that the recipient doesn't have too many pending invites.
102
+ */
103
+ async function checkRecipientPendingCount(
104
+ recipientFingerprint: string,
105
+ ddb: DynamoDBDocumentClient,
106
+ tableName: string,
107
+ ): Promise<boolean> {
108
+ const result = await ddb.send(
109
+ new QueryCommand({
110
+ TableName: tableName,
111
+ IndexName: 'GSI-RecipientFingerprint',
112
+ KeyConditionExpression: 'recipientFingerprint = :fp',
113
+ FilterExpression: '#status = :pending',
114
+ ExpressionAttributeNames: {
115
+ '#status': 'status',
116
+ },
117
+ ExpressionAttributeValues: {
118
+ ':fp': recipientFingerprint,
119
+ ':pending': 'pending',
120
+ },
121
+ Select: 'COUNT',
122
+ }),
123
+ );
124
+
125
+ return (result.Count ?? 0) < MAX_PENDING_INVITES;
126
+ }
127
+
128
+ /**
129
+ * POST /v1/invites - Create an invite
130
+ */
131
+ export async function handleCreateInvite(
132
+ tenantId: string,
133
+ fingerprint: string,
134
+ body: string | null | undefined,
135
+ ddb: DynamoDBDocumentClient,
136
+ tableName: string,
137
+ ): Promise<HandlerResponse> {
138
+ if (!body) {
139
+ return {
140
+ statusCode: 400,
141
+ headers: JSON_HEADERS,
142
+ body: JSON.stringify({ error: 'invalid_request', message: 'Request body is required' }),
143
+ };
144
+ }
145
+
146
+ let parsed: {
147
+ recipientFingerprint: string;
148
+ projectTenantId: string;
149
+ encryptedPayload: string;
150
+ role: string;
151
+ };
152
+ try {
153
+ parsed = JSON.parse(body);
154
+ } catch {
155
+ return {
156
+ statusCode: 400,
157
+ headers: JSON_HEADERS,
158
+ body: JSON.stringify({ error: 'invalid_request', message: 'Invalid JSON body' }),
159
+ };
160
+ }
161
+
162
+ if (
163
+ !parsed.recipientFingerprint ||
164
+ !parsed.projectTenantId ||
165
+ !parsed.encryptedPayload ||
166
+ !parsed.role
167
+ ) {
168
+ return {
169
+ statusCode: 400,
170
+ headers: JSON_HEADERS,
171
+ body: JSON.stringify({
172
+ error: 'invalid_request',
173
+ message: 'recipientFingerprint, projectTenantId, encryptedPayload, and role are required',
174
+ }),
175
+ };
176
+ }
177
+
178
+ // Validate encryptedPayload is valid base64
179
+ if (!/^[A-Za-z0-9+/]+=*$/.test(parsed.encryptedPayload)) {
180
+ return {
181
+ statusCode: 400,
182
+ headers: JSON_HEADERS,
183
+ body: JSON.stringify({ error: 'invalid_request', message: 'encryptedPayload must be valid base64' }),
184
+ };
185
+ }
186
+
187
+ // Check sender rate limits
188
+ const rateCheck = await checkInviteRateLimit(fingerprint, ddb, tableName);
189
+ if (!rateCheck.allowed) {
190
+ return {
191
+ statusCode: 429,
192
+ headers: JSON_HEADERS,
193
+ body: JSON.stringify({ error: 'rate_limited', message: rateCheck.message }),
194
+ };
195
+ }
196
+
197
+ // Check recipient pending count
198
+ const recipientOk = await checkRecipientPendingCount(
199
+ parsed.recipientFingerprint,
200
+ ddb,
201
+ tableName,
202
+ );
203
+ if (!recipientOk) {
204
+ return {
205
+ statusCode: 409,
206
+ headers: JSON_HEADERS,
207
+ body: JSON.stringify({
208
+ error: 'recipient_limit',
209
+ message: 'Recipient has too many pending invites',
210
+ }),
211
+ };
212
+ }
213
+
214
+ const inviteId = crypto.randomUUID();
215
+ const now = new Date();
216
+ const createdAt = now.toISOString();
217
+ const expiresAt = new Date(now.getTime() + FOURTEEN_DAYS_MS).toISOString();
218
+ const ttl = Math.floor(now.getTime() / 1000) + FOURTEEN_DAYS_SECONDS;
219
+
220
+ await ddb.send(
221
+ new PutCommand({
222
+ TableName: tableName,
223
+ Item: {
224
+ PK: `INVITE#${inviteId}`,
225
+ SK: 'META',
226
+ inviteId,
227
+ status: 'pending',
228
+ senderFingerprint: fingerprint,
229
+ recipientFingerprint: parsed.recipientFingerprint,
230
+ projectTenantId: parsed.projectTenantId,
231
+ encryptedPayload: parsed.encryptedPayload,
232
+ role: parsed.role,
233
+ createdAt,
234
+ expiresAt,
235
+ ttl,
236
+ },
237
+ }),
238
+ );
239
+
240
+ await logAuditEvent(ddb, tableName, tenantId, {
241
+ eventType: 'invite-created' as any,
242
+ fingerprint,
243
+ metadata: {
244
+ inviteId,
245
+ recipientFingerprint: parsed.recipientFingerprint,
246
+ projectTenantId: parsed.projectTenantId,
247
+ role: parsed.role,
248
+ },
249
+ });
250
+
251
+ logger.info('Invite created', { tenantId, inviteId });
252
+
253
+ return {
254
+ statusCode: 201,
255
+ headers: JSON_HEADERS,
256
+ body: JSON.stringify({ inviteId, status: 'pending' }),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * GET /v1/invites - List pending invites for the authenticated user
262
+ */
263
+ export async function handleListInvites(
264
+ tenantId: string,
265
+ fingerprint: string,
266
+ ddb: DynamoDBDocumentClient,
267
+ tableName: string,
268
+ ): Promise<HandlerResponse> {
269
+ const now = new Date().toISOString();
270
+
271
+ const result = await ddb.send(
272
+ new QueryCommand({
273
+ TableName: tableName,
274
+ IndexName: 'GSI-RecipientFingerprint',
275
+ KeyConditionExpression: 'recipientFingerprint = :fp',
276
+ FilterExpression: '#status = :pending AND expiresAt > :now',
277
+ ExpressionAttributeNames: {
278
+ '#status': 'status',
279
+ },
280
+ ExpressionAttributeValues: {
281
+ ':fp': fingerprint,
282
+ ':pending': 'pending',
283
+ ':now': now,
284
+ },
285
+ }),
286
+ );
287
+
288
+ const invites = (result.Items ?? []).map((item) => ({
289
+ inviteId: item['inviteId'],
290
+ senderFingerprint: item['senderFingerprint'],
291
+ projectTenantId: item['projectTenantId'],
292
+ role: item['role'],
293
+ createdAt: item['createdAt'],
294
+ }));
295
+
296
+ return {
297
+ statusCode: 200,
298
+ headers: JSON_HEADERS,
299
+ body: JSON.stringify({ invites }),
300
+ };
301
+ }
302
+
303
+ /**
304
+ * POST /v1/invites/{id}/accept - Accept an invite
305
+ */
306
+ export async function handleAcceptInvite(
307
+ tenantId: string,
308
+ fingerprint: string,
309
+ inviteId: string,
310
+ ddb: DynamoDBDocumentClient,
311
+ tableName: string,
312
+ ): Promise<HandlerResponse> {
313
+ const result = await ddb.send(
314
+ new GetCommand({
315
+ TableName: tableName,
316
+ Key: {
317
+ PK: `INVITE#${inviteId}`,
318
+ SK: 'META',
319
+ },
320
+ }),
321
+ );
322
+
323
+ if (!result.Item) {
324
+ return {
325
+ statusCode: 404,
326
+ headers: JSON_HEADERS,
327
+ body: JSON.stringify({ error: 'not_found', message: 'Invite not found' }),
328
+ };
329
+ }
330
+
331
+ const invite = result.Item;
332
+
333
+ // Check recipient matches
334
+ if (invite['recipientFingerprint'] !== fingerprint) {
335
+ return {
336
+ statusCode: 403,
337
+ headers: JSON_HEADERS,
338
+ body: JSON.stringify({ error: 'forbidden', message: 'Not authorized to accept this invite' }),
339
+ };
340
+ }
341
+
342
+ // Idempotent: already accepted
343
+ if (invite['status'] === 'accepted') {
344
+ return {
345
+ statusCode: 200,
346
+ headers: JSON_HEADERS,
347
+ body: JSON.stringify({
348
+ status: 'already_accepted',
349
+ encryptedPayload: invite['encryptedPayload'],
350
+ projectTenantId: invite['projectTenantId'],
351
+ }),
352
+ };
353
+ }
354
+
355
+ // Check status is pending
356
+ if (invite['status'] !== 'pending') {
357
+ return {
358
+ statusCode: 409,
359
+ headers: JSON_HEADERS,
360
+ body: JSON.stringify({ error: 'conflict', message: `Invite is ${invite['status']}` }),
361
+ };
362
+ }
363
+
364
+ // Check not expired
365
+ if (new Date(invite['expiresAt'] as string) < new Date()) {
366
+ return {
367
+ statusCode: 410,
368
+ headers: JSON_HEADERS,
369
+ body: JSON.stringify({ error: 'expired', message: 'Invite has expired' }),
370
+ };
371
+ }
372
+
373
+ const now = new Date();
374
+ const acceptedAt = now.toISOString();
375
+ // Schedule cleanup: TTL 24 hours after acceptance
376
+ const cleanupTtl = Math.floor(now.getTime() / 1000) + TWENTY_FOUR_HOURS_SECONDS;
377
+
378
+ await ddb.send(
379
+ new UpdateCommand({
380
+ TableName: tableName,
381
+ Key: {
382
+ PK: `INVITE#${inviteId}`,
383
+ SK: 'META',
384
+ },
385
+ UpdateExpression: 'SET #status = :accepted, acceptedAt = :acceptedAt, #ttl = :ttl',
386
+ ExpressionAttributeNames: {
387
+ '#status': 'status',
388
+ '#ttl': 'ttl',
389
+ },
390
+ ExpressionAttributeValues: {
391
+ ':accepted': 'accepted',
392
+ ':acceptedAt': acceptedAt,
393
+ ':ttl': cleanupTtl,
394
+ },
395
+ }),
396
+ );
397
+
398
+ await logAuditEvent(ddb, tableName, tenantId, {
399
+ eventType: 'invite-accepted' as any,
400
+ fingerprint,
401
+ metadata: {
402
+ inviteId,
403
+ projectTenantId: invite['projectTenantId'],
404
+ },
405
+ });
406
+
407
+ logger.info('Invite accepted', { tenantId, inviteId });
408
+
409
+ return {
410
+ statusCode: 200,
411
+ headers: JSON_HEADERS,
412
+ body: JSON.stringify({
413
+ status: 'accepted',
414
+ encryptedPayload: invite['encryptedPayload'],
415
+ projectTenantId: invite['projectTenantId'],
416
+ }),
417
+ };
418
+ }
419
+
420
+ /**
421
+ * POST /v1/invites/{id}/decline - Decline an invite
422
+ */
423
+ export async function handleDeclineInvite(
424
+ tenantId: string,
425
+ fingerprint: string,
426
+ inviteId: string,
427
+ body: string | null | undefined,
428
+ ddb: DynamoDBDocumentClient,
429
+ tableName: string,
430
+ ): Promise<HandlerResponse> {
431
+ const result = await ddb.send(
432
+ new GetCommand({
433
+ TableName: tableName,
434
+ Key: {
435
+ PK: `INVITE#${inviteId}`,
436
+ SK: 'META',
437
+ },
438
+ }),
439
+ );
440
+
441
+ if (!result.Item) {
442
+ return {
443
+ statusCode: 404,
444
+ headers: JSON_HEADERS,
445
+ body: JSON.stringify({ error: 'not_found', message: 'Invite not found' }),
446
+ };
447
+ }
448
+
449
+ const invite = result.Item;
450
+
451
+ // Check recipient matches
452
+ if (invite['recipientFingerprint'] !== fingerprint) {
453
+ return {
454
+ statusCode: 403,
455
+ headers: JSON_HEADERS,
456
+ body: JSON.stringify({ error: 'forbidden', message: 'Not authorized to decline this invite' }),
457
+ };
458
+ }
459
+
460
+ // Check status is pending
461
+ if (invite['status'] !== 'pending') {
462
+ return {
463
+ statusCode: 409,
464
+ headers: JSON_HEADERS,
465
+ body: JSON.stringify({ error: 'conflict', message: `Invite is already ${invite['status']}` }),
466
+ };
467
+ }
468
+
469
+ await ddb.send(
470
+ new UpdateCommand({
471
+ TableName: tableName,
472
+ Key: {
473
+ PK: `INVITE#${inviteId}`,
474
+ SK: 'META',
475
+ },
476
+ UpdateExpression: 'SET #status = :declined, declinedAt = :declinedAt',
477
+ ExpressionAttributeNames: {
478
+ '#status': 'status',
479
+ },
480
+ ExpressionAttributeValues: {
481
+ ':declined': 'declined',
482
+ ':declinedAt': new Date().toISOString(),
483
+ },
484
+ }),
485
+ );
486
+
487
+ // Handle optional block
488
+ let parsed: { block?: boolean; senderFingerprint?: string } = {};
489
+ if (body) {
490
+ try {
491
+ parsed = JSON.parse(body);
492
+ } catch {
493
+ // Ignore invalid JSON in decline body — blocking is optional
494
+ }
495
+ }
496
+
497
+ if (parsed.block && parsed.senderFingerprint) {
498
+ await ddb.send(
499
+ new PutCommand({
500
+ TableName: tableName,
501
+ Item: {
502
+ PK: `TENANT#${tenantId}`,
503
+ SK: `BLOCK#${parsed.senderFingerprint}`,
504
+ blockedAt: new Date().toISOString(),
505
+ },
506
+ }),
507
+ );
508
+ logger.info('Sender blocked', { tenantId, senderFingerprint: parsed.senderFingerprint });
509
+ }
510
+
511
+ await logAuditEvent(ddb, tableName, tenantId, {
512
+ eventType: 'invite-declined' as any,
513
+ fingerprint,
514
+ metadata: {
515
+ inviteId,
516
+ blocked: parsed.block ?? false,
517
+ },
518
+ });
519
+
520
+ logger.info('Invite declined', { tenantId, inviteId });
521
+
522
+ return {
523
+ statusCode: 200,
524
+ headers: JSON_HEADERS,
525
+ body: JSON.stringify({ status: 'declined' }),
526
+ };
527
+ }
@@ -0,0 +1,39 @@
1
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
+ interface HandlerResponse {
3
+ statusCode: number;
4
+ body: string;
5
+ headers: Record<string, string>;
6
+ }
7
+ export type NotificationType = 'device_linked' | 'device_revoked' | 'key_rotated';
8
+ export interface DeviceInfo {
9
+ hostname?: string;
10
+ platform?: string;
11
+ arch?: string;
12
+ osVersion?: string;
13
+ deviceModel?: string | null;
14
+ location?: string | null;
15
+ }
16
+ /**
17
+ * Create a notification for a tenant.
18
+ *
19
+ * DynamoDB: PK: TENANT#{tenantId}, SK: NOTIFICATION#{timestamp}#{suffix}
20
+ */
21
+ export declare function createNotification(tenantId: string, type: NotificationType, deviceInfo: DeviceInfo, ddb: DynamoDBDocumentClient, tableName: string): Promise<void>;
22
+ /**
23
+ * GET /v1/notifications — return unacknowledged notifications for tenant.
24
+ */
25
+ export declare function handleGetNotifications(tenantId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
26
+ /**
27
+ * POST /v1/notifications/{id}/dismiss — mark notification as acknowledged.
28
+ */
29
+ export declare function handleDismissNotification(tenantId: string, notificationId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
30
+ /**
31
+ * G3: Resolve IP address to approximate location.
32
+ *
33
+ * For Lambda, use a lightweight approach: extract country/region from
34
+ * CloudFront headers if available, otherwise return null.
35
+ * A full MaxMind integration can be added later.
36
+ */
37
+ export declare function resolveIpLocation(headers: Record<string, string>): string | null;
38
+ export {};
39
+ //# sourceMappingURL=notifications.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications.d.ts","sourceRoot":"","sources":["../../../../lib/handler/routes/notifications.ts"],"names":[],"mappings":"AACA,OAAO,EACL,sBAAsB,EAIvB,MAAM,uBAAuB,CAAC;AAG/B,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAKD,MAAM,MAAM,gBAAgB,GAAG,eAAe,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,gBAAgB,EACtB,UAAU,EAAE,UAAU,EACtB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAqBf;AAED;;GAEG;AACH,wBAAsB,sBAAsB,CAC1C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CA4B1B;AAED;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,EACtB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAgC1B;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAahF"}