@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.0",
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.0",
123
- "@cosmicdrift/kumiko-framework": "0.171.0",
124
- "@cosmicdrift/kumiko-headless": "0.171.0",
125
- "@cosmicdrift/kumiko-renderer": "0.171.0",
126
- "@cosmicdrift/kumiko-renderer-web": "0.171.0",
127
- "@cosmicdrift/kumiko-types": "0.171.0",
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: null,
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: null,
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: null,
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(route, notificationType, data, tenantId, priority);
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
  }