@cosmicdrift/kumiko-bundled-features 0.171.0 → 0.171.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.2",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -119,12 +119,12 @@
|
|
|
119
119
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.171.
|
|
123
|
-
"@cosmicdrift/kumiko-framework": "0.171.
|
|
124
|
-
"@cosmicdrift/kumiko-headless": "0.171.
|
|
125
|
-
"@cosmicdrift/kumiko-renderer": "0.171.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer-web": "0.171.
|
|
127
|
-
"@cosmicdrift/kumiko-types": "0.171.
|
|
122
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.171.2",
|
|
123
|
+
"@cosmicdrift/kumiko-framework": "0.171.2",
|
|
124
|
+
"@cosmicdrift/kumiko-headless": "0.171.2",
|
|
125
|
+
"@cosmicdrift/kumiko-renderer": "0.171.2",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer-web": "0.171.2",
|
|
127
|
+
"@cosmicdrift/kumiko-types": "0.171.2",
|
|
128
128
|
"@mollie/api-client": "^4.5.0",
|
|
129
129
|
"imapflow": "^1.3.3",
|
|
130
130
|
"mailparser": "^3.9.8",
|
|
@@ -68,6 +68,23 @@ const appFeature = defineFeature("app", (r) => {
|
|
|
68
68
|
access: { openToAll: true },
|
|
69
69
|
}),
|
|
70
70
|
);
|
|
71
|
+
|
|
72
|
+
r.writeHandler(
|
|
73
|
+
defineWriteHandler({
|
|
74
|
+
name: "ping-direct",
|
|
75
|
+
schema: z.object({ email: z.string(), subjectId: z.string() }),
|
|
76
|
+
handler: async (event, ctx) => {
|
|
77
|
+
const notify = ctx.notify as NotifyFn;
|
|
78
|
+
await notify(qn("app", "notify", "pinged"), {
|
|
79
|
+
route: { email: event.payload.email },
|
|
80
|
+
recipientId: event.payload.subjectId,
|
|
81
|
+
data: { title: "Ping", body: "Du wurdest angepingt" },
|
|
82
|
+
});
|
|
83
|
+
return { isSuccess: true, data: { sent: true } };
|
|
84
|
+
},
|
|
85
|
+
access: { openToAll: true },
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
71
88
|
});
|
|
72
89
|
|
|
73
90
|
let stack: TestStack;
|
|
@@ -168,4 +185,64 @@ describe("delivery attempt log under KMS", () => {
|
|
|
168
185
|
);
|
|
169
186
|
expect(erased?.["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
170
187
|
});
|
|
188
|
+
|
|
189
|
+
// money-horse#330: route:{email} (no user account, e.g. a share-token
|
|
190
|
+
// recipient) carries recipientId so recipientAddress is encrypted under
|
|
191
|
+
// that subject too, not left plaintext.
|
|
192
|
+
test("route:{email} with recipientId ciphers recipientAddress under that subject; erasing it doesn't touch other subjects", async () => {
|
|
193
|
+
const directEmail = "share-recipient@test.com";
|
|
194
|
+
const subjectId = "share-token-330";
|
|
195
|
+
|
|
196
|
+
await stack.http.writeOk(
|
|
197
|
+
qn("app", "write", "ping-direct"),
|
|
198
|
+
{ email: directEmail, subjectId },
|
|
199
|
+
admin,
|
|
200
|
+
);
|
|
201
|
+
await pingRecipient();
|
|
202
|
+
|
|
203
|
+
const events = await selectMany(stack.db, eventsTable, { type: DELIVERY_ATTEMPT_EVENT });
|
|
204
|
+
const direct = events
|
|
205
|
+
.map((e) => e.payload as Record<string, unknown>)
|
|
206
|
+
.find((p) => p["channel"] === "email" && p["recipientId"] === subjectId);
|
|
207
|
+
expect(direct).toBeDefined();
|
|
208
|
+
expect(isPiiCiphertext(direct?.["recipientAddress"])).toBe(true);
|
|
209
|
+
expect(String(direct?.["recipientAddress"])).toContain(`user:${subjectId}`);
|
|
210
|
+
|
|
211
|
+
const rowsBefore = await selectMany(stack.db, deliveryAttemptsTable, {
|
|
212
|
+
recipientId: subjectId,
|
|
213
|
+
channel: "email",
|
|
214
|
+
});
|
|
215
|
+
expect(rowsBefore.length).toBeGreaterThan(0);
|
|
216
|
+
expect(isPiiCiphertext(rowsBefore[0]?.["recipientAddress"])).toBe(true);
|
|
217
|
+
|
|
218
|
+
// Snapshot the plain `to:` recipient's entry before erasing the
|
|
219
|
+
// share-token subject, to prove the erase below is subject-scoped.
|
|
220
|
+
const beforeErase = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
221
|
+
"delivery:query:log",
|
|
222
|
+
{ limit: 100 },
|
|
223
|
+
admin,
|
|
224
|
+
);
|
|
225
|
+
const otherSubjectBefore = beforeErase.rows.find(
|
|
226
|
+
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
227
|
+
)?.["recipientAddress"];
|
|
228
|
+
|
|
229
|
+
await kms.eraseKey({ kind: "user", userId: subjectId });
|
|
230
|
+
|
|
231
|
+
const after = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
232
|
+
"delivery:query:log",
|
|
233
|
+
{ limit: 100 },
|
|
234
|
+
admin,
|
|
235
|
+
);
|
|
236
|
+
const shredded = after.rows.find(
|
|
237
|
+
(r) => r["recipientId"] === subjectId && r["channel"] === "email",
|
|
238
|
+
);
|
|
239
|
+
expect(shredded?.["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
240
|
+
|
|
241
|
+
// Other subjects (e.g. the plain `to:` recipient) are unaffected by the
|
|
242
|
+
// share-token subject's erasure.
|
|
243
|
+
const otherSubjectAfter = after.rows.find(
|
|
244
|
+
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
245
|
+
)?.["recipientAddress"];
|
|
246
|
+
expect(otherSubjectAfter).toBe(otherSubjectBefore);
|
|
247
|
+
});
|
|
171
248
|
});
|
|
@@ -422,6 +422,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
422
422
|
data: Readonly<Record<string, unknown>> | undefined,
|
|
423
423
|
tenantId: TenantId,
|
|
424
424
|
priority: NotifyPriority,
|
|
425
|
+
recipientId: string | null,
|
|
425
426
|
): Promise<void> {
|
|
426
427
|
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
|
|
427
428
|
|
|
@@ -439,7 +440,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
439
440
|
tenantId,
|
|
440
441
|
notificationType,
|
|
441
442
|
channel: channel.name,
|
|
442
|
-
recipientId
|
|
443
|
+
recipientId,
|
|
443
444
|
recipientAddress: address,
|
|
444
445
|
status: "skipped",
|
|
445
446
|
error: "rate_limited",
|
|
@@ -456,7 +457,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
456
457
|
message,
|
|
457
458
|
channelCtx,
|
|
458
459
|
tenantId,
|
|
459
|
-
recipientId
|
|
460
|
+
recipientId,
|
|
460
461
|
notificationType,
|
|
461
462
|
priority,
|
|
462
463
|
});
|
|
@@ -465,7 +466,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
465
466
|
tenantId,
|
|
466
467
|
notificationType,
|
|
467
468
|
channel: channel.name,
|
|
468
|
-
recipientId
|
|
469
|
+
recipientId,
|
|
469
470
|
recipientAddress: address,
|
|
470
471
|
status: "failed",
|
|
471
472
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -499,7 +500,14 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
499
500
|
}
|
|
500
501
|
|
|
501
502
|
if (route) {
|
|
502
|
-
await deliverDirect(
|
|
503
|
+
await deliverDirect(
|
|
504
|
+
route,
|
|
505
|
+
notificationType,
|
|
506
|
+
data,
|
|
507
|
+
tenantId,
|
|
508
|
+
priority,
|
|
509
|
+
options.recipientId ?? null,
|
|
510
|
+
);
|
|
503
511
|
// skip: direct route delivered, no recipient resolution needed
|
|
504
512
|
return;
|
|
505
513
|
}
|