@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.
- package/dist/lib/admin-handler/index.d.ts +22 -0
- package/dist/lib/admin-handler/index.d.ts.map +1 -0
- package/dist/lib/admin-handler/index.js +92 -0
- package/dist/lib/admin-handler/index.js.map +1 -0
- package/dist/lib/admin-handler/index.ts +123 -0
- package/dist/lib/admin-handler/routes/metrics.d.ts +11 -0
- package/dist/lib/admin-handler/routes/metrics.d.ts.map +1 -0
- package/dist/lib/admin-handler/routes/metrics.js +200 -0
- package/dist/lib/admin-handler/routes/metrics.js.map +1 -0
- package/dist/lib/admin-handler/routes/metrics.ts +234 -0
- package/dist/lib/admin-handler/routes/overview.d.ts +9 -0
- package/dist/lib/admin-handler/routes/overview.d.ts.map +1 -0
- package/dist/lib/admin-handler/routes/overview.js +110 -0
- package/dist/lib/admin-handler/routes/overview.js.map +1 -0
- package/dist/lib/admin-handler/routes/overview.ts +133 -0
- package/dist/lib/admin-handler/routes/tenants.d.ts +10 -0
- package/dist/lib/admin-handler/routes/tenants.d.ts.map +1 -0
- package/dist/lib/admin-handler/routes/tenants.js +108 -0
- package/dist/lib/admin-handler/routes/tenants.js.map +1 -0
- package/dist/lib/admin-handler/routes/tenants.ts +134 -0
- package/dist/lib/chaoskb-stack.d.ts +22 -0
- package/dist/lib/chaoskb-stack.d.ts.map +1 -0
- package/dist/lib/chaoskb-stack.js +60 -0
- package/dist/lib/chaoskb-stack.js.map +1 -0
- package/dist/lib/constructs/admin-api.d.ts +16 -0
- package/dist/lib/constructs/admin-api.d.ts.map +1 -0
- package/dist/lib/constructs/admin-api.js +93 -0
- package/dist/lib/constructs/admin-api.js.map +1 -0
- package/dist/lib/constructs/admin-dashboard.d.ts +18 -0
- package/dist/lib/constructs/admin-dashboard.d.ts.map +1 -0
- package/dist/lib/constructs/admin-dashboard.js +172 -0
- package/dist/lib/constructs/admin-dashboard.js.map +1 -0
- package/dist/lib/constructs/api.d.ts +17 -0
- package/dist/lib/constructs/api.d.ts.map +1 -0
- package/dist/lib/constructs/api.js +81 -0
- package/dist/lib/constructs/api.js.map +1 -0
- package/dist/lib/constructs/auth.d.ts +11 -0
- package/dist/lib/constructs/auth.d.ts.map +1 -0
- package/dist/lib/constructs/auth.js +18 -0
- package/dist/lib/constructs/auth.js.map +1 -0
- package/dist/lib/constructs/blob-store.d.ts +10 -0
- package/dist/lib/constructs/blob-store.d.ts.map +1 -0
- package/dist/lib/constructs/blob-store.js +31 -0
- package/dist/lib/constructs/blob-store.js.map +1 -0
- package/dist/lib/deploy-cli.d.ts +3 -0
- package/dist/lib/deploy-cli.d.ts.map +1 -0
- package/dist/lib/deploy-cli.js +49 -0
- package/dist/lib/deploy-cli.js.map +1 -0
- package/dist/lib/handler/index.d.ts +23 -0
- package/dist/lib/handler/index.d.ts.map +1 -0
- package/dist/lib/handler/index.js +276 -0
- package/dist/lib/handler/index.js.map +1 -0
- package/dist/lib/handler/index.ts +372 -0
- package/dist/lib/handler/logger.d.ts +16 -0
- package/dist/lib/handler/logger.d.ts.map +1 -0
- package/dist/lib/handler/logger.js +26 -0
- package/dist/lib/handler/logger.js.map +1 -0
- package/dist/lib/handler/logger.ts +36 -0
- package/dist/lib/handler/middleware/input-validation.d.ts +6 -0
- package/dist/lib/handler/middleware/input-validation.d.ts.map +1 -0
- package/dist/lib/handler/middleware/input-validation.js +36 -0
- package/dist/lib/handler/middleware/input-validation.js.map +1 -0
- package/dist/lib/handler/middleware/input-validation.ts +44 -0
- package/dist/lib/handler/middleware/rate-limit.d.ts +14 -0
- package/dist/lib/handler/middleware/rate-limit.d.ts.map +1 -0
- package/dist/lib/handler/middleware/rate-limit.js +94 -0
- package/dist/lib/handler/middleware/rate-limit.js.map +1 -0
- package/dist/lib/handler/middleware/rate-limit.ts +121 -0
- package/dist/lib/handler/middleware/ssh-auth.d.ts +48 -0
- package/dist/lib/handler/middleware/ssh-auth.d.ts.map +1 -0
- package/dist/lib/handler/middleware/ssh-auth.js +256 -0
- package/dist/lib/handler/middleware/ssh-auth.js.map +1 -0
- package/dist/lib/handler/middleware/ssh-auth.ts +300 -0
- package/dist/lib/handler/routes/audit.d.ts +24 -0
- package/dist/lib/handler/routes/audit.d.ts.map +1 -0
- package/dist/lib/handler/routes/audit.js +94 -0
- package/dist/lib/handler/routes/audit.js.map +1 -0
- package/dist/lib/handler/routes/audit.ts +101 -0
- package/dist/lib/handler/routes/blobs.d.ts +13 -0
- package/dist/lib/handler/routes/blobs.d.ts.map +1 -0
- package/dist/lib/handler/routes/blobs.js +298 -0
- package/dist/lib/handler/routes/blobs.js.map +1 -0
- package/dist/lib/handler/routes/blobs.ts +348 -0
- package/dist/lib/handler/routes/devices.d.ts +48 -0
- package/dist/lib/handler/routes/devices.d.ts.map +1 -0
- package/dist/lib/handler/routes/devices.js +394 -0
- package/dist/lib/handler/routes/devices.js.map +1 -0
- package/dist/lib/handler/routes/devices.ts +458 -0
- package/dist/lib/handler/routes/export.d.ts +9 -0
- package/dist/lib/handler/routes/export.d.ts.map +1 -0
- package/dist/lib/handler/routes/export.js +40 -0
- package/dist/lib/handler/routes/export.js.map +1 -0
- package/dist/lib/handler/routes/export.ts +55 -0
- package/dist/lib/handler/routes/github.d.ts +31 -0
- package/dist/lib/handler/routes/github.d.ts.map +1 -0
- package/dist/lib/handler/routes/github.js +118 -0
- package/dist/lib/handler/routes/github.js.map +1 -0
- package/dist/lib/handler/routes/github.ts +162 -0
- package/dist/lib/handler/routes/health.d.ts +6 -0
- package/dist/lib/handler/routes/health.d.ts.map +1 -0
- package/dist/lib/handler/routes/health.js +14 -0
- package/dist/lib/handler/routes/health.js.map +1 -0
- package/dist/lib/handler/routes/health.ts +10 -0
- package/dist/lib/handler/routes/invites.d.ts +24 -0
- package/dist/lib/handler/routes/invites.d.ts.map +1 -0
- package/dist/lib/handler/routes/invites.js +445 -0
- package/dist/lib/handler/routes/invites.js.map +1 -0
- package/dist/lib/handler/routes/invites.ts +527 -0
- package/dist/lib/handler/routes/notifications.d.ts +39 -0
- package/dist/lib/handler/routes/notifications.d.ts.map +1 -0
- package/dist/lib/handler/routes/notifications.js +150 -0
- package/dist/lib/handler/routes/notifications.js.map +1 -0
- package/dist/lib/handler/routes/notifications.ts +163 -0
- package/dist/lib/handler/routes/projects.d.ts +24 -0
- package/dist/lib/handler/routes/projects.d.ts.map +1 -0
- package/dist/lib/handler/routes/projects.js +47 -0
- package/dist/lib/handler/routes/projects.js.map +1 -0
- package/dist/lib/handler/routes/projects.ts +69 -0
- package/dist/lib/handler/routes/register.d.ts +19 -0
- package/dist/lib/handler/routes/register.d.ts.map +1 -0
- package/dist/lib/handler/routes/register.js +327 -0
- package/dist/lib/handler/routes/register.js.map +1 -0
- package/dist/lib/handler/routes/register.ts +363 -0
- package/dist/lib/handler/routes/restore.d.ts +9 -0
- package/dist/lib/handler/routes/restore.d.ts.map +1 -0
- package/dist/lib/handler/routes/restore.js +52 -0
- package/dist/lib/handler/routes/restore.js.map +1 -0
- package/dist/lib/handler/routes/restore.ts +73 -0
- package/dist/lib/handler/routes/revocation.d.ts +13 -0
- package/dist/lib/handler/routes/revocation.d.ts.map +1 -0
- package/dist/lib/handler/routes/revocation.js +63 -0
- package/dist/lib/handler/routes/revocation.js.map +1 -0
- package/dist/lib/handler/routes/revocation.ts +87 -0
- package/dist/lib/handler/routes/rotation.d.ts +24 -0
- package/dist/lib/handler/routes/rotation.d.ts.map +1 -0
- package/dist/lib/handler/routes/rotation.js +291 -0
- package/dist/lib/handler/routes/rotation.js.map +1 -0
- package/dist/lib/handler/routes/rotation.ts +336 -0
- package/dist/lib/handler/routes/tenants.d.ts +11 -0
- package/dist/lib/handler/routes/tenants.d.ts.map +1 -0
- package/dist/lib/handler/routes/tenants.js +181 -0
- package/dist/lib/handler/routes/tenants.js.map +1 -0
- package/dist/lib/handler/routes/tenants.ts +198 -0
- package/dist/lib/handler/routes/wrapped-key.d.ts +21 -0
- package/dist/lib/handler/routes/wrapped-key.d.ts.map +1 -0
- package/dist/lib/handler/routes/wrapped-key.js +76 -0
- package/dist/lib/handler/routes/wrapped-key.js.map +1 -0
- package/dist/lib/handler/routes/wrapped-key.ts +108 -0
- package/dist/lib/index.d.ts +7 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +16 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/vitest.config.d.ts +3 -0
- package/dist/vitest.config.d.ts.map +1 -0
- package/dist/vitest.config.js +18 -0
- package/dist/vitest.config.js.map +1 -0
- 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"}
|