@cosmicdrift/kumiko-samples 0.381.0 → 0.383.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 (25) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +73 -27
  5. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  6. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  7. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +37 -9
  8. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +9 -7
  9. package/packages/bundled-features/src/delivery/attempt-log.ts +22 -1
  10. package/packages/bundled-features/src/delivery/changes.json +6 -0
  11. package/packages/bundled-features/src/delivery/delivery-service.ts +12 -3
  12. package/packages/bundled-features/src/delivery/feature.ts +12 -9
  13. package/packages/bundled-features/src/delivery/mask-recipient-address.ts +14 -0
  14. package/packages/bundled-features/src/jobs/changes.json +7 -0
  15. package/packages/bundled-features/src/jobs/job-run-logger.ts +34 -1
  16. package/packages/bundled-features/src/sessions/changes.json +6 -0
  17. package/packages/bundled-features/src/sessions/session-callbacks.ts +34 -20
  18. package/packages/bundled-features/src/tenant/changes.json +6 -0
  19. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +25 -0
  20. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +12 -8
  21. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +7 -1
  22. package/samples/recipes/api-worker-split/README.md +8 -0
  23. package/samples/recipes/api-worker-split/src/feature.ts +10 -0
  24. package/samples/recipes/record-detail-layout/README.md +2 -2
  25. package/samples/recipes/record-detail-layout/src/feature.ts +8 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.381.0",
3
+ "version": "0.383.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.381.0",
3
+ "version": "0.383.0",
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>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "improvement",
5
+ "title": "Escape-hatch retention loads only needed columns, in keyset pages",
6
+ "detail": "The retention run groups tenants only over events older than the shortest cutoff, selects id, aggregateId, createdAt and payload, and prunes per 2000-row keyset page instead of loading every row with its full payload."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "improvement",
@@ -76,8 +76,10 @@ async function resolveExplicitRetentionDays(args: {
76
76
  const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
77
77
  const PRUNE_CHUNK_SIZE = 1000;
78
78
  const PRUNE_BATCH_SIZE = 5000;
79
+ const SCAN_PAGE_SIZE = 2000;
79
80
 
80
81
  type AuditEventRow = {
82
+ readonly id: bigint;
81
83
  readonly aggregateId: string;
82
84
  readonly tenantId: TenantId;
83
85
  readonly createdAt: Temporal.Instant;
@@ -109,12 +111,25 @@ function targetTenantOf(payload: unknown): TenantId | null {
109
111
  return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
110
112
  }
111
113
 
112
- async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]> {
114
+ // No tenant's cutoff can be later than the shortest built-in retention: overrides only lengthen it.
115
+ function latestPossibleCutoff(now: Temporal.Instant): Temporal.Instant {
116
+ const cutoffs = Object.values(COMPLIANCE_PROFILES).map((profile) =>
117
+ subtractRetentionSpec(now, profile.auditLog.retention),
118
+ );
119
+ return cutoffs.reduce((latest, cutoff) =>
120
+ Temporal.Instant.compare(cutoff, latest) > 0 ? cutoff : latest,
121
+ );
122
+ }
123
+
124
+ async function storingTenantIds(
125
+ db: DbConnection,
126
+ expirableBefore: Temporal.Instant,
127
+ ): Promise<readonly TenantId[]> {
113
128
  const groups = await aggregateWhere(
114
129
  db,
115
130
  eventsTable,
116
131
  { measure: { fn: "count" }, groupBy: [{ field: "tenantId" }] },
117
- { aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE },
132
+ { aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE, createdAt: { lt: expirableBefore } },
118
133
  );
119
134
  return groups.flatMap((group) => {
120
135
  const tenantId = parseTenantId(group.keys[0]);
@@ -122,9 +137,24 @@ async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]>
122
137
  });
123
138
  }
124
139
 
140
+ async function pruneAggregates(db: DbConnection, aggregateIds: readonly string[]): Promise<number> {
141
+ const now = getTemporal().Now.instant();
142
+ let deletedCount = 0;
143
+ for (let i = 0; i < aggregateIds.length; i += PRUNE_CHUNK_SIZE) {
144
+ const result = await pruneEvents(db, {
145
+ aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
146
+ aggregateIds: aggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
147
+ olderThan: now,
148
+ });
149
+ deletedCount += result.deletedCount;
150
+ }
151
+ return deletedCount;
152
+ }
153
+
125
154
  async function pruneByTenantProfiles(
126
155
  db: DbConnection,
127
156
  operatorRetentionDays: number | undefined,
157
+ scanPageSize: number,
128
158
  ): Promise<number> {
129
159
  const now = getTemporal().Now.instant();
130
160
  const operatorFloor =
@@ -142,34 +172,43 @@ async function pruneByTenantProfiles(
142
172
 
143
173
  // The effective cutoff is never later than the storing tenant's, so only rows older than
144
174
  // that one can expire; everything younger stays in the database.
145
- const expiredAggregateIds: string[] = [];
146
- for (const storingTenantId of await storingTenantIds(db)) {
175
+ let deletedCount = 0;
176
+ for (const storingTenantId of await storingTenantIds(db, latestPossibleCutoff(now))) {
147
177
  const storeCutoff = await cutoffOf(storingTenantId);
148
- const candidates = await selectMany<AuditEventRow>(db, eventsTable, {
149
- aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
150
- tenantId: storingTenantId,
151
- createdAt: { lt: storeCutoff },
152
- });
153
- for (const row of candidates) {
154
- const targetTenantId = targetTenantOf(row.payload);
155
- const cutoff = targetTenantId
156
- ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
157
- : storeCutoff;
158
- if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
159
- expiredAggregateIds.push(row.aggregateId);
178
+ // Keyset on id: pruning a page only removes rows at or below the cursor.
179
+ let afterId: bigint | undefined;
180
+ for (;;) {
181
+ const page = await selectMany<AuditEventRow>(
182
+ db,
183
+ eventsTable,
184
+ {
185
+ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
186
+ tenantId: storingTenantId,
187
+ createdAt: { lt: storeCutoff },
188
+ ...(afterId !== undefined && { id: { gt: afterId } }),
189
+ },
190
+ {
191
+ fields: ["id", "aggregateId", "createdAt", "payload"],
192
+ orderBy: { col: "id", direction: "asc" },
193
+ limit: scanPageSize,
194
+ },
195
+ );
196
+ const expiredAggregateIds: string[] = [];
197
+ for (const row of page) {
198
+ const targetTenantId = targetTenantOf(row.payload);
199
+ const cutoff = targetTenantId
200
+ ? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
201
+ : storeCutoff;
202
+ if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
203
+ expiredAggregateIds.push(row.aggregateId);
204
+ }
160
205
  }
206
+ deletedCount += await pruneAggregates(db, expiredAggregateIds);
207
+ const lastRow = page[page.length - 1];
208
+ if (lastRow === undefined || page.length < scanPageSize) break;
209
+ afterId = lastRow.id;
161
210
  }
162
211
  }
163
-
164
- let deletedCount = 0;
165
- for (let i = 0; i < expiredAggregateIds.length; i += PRUNE_CHUNK_SIZE) {
166
- const result = await pruneEvents(db, {
167
- aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
168
- aggregateIds: expiredAggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
169
- olderThan: now,
170
- });
171
- deletedCount += result.deletedCount;
172
- }
173
212
  return deletedCount;
174
213
  }
175
214
 
@@ -199,6 +238,7 @@ export async function runEscapeHatchRetention(args: {
199
238
  readonly configResolver: ConfigResolver | undefined;
200
239
  readonly userId: string;
201
240
  readonly batchSize?: number;
241
+ readonly scanPageSize?: number;
202
242
  }): Promise<{ readonly deletedCount: number }> {
203
243
  const { db } = args;
204
244
  if (!isDbConnection(db)) {
@@ -211,5 +251,11 @@ export async function runEscapeHatchRetention(args: {
211
251
  }
212
252
 
213
253
  const operatorRetentionDays = await resolveExplicitRetentionDays({ ...args, db });
214
- return { deletedCount: await pruneByTenantProfiles(db, operatorRetentionDays) };
254
+ return {
255
+ deletedCount: await pruneByTenantProfiles(
256
+ db,
257
+ operatorRetentionDays,
258
+ args.scanPageSize ?? SCAN_PAGE_SIZE,
259
+ ),
260
+ };
215
261
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "improvement",
5
+ "title": "Warning when the login limiter falls back to memory in production",
6
+ "detail": "The in-memory login and MFA rate limiter logs one warning per process in production, because its limits are per replica."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "improvement",
5
+ "title": "Tenant caps list decrypts only the visible page",
6
+ "detail": "Without a search and with a sort other than name, the list loads light tenant rows, pages them, and fetches and decrypts names for the current page only. The default name sort and any search still decrypt every tenant, because tenant names are stored encrypted and cannot be sorted or filtered in SQL. Order, total and rows are unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.374.0",
4
10
  "type": "breaking",
@@ -16,7 +16,7 @@ import type { CapSpec, CapUsage } from "../types.ts";
16
16
  import { capFieldName } from "../web/constants.ts";
17
17
  import { computeFraction } from "../web/usage-math.ts";
18
18
 
19
- type TenantRow = { readonly id: string; readonly name: string };
19
+ type TenantRow = { readonly id: string; readonly name?: string };
20
20
  type TierAssignmentRow = {
21
21
  readonly tenantId: string;
22
22
  readonly tier: string;
@@ -54,6 +54,24 @@ const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
54
54
  type FilterOp = z.infer<typeof FILTER_OP>;
55
55
 
56
56
  const SORTABLE_FIELDS = ["name", "tier", "billing"] as const;
57
+ const DEFAULT_SORT_FIELD = "name";
58
+
59
+ async function withDecryptedPageNames<TRow extends { readonly tenantId: string }>(
60
+ db: Parameters<typeof selectMany>[0],
61
+ pageRows: readonly TRow[],
62
+ ): Promise<(TRow & { readonly name: string })[]> {
63
+ const decrypted = await decryptTenantNames(
64
+ await selectMany<TenantRow>(
65
+ db,
66
+ tenantTable,
67
+ { id: { in: pageRows.map((row) => row.tenantId) } },
68
+ { fields: ["id", "name"] },
69
+ ),
70
+ "cap-overview:tenant-caps:list",
71
+ );
72
+ const nameById = new Map(decrypted.map((tenant) => [tenant.id, tenant.name ?? ""]));
73
+ return pageRows.map((row) => ({ ...row, name: nameById.get(row.tenantId) ?? "" }));
74
+ }
57
75
 
58
76
  function tenantCapKey(tenantId: string, capId: string): string {
59
77
  return JSON.stringify([tenantId, capId]);
@@ -163,11 +181,18 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
163
181
  "cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview",
164
182
  );
165
183
 
166
- const tenants = await decryptTenantNames(
167
- // Destroyed tenants decrypt to the erased sentinel, which a picker or list would show verbatim.
168
- await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
169
- "cap-overview:tenant-caps:list",
170
- );
184
+ // Names are ciphertext at rest: only a name sort or search has to decrypt every tenant;
185
+ // otherwise rows stay name-less and just the page is decrypted afterwards.
186
+ const needsAllNames =
187
+ (query.payload.sort ?? DEFAULT_SORT_FIELD) === "name" ||
188
+ (query.payload.search?.trim() ?? "") !== "";
189
+ const tenants = needsAllNames
190
+ ? await decryptTenantNames(
191
+ // Destroyed tenants decrypt to the erased sentinel, which a picker or list would show verbatim.
192
+ await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
193
+ "cap-overview:tenant-caps:list",
194
+ )
195
+ : await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }, { fields: ["id"] });
171
196
  const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
172
197
  const subscriptions = await selectMany<SubscriptionRow>(db, subscriptionsProjectionTable, {});
173
198
 
@@ -182,7 +207,7 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
182
207
  return {
183
208
  id: tenant.id,
184
209
  tenantId: tenant.id,
185
- name: tenant.name,
210
+ name: tenant.name ?? "",
186
211
  tier: assignment?.tier ?? "",
187
212
  billing: billingLabel(subscriptionByTenant.get(tenant.id), assignment?.source ?? null),
188
213
  };
@@ -194,10 +219,13 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
194
219
  // tenant/team-list.query.ts. Holds up to tier-engine's documented
195
220
  // "single-pass scan" scale (a few thousand tenants); past that the fix
196
221
  // is a real combined read-projection.
197
- const { rows: page, total } = pageRows(merged, query.payload, {
222
+ const { rows: pageWithoutNames, total } = pageRows(merged, query.payload, {
198
223
  searchFields: ["name"],
199
- defaultSort: "name",
224
+ defaultSort: DEFAULT_SORT_FIELD,
200
225
  });
226
+ const page = needsAllNames
227
+ ? pageWithoutNames
228
+ : await withDecryptedPageNames(db, pageWithoutNames);
201
229
 
202
230
  // Usage is computed only for this page's tenant ids, never for the
203
231
  // full tenant set; caps without usageBatch still cost one query per
@@ -34,20 +34,22 @@ export const tenantsMissingProfileQuery = defineQueryHandler({
34
34
  "platform operator scans every tenant for a missing compliance-profile selection",
35
35
  );
36
36
 
37
- const tenants = await decryptTenantNames(
38
- await db.selectMany<{ id: TenantId; name: string }>(tenantTable, { isEnabled: true }),
39
- "compliance-profiles-ops:tenants-missing-profile",
40
- );
37
+ const tenantRows = await db.selectMany<{ id: TenantId; name: string }>(tenantTable, {
38
+ isEnabled: true,
39
+ });
41
40
  const profileRows = await db.selectMany<{ tenantId: TenantId }>(
42
41
  tenantComplianceProfileTable,
43
42
  {},
44
43
  );
45
44
  const tenantIdsWithProfile = new Set(profileRows.map((row) => row.tenantId));
45
+ // Only tenants that stay in the answer need their name decrypted.
46
+ const tenants = await decryptTenantNames(
47
+ tenantRows.filter((tenant) => !tenantIdsWithProfile.has(tenant.id)),
48
+ "compliance-profiles-ops:tenants-missing-profile",
49
+ );
46
50
 
47
51
  return {
48
- tenants: tenants
49
- .filter((tenant) => !tenantIdsWithProfile.has(tenant.id))
50
- .map((tenant) => ({ id: tenant.id, name: tenant.name })),
52
+ tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })),
51
53
  };
52
54
  },
53
55
  });
@@ -10,7 +10,7 @@ import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
10
10
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
11
11
  import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_EXTENSION } from "./constants.ts";
12
12
  import { deliveryAttemptSchema } from "./events.ts";
13
- import { maskRecipientAddress } from "./mask-recipient-address.ts";
13
+ import { maskRecipientAddress, maskRecipientAddressOnce } from "./mask-recipient-address.ts";
14
14
  import { type DeliveryLogEntry, isDeliveryChannelPlugin } from "./types.ts";
15
15
 
16
16
  // Unknown channels count as personal: masking is the fail-closed default.
@@ -31,6 +31,27 @@ function loggedAddress(registry: Registry, entry: DeliveryLogEntry): string | nu
31
31
  : maskRecipientAddress(entry.recipientAddress);
32
32
  }
33
33
 
34
+ // Projection-side counterpart of loggedAddress: legacy events were appended with the full address.
35
+ export function rememberConnectionNameChannel(
36
+ connectionNameChannels: Set<string>,
37
+ channelName: string,
38
+ channelOptions: unknown,
39
+ ): void {
40
+ if (isDeliveryChannelPlugin(channelOptions) && channelOptions.addressKind === "connection-name") {
41
+ connectionNameChannels.add(channelName);
42
+ }
43
+ }
44
+
45
+ export function projectedAttemptAddress(
46
+ connectionNameChannels: ReadonlySet<string>,
47
+ channel: string,
48
+ recipientAddress: string | null,
49
+ ): string | null {
50
+ return connectionNameChannels.has(channel)
51
+ ? recipientAddress
52
+ : maskRecipientAddressOnce(recipientAddress);
53
+ }
54
+
34
55
  // Shared append + inline-projection write (low-level append() does not
35
56
  // auto-fire projections — only the dispatcher/executor paths do).
36
57
  async function writeAttemptEvent(
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "fix",
5
+ "title": "delivery-log projection masks the recipient address",
6
+ "detail": "The inline projection now masks the address with the same helper as the write path, also on rebuild, so legacy events with a full address no longer reach the attempt log. Already masked addresses and connection-name channels stay unchanged. Swallowed errors in the snapshot save, queue-depth poll and channel pre-resolve now log a scrubbed warning."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "improvement",
@@ -19,7 +19,11 @@ import {
19
19
  createSystemUser,
20
20
  } from "@cosmicdrift/kumiko-framework/engine";
21
21
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
22
- import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
22
+ import {
23
+ createFallbackLogger,
24
+ type Logger,
25
+ scrubLogText,
26
+ } from "@cosmicdrift/kumiko-framework/logging";
23
27
  import {
24
28
  assertNotifyTargetsOwnTenant,
25
29
  createEscapeHatchReporter,
@@ -652,8 +656,13 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
652
656
  gates.set(key, gate);
653
657
  if (gate !== "open") continue;
654
658
  addresses.set(key, await channel.resolve(userId, channelCtx));
655
- } catch {
656
- // skip: re-resolved (and logged) by deliverToUser
659
+ } catch (err) {
660
+ // The per-recipient delivery path resolves again and decides the outcome; this only surfaces the early failure.
661
+ logError.warn("channel pre-resolve failed", {
662
+ channel: channel.name,
663
+ notificationType,
664
+ error: scrubLogText(err instanceof Error ? err.message : String(err)),
665
+ });
657
666
  }
658
667
  }
659
668
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
10
10
  import type * as z from "zod";
11
+ import { projectedAttemptAddress, rememberConnectionNameChannel } from "./attempt-log.ts";
11
12
  import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.ts";
12
13
  import {
13
14
  DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
@@ -120,6 +121,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
120
121
  // drain needed in tests. Chosen over a MultiStreamProjection because
121
122
  // delivery-log is a hot read-path for admin/audit UIs that expect
122
123
  // read-your-own-write semantics.
124
+ const connectionNameChannels = new Set<string>();
123
125
  r.projection({
124
126
  name: "delivery-log",
125
127
  source: "deliveryAttempt",
@@ -127,6 +129,11 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
127
129
  apply: {
128
130
  [DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
129
131
  const p = event.payload as z.infer<typeof deliveryAttemptSchema>; // @cast-boundary engine-payload
132
+ const projectedAddress = projectedAttemptAddress(
133
+ connectionNameChannels,
134
+ p.channel,
135
+ p.recipientAddress,
136
+ );
130
137
  // PK = aggregateId. An async attempt accrues multiple events on one
131
138
  // stream (queued → sent/failed): the first INSERTs, later events
132
139
  // UPDATE the same row. Events arrive in version order, so the last
@@ -140,7 +147,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
140
147
  notificationType: p.notificationType,
141
148
  channel: p.channel,
142
149
  recipientId: p.recipientId,
143
- recipientAddress: p.recipientAddress,
150
+ recipientAddress: projectedAddress,
144
151
  status: p.status,
145
152
  error: p.error,
146
153
  priority: p.priority,
@@ -149,7 +156,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
149
156
  {
150
157
  status: p.status,
151
158
  error: p.error,
152
- recipientAddress: p.recipientAddress,
159
+ recipientAddress: projectedAddress,
153
160
  confirmed: p.confirmed ?? null,
154
161
  },
155
162
  );
@@ -165,14 +172,10 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
165
172
  },
166
173
  });
167
174
 
168
- // Extension point: delivery-channels (email/in-app/push). Renderer-
169
- // Extension-Point lebt jetzt im `renderer-foundation`-Bundle als
170
- // `renderer` (Multi-Kind-Plugin-Contract). delivery hostet keinen
171
- // eigenen mehr — channel-email nimmt renderer als direkte
172
- // Konstruktor-Option (siehe email-channel.ts), nicht via Extension-
173
- // Usage. Migration 2026-05-19.
175
+ // Connection-name channels address a configured connection, not a person, so they stay unmasked.
174
176
  r.extendsRegistrar(DELIVERY_CHANNEL_EXTENSION, {
175
- onRegister: () => {},
177
+ onRegister: (channelName, channelOptions) =>
178
+ rememberConnectionNameChannel(connectionNameChannels, channelName, channelOptions),
176
179
  });
177
180
 
178
181
  // Async delivery pipeline for queued-mode channels. render decouples the
@@ -38,3 +38,17 @@ function maskStructuredOrTail(address: string): string {
38
38
  if (address.length < MIN_LENGTH_FOR_VISIBLE_TAIL) return MASK;
39
39
  return `${MASK}${address.slice(-VISIBLE_TAIL_LENGTH)}`;
40
40
  }
41
+
42
+ // Masked output is itself a valid input; the projection sees both fresh (already masked) and legacy (full) events.
43
+ function isMaskedRecipientAddress(address: string): boolean {
44
+ if (address.startsWith(MASK) && address.length <= MASK.length + VISIBLE_TAIL_LENGTH) return true;
45
+ if (address.endsWith(`/${MASK}`)) return true;
46
+ return /^[^@]\*\*\*@[^@]+$/.test(address);
47
+ }
48
+
49
+ // Ciphertext passes through: it is decrypted at read time and a key erase removes it.
50
+ export function maskRecipientAddressOnce(address: string | null): string | null {
51
+ if (address === null || isPiiCiphertext(address)) return address;
52
+ if (isMaskedRecipientAddress(address)) return address;
53
+ return maskPlainAddress(address);
54
+ }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "breaking",
5
+ "title": "Job handlers can return ctx.defer(ms)",
6
+ "detail": "A handler that returns ctx.defer(ms) moves the queue job to delayed and runs again after ms, like the maxPerTenant defer. It consumes no retry attempt and records no failure, failure log or failed run; the run row of the deferred pass is removed and a tenantVisibleRun record goes back to queued. maxDefers on the job (default 100, shared counter with maxPerTenant defers) bounds the loop; exceeding it fails the run for good (no retry) with a clear message. Inline runs (boot gates) throw. New run-logger hook onJobDeferred, passed through the entrypoint jobs options. JobHandlerFn may now return a JobDeferral. JobContext has a required defer member.",
7
+ "migration": "add defer to every hand-built JobContext object (tests, seeds), e.g. defer: (delayMs) => ({ kind: \"job-deferral\", delayMs })"
8
+ },
2
9
  {
3
10
  "version": "0.365.0",
4
11
  "type": "fix",
@@ -59,7 +59,7 @@ export type JobRunLoggerOptions = {
59
59
 
60
60
  export type JobRunLoggerCallbacks = Pick<
61
61
  JobRunnerOptions,
62
- "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"
62
+ "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobDeferred" | "onJobQueued" | "onJobDropped"
63
63
  >;
64
64
 
65
65
  // Default cap on the bullJobId → runId cache. A worker that starts jobs
@@ -362,6 +362,25 @@ async function recordTenantRunOutcome(
362
362
  });
363
363
  }
364
364
 
365
+ async function requeueTenantRun(
366
+ db: DbConnection,
367
+ jobName: string,
368
+ bullJobId: string,
369
+ meta: JobMeta,
370
+ ): Promise<void> {
371
+ // skip: job did not opt in to tenantVisibleRun
372
+ if (!meta.tenantVisibleRun) return;
373
+ const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
374
+ // skip: system-tenant run, no tenant-scoped query reads it
375
+ if (!key) return;
376
+ await updateMany(
377
+ db,
378
+ tenantJobRunsTable,
379
+ { status: "queued", startedAt: null, finishedAt: null, updatedAt: Temporal.Now.instant() },
380
+ { tenantId: key.tenantId, jobName, bullJobId },
381
+ );
382
+ }
383
+
365
384
  async function dropTenantRunQueued(
366
385
  db: DbConnection,
367
386
  jobName: string,
@@ -501,6 +520,20 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
501
520
  );
502
521
  },
503
522
 
523
+ // A deferred run is neither success nor failure: the same job id starts a fresh run later.
524
+ onJobDeferred: async (jobName: string, bullJobId: string, meta: JobMeta) => {
525
+ await isolateTenantRunWrite(
526
+ () => requeueTenantRun(db, jobName, bullJobId, meta),
527
+ jobName,
528
+ bullJobId,
529
+ );
530
+ const resolved = await resolveRun(bullJobId);
531
+ // skip: run row already gone (cache evicted and row unreachable)
532
+ if (!resolved) return;
533
+ await deleteMany(db, jobRunsTable, { id: resolved.runId });
534
+ runIdByBullJobId.delete(bullJobId);
535
+ },
536
+
504
537
  onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
505
538
  await isolateTenantRunWrite(
506
539
  () => recordTenantRunStarted(db, jobName, bullJobId, meta),
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "improvement",
5
+ "title": "sessionChecker looks up the user and the membership concurrently",
6
+ "detail": "The session check starts the user lookup together with the session read and the membership lookup together with the last-seen refresh. Results and the fail-open behavior are unchanged; a failed lookup that is not needed no longer raises an unhandled rejection."
7
+ },
2
8
  {
3
9
  "version": "0.336.0",
4
10
  "type": "fix",
@@ -39,6 +39,15 @@ import {
39
39
  // Re-exported so existing `../sessions` importers keep working unchanged.
40
40
  export { isPrincipalBlocked };
41
41
 
42
+ type SettledLookup<T> = { readonly ok: true; readonly value: T } | { readonly ok: false };
43
+
44
+ function settleLookup<T>(lookup: Promise<T>): Promise<SettledLookup<T>> {
45
+ return lookup.then(
46
+ (value): SettledLookup<T> => ({ ok: true, value }),
47
+ (): SettledLookup<T> => ({ ok: false }),
48
+ );
49
+ }
50
+
42
51
  // Why the callbacks live at the raw-DB level rather than going through the
43
52
  // dispatcher: session-create/revoke/check run on the hot path of every
44
53
  // login and every request. The (createdAt/revokedAt/ip/userAgent) columns
@@ -145,6 +154,14 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
145
154
 
146
155
  // kumiko-lint-ignore complexity-budget session check + lastSeen refresh on hot path
147
156
  async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
157
+ // The user lookup is keyed by the JWT's userId, not the session row, so it
158
+ // overlaps the session read. Settled eagerly: a throwing session read must
159
+ // not leave this promise as an unhandled rejection.
160
+ const userLookup = settleLookup(
161
+ fetchOne<{ status: UserStatus; isDeleted: boolean; roles: string | null }>(db, userTable, {
162
+ id: expectedUserId,
163
+ }),
164
+ );
148
165
  const row = await fetchOne<{
149
166
  userId: string;
150
167
  tenantId: TenantId;
@@ -165,6 +182,15 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
165
182
  return "expired";
166
183
  }
167
184
 
185
+ // Membership is keyed by the session's tenantId, so it can only start now;
186
+ // it overlaps the user lookup and the lastSeen refresh.
187
+ const membershipLookup = settleLookup(
188
+ fetchOne<{ roles: string | null }>(db, tenantMembershipsTable, {
189
+ userId: expectedUserId,
190
+ tenantId: row.tenantId,
191
+ }),
192
+ );
193
+
168
194
  // Hourly, not per-request: this check runs on every authenticated
169
195
  // request, so a write here has to stay off the hot path.
170
196
  const nowMs = Temporal.Now.instant().epochMilliseconds;
@@ -195,16 +221,9 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
195
221
  // missing row is a legitimate "no tenant roles" outcome. Both branches
196
222
  // return the bare "live" string (no re-derived roles) — the middleware
197
223
  // falls back to the JWT's frozen roles claim.
198
- let user: { status: UserStatus; isDeleted: boolean; roles: string | null } | undefined;
199
- try {
200
- user = await fetchOne<{ status: UserStatus; isDeleted: boolean; roles: string | null }>(
201
- db,
202
- userTable,
203
- { id: expectedUserId },
204
- );
205
- } catch {
206
- return "live";
207
- }
224
+ const userResult = await userLookup;
225
+ if (!userResult.ok) return "live";
226
+ const user = userResult.value;
208
227
  if (!user) return "live";
209
228
  if (isPrincipalRowBlocked(user)) return "blocked";
210
229
 
@@ -212,16 +231,11 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
212
231
  // DB error on the membership read must not lock the user out. A
213
232
  // missing row (not a throw) is a valid outcome: the user genuinely has
214
233
  // no tenant-scoped roles for row.tenantId, so membershipRoles is [].
215
- let membershipRoles: readonly string[];
216
- try {
217
- const membership = await fetchOne<{ roles: string | null }>(db, tenantMembershipsTable, {
218
- userId: expectedUserId,
219
- tenantId: row.tenantId,
220
- });
221
- membershipRoles = membership ? parseRoles(membership.roles) : [];
222
- } catch {
223
- return "live";
224
- }
234
+ const membershipResult = await membershipLookup;
235
+ if (!membershipResult.ok) return "live";
236
+ const membershipRoles = membershipResult.value
237
+ ? parseRoles(membershipResult.value.roles)
238
+ : [];
225
239
 
226
240
  const globalRoles = parseRoles(user.roles);
227
241
  const roles = buildSessionRoles(globalRoles, membershipRoles);
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "improvement",
5
+ "title": "Tenant directory search stops decrypting once the limit is reached",
6
+ "detail": "Tenant names are encrypted and not in the search index, so the directory search decrypts in small batches and stops after limit matches; the 1000-row scan cap stays. With more matches than the limit, the first matches in key order are returned (sorted by label), not the best alphabetical ones. The missing-profile list drops tenants that already have a profile before decrypting."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -15,3 +15,28 @@ export async function decryptTenantNames<T extends { readonly name?: unknown }>(
15
15
  : row,
16
16
  );
17
17
  }
18
+
19
+ // Tenant names are not in the search index (find: "none"), so a name search has to decrypt;
20
+ // stopping at `limit` matches keeps a typed prefix from decrypting the whole scan window.
21
+ export async function takeTenantsMatchingDecryptedName<
22
+ T extends { readonly id: unknown; readonly name?: unknown },
23
+ >(
24
+ rows: readonly T[],
25
+ labelMatches: (label: string) => boolean,
26
+ limit: number,
27
+ fallbackRequestId: string,
28
+ ): Promise<T[]> {
29
+ const batchSize = Math.max(limit, KMS_POOL_CONCURRENCY);
30
+ const matches: T[] = [];
31
+ for (let start = 0; start < rows.length && matches.length < limit; start += batchSize) {
32
+ const decrypted = await decryptTenantNames(
33
+ rows.slice(start, start + batchSize),
34
+ fallbackRequestId,
35
+ );
36
+ for (const row of decrypted) {
37
+ const label = typeof row.name === "string" ? row.name : String(row.id);
38
+ if (labelMatches(label)) matches.push(row);
39
+ }
40
+ }
41
+ return matches.slice(0, limit);
42
+ }
@@ -6,7 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
- import { decryptTenantNames } from "../decrypt-tenant-names.ts";
9
+ import { decryptTenantNames, takeTenantsMatchingDecryptedName } from "../decrypt-tenant-names.ts";
10
10
  import { tenantTable } from "../schema/tenant.ts";
11
11
  import { isSystemAdmin } from "./is-system-admin.ts";
12
12
 
@@ -60,18 +60,22 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
60
60
  }
61
61
  // name is ciphertext at rest, so the SQL page is keyed by the plaintext
62
62
  // `key`; label order and search happen in memory after decrypting.
63
- const decrypted = await decryptTenantNames(tenants, "tenant:tenant-directory");
64
- const labeled = decrypted
63
+ const needle = search?.toLowerCase();
64
+ const named =
65
+ needle === undefined
66
+ ? await decryptTenantNames(tenants, "tenant:tenant-directory")
67
+ : await takeTenantsMatchingDecryptedName(
68
+ tenants,
69
+ (label) => label.toLowerCase().includes(needle),
70
+ limit,
71
+ "tenant:tenant-directory",
72
+ );
73
+ const rows = named
65
74
  .map((tenant) => {
66
75
  const id = String(tenant.id);
67
76
  return { id, label: typeof tenant.name === "string" ? tenant.name : id };
68
77
  })
69
78
  .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
70
- const needle = search?.toLowerCase();
71
- const rows =
72
- needle === undefined
73
- ? labeled
74
- : labeled.filter((row) => row.label.toLowerCase().includes(needle)).slice(0, limit);
75
79
  return { rows, nextCursor: null };
76
80
  },
77
81
  });
@@ -171,7 +171,13 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
171
171
  statusToneField: "status",
172
172
  statusTones: { active: "ok", terminated: "bad" },
173
173
  },
174
- metrics: ["grundmiete", "beginn", "kuendigungsfrist", "zahltag", "mieter"],
174
+ metrics: [
175
+ { field: "grundmiete", label: "rental:entity:lease:field:grundmiete" },
176
+ { field: "beginn", label: "rental:entity:lease:field:beginn" },
177
+ { field: "kuendigungsfrist", label: "rental:entity:lease:field:kuendigungsfrist" },
178
+ { field: "zahltag", label: "rental:entity:lease:field:zahltag" },
179
+ { field: "mieter", label: "rental:entity:lease:field:mieter" },
180
+ ],
175
181
  fieldLabels: {
176
182
  grundmiete: "rental:entity:lease:field:grundmiete",
177
183
  beginn: "rental:entity:lease:field:beginn",
@@ -54,6 +54,14 @@ With only the API running, `read_orders` gains the row but
54
54
  `read_order_activity` stays empty. Start the worker and both the activity
55
55
  row and the `read_fulfillments` row appear.
56
56
 
57
+ ## Deferring a job
58
+
59
+ `process-order` returns `context.defer(ms)` while the (simulated) carrier is busy. The job
60
+ is re-run after `ms` on the same queue job: no retry attempt is used, no failure is logged
61
+ or recorded, and the run log shows only the runs that actually executed. `maxDefers`
62
+ (default 100) bounds it; beyond that the run fails. It needs a queued run: an inline boot
63
+ gate throws.
64
+
57
65
  ## Test
58
66
 
59
67
  Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
@@ -84,6 +84,13 @@ export function setOrderFulfillWrite(fn: FulfillWrite): void {
84
84
  fulfillWrite = fn;
85
85
  }
86
86
 
87
+ let carrierRetryAfterMs: () => number = () => 0;
88
+
89
+ /** Stand-in for a rate-limited carrier API: ms until it accepts work again, 0 when free. */
90
+ export function setCarrierRetryAfter(fn: () => number): void {
91
+ carrierRetryAfterMs = fn;
92
+ }
93
+
87
94
  // Job payloads arrive as `Record<string, unknown>` (JobHandlerFn) — an
88
95
  // unchecked `as string` cast here would let a missing/non-string field
89
96
  // through silently, writing `orderKey: undefined` / `label: "label-
@@ -123,6 +130,9 @@ export function createApiWorkerSplitFeature() {
123
130
  );
124
131
  }
125
132
  const { customerName } = processOrderPayloadSchema.parse(payload);
133
+ // Busy is not a failure: ctx.defer re-runs the job later without burning a retry.
134
+ const retryAfterMs = carrierRetryAfterMs();
135
+ if (retryAfterMs > 0) return context.defer(retryAfterMs);
126
136
  const result = await fulfillWrite({
127
137
  handlerQn: "orders:write:fulfillment:create",
128
138
  payload: {
@@ -5,12 +5,12 @@ header, a metrics band, and a tabbed layout instead of one long stacked form.
5
5
 
6
6
  ## What it shows
7
7
 
8
- - `header` (title/subtitle/status) and `metrics` (labeled via `fieldLabels`)
8
+ - `header` (title/subtitle/status) and `metrics` (each with its own i18n `label`)
9
9
  rendered from the columns of the screen's own query row
10
10
  - `layout.mode: "tabs"` with two `relatedList` tabs (order items, payments)
11
11
  and one `fields` tab (master data) — each `relatedList` runs its own query
12
12
  - boot-validator catching two common author mistakes: a `metrics` entry
13
- without a `fieldLabels` override, and a `tabs` layout with fewer than two
13
+ with an empty `label`, and a `tabs` layout with fewer than two
14
14
  sections
15
15
 
16
16
  ## Source
@@ -3,8 +3,8 @@
3
3
  // 1. `header` renders the query row's customer/order/status columns above
4
4
  // the layout — the identity a support agent scans first.
5
5
  // 2. `metrics` renders a labeled band of key numbers from the same row —
6
- // labels come from `fieldLabels`, there is no fallback to the raw
7
- // column name.
6
+ // each metric carries its own i18n `label`, there is no fallback to the
7
+ // raw column name.
8
8
  // 3. `layout.mode: "tabs"` splits the record into four tabs: two
9
9
  // `relatedList` sections (own query, own pagination, mounts only when
10
10
  // active), one `fields` section (master data from the detail row), and
@@ -76,13 +76,12 @@ export function createOrderDeskFeature(): FeatureDefinition {
76
76
  type: "projectionDetail",
77
77
  query: "order-desk:query:order:detail",
78
78
  header: { title: "customerName", subtitle: "orderNumber", status: "status" },
79
- metrics: ["totalAmount", "outstandingAmount", "itemCount", "placedAt"],
80
- fieldLabels: {
81
- totalAmount: "order-desk.metric.totalAmount",
82
- outstandingAmount: "order-desk.metric.outstandingAmount",
83
- itemCount: "order-desk.metric.itemCount",
84
- placedAt: "order-desk.metric.placedAt",
85
- },
79
+ metrics: [
80
+ { field: "totalAmount", label: "order-desk.metric.totalAmount" },
81
+ { field: "outstandingAmount", label: "order-desk.metric.outstandingAmount" },
82
+ { field: "itemCount", label: "order-desk.metric.itemCount" },
83
+ { field: "placedAt", label: "order-desk.metric.placedAt" },
84
+ ],
86
85
  layout: {
87
86
  mode: "tabs",
88
87
  sections: [