@cosmicdrift/kumiko-samples 0.404.1 → 0.405.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 (93) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +10 -10
  3. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  5. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  6. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  7. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  8. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  9. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  11. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  12. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  13. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  14. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  15. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  16. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  17. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  18. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  19. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  21. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  22. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  23. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  24. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  25. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  26. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  27. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  28. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  29. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  30. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  31. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  32. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  33. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  34. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  35. package/packages/bundled-features/src/folders/feature.ts +2 -8
  36. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  37. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  38. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  39. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  40. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  41. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  42. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  43. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  44. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  45. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  46. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  47. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  48. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  49. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  50. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  51. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  52. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  53. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  54. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  55. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  56. package/packages/bundled-features/src/shared/changes.json +6 -0
  57. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  58. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  59. package/packages/bundled-features/src/shared/index.ts +6 -0
  60. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  61. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  62. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  63. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  64. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  65. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  66. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  67. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  68. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  69. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  70. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  71. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  72. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  73. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  74. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  75. package/packages/bundled-features/src/tags/feature.ts +2 -8
  76. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  77. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  78. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  79. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  80. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  81. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  82. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  83. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  84. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  85. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  86. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  87. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  88. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  89. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  90. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  91. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  92. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  93. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
@@ -26,14 +26,10 @@
26
26
  // src (node_modules is ignored by default) or these classes never compile. money-
27
27
  // horse already does; that scan is a real shipping requirement for any host.
28
28
 
29
- import {
30
- useDispatcher,
31
- usePrimitives,
32
- useQuery,
33
- useTranslation,
34
- } from "@cosmicdrift/kumiko-renderer";
29
+ import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
35
30
  import { ChevronRight, File, Folder, type LucideIcon, Pencil, Plus, Trash2 } from "lucide-react";
36
31
  import { type DragEvent, type ReactNode, useEffect, useState } from "react";
32
+ import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
37
33
  import { FoldersHandlers, FoldersQueries } from "./constants.ts";
38
34
  import { buildFolderTree, type FolderNode, type FolderRow } from "./tree.ts";
39
35
 
@@ -99,13 +95,15 @@ export function FolderManager({
99
95
  }): ReactNode {
100
96
  const { Banner, Button, Dialog, Input, Text } = usePrimitives();
101
97
  const t = useTranslation();
102
- const dispatcher = useDispatcher();
103
98
  const catalog = useQuery<FolderListResponse>(FoldersQueries.folderList, {});
104
99
  const [pending, setPending] = useState<Pending>(null);
105
100
  const [draftName, setDraftName] = useState("");
106
101
  const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(loadCollapsed);
107
- const [busy, setBusy] = useState(false);
108
- const [errorKey, setErrorKey] = useState<string | null>(null);
102
+ const { busy, setBusy, errorKey, setErrorKey, writeOk, apply } = useDispatchedWrites(async () => {
103
+ setPending(null);
104
+ await catalog.refetch();
105
+ await filing?.onReassigned();
106
+ });
109
107
  const [dragOverKey, setDragOverKey] = useState<string | null>(null);
110
108
  const [pendingDelete, setPendingDelete] = useState<FolderNode | null>(null);
111
109
 
@@ -177,29 +175,6 @@ export function FolderManager({
177
175
  setPending({ mode: "rename", id: folder.id, version: folder.version });
178
176
  };
179
177
 
180
- const apply = async (write: () => Promise<boolean>): Promise<void> => {
181
- setBusy(true);
182
- setErrorKey(null);
183
- try {
184
- if (await write()) {
185
- setPending(null);
186
- await catalog.refetch();
187
- await filing?.onReassigned();
188
- }
189
- } finally {
190
- setBusy(false);
191
- }
192
- };
193
-
194
- const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
195
- const result = await dispatcher.write(type, payload);
196
- if (!result.isSuccess) {
197
- setErrorKey(result.error.i18nKey);
198
- return false;
199
- }
200
- return true;
201
- };
202
-
203
178
  const saveDraft = (): void => {
204
179
  const name = draftName.trim();
205
180
  if (name === "" || pending === null) return;
@@ -18,6 +18,7 @@ import {
18
18
  useTranslation,
19
19
  } from "@cosmicdrift/kumiko-renderer";
20
20
  import { type ReactNode, useState } from "react";
21
+ import { useDispatchedWrites } from "../../shared/web/use-dispatched-writes.ts";
21
22
  import { FoldersHandlers, FoldersQueries } from "./constants.ts";
22
23
  import { type FolderRow, folderPath } from "./tree.ts";
23
24
 
@@ -58,8 +59,10 @@ export function FolderSection({
58
59
  { enabled },
59
60
  );
60
61
  const [newName, setNewName] = useState("");
61
- const [busy, setBusy] = useState(false);
62
- const [errorKey, setErrorKey] = useState<string | null>(null);
62
+ const refetch = async (): Promise<void> => {
63
+ await Promise.all([catalog.refetch(), assignments.refetch()]);
64
+ };
65
+ const { busy, errorKey, setErrorKey, writeOk, apply } = useDispatchedWrites(refetch);
63
66
 
64
67
  if (entityId === null) {
65
68
  return (
@@ -95,29 +98,6 @@ export function FolderSection({
95
98
  ...folders.map((f) => ({ value: f.id, label: folderPath(folders, f.id) })),
96
99
  ];
97
100
 
98
- const refetch = async (): Promise<void> => {
99
- await Promise.all([catalog.refetch(), assignments.refetch()]);
100
- };
101
-
102
- const apply = async (write: () => Promise<boolean>): Promise<void> => {
103
- setBusy(true);
104
- setErrorKey(null);
105
- try {
106
- if (await write()) await refetch();
107
- } finally {
108
- setBusy(false);
109
- }
110
- };
111
-
112
- const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
113
- const result = await dispatcher.write(type, payload);
114
- if (!result.isSuccess) {
115
- setErrorKey(result.error.i18nKey);
116
- return false;
117
- }
118
- return true;
119
- };
120
-
121
101
  const onSelect = (next: string): void => {
122
102
  if (next === currentFolderId) return;
123
103
  void apply(() =>
@@ -1,6 +1,7 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import { failUnprocessable, type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
4
+ import { isUniqueConstraintFailure } from "../../shared/index.ts";
4
5
  import { countDraftsByOwner } from "../db/queries/draft-count.ts";
5
6
  import { formDraftExecutor } from "../executor.ts";
6
7
  import { lookupDraft } from "../lookup.ts";
@@ -11,6 +12,9 @@ import {
11
12
  FORM_DRAFT_UNIQUE_KEY_CONSTRAINT,
12
13
  } from "../web/constants.ts";
13
14
 
15
+ const SAVE_DRAFT_COUNT_REASON =
16
+ "COUNT(*) of the caller's own drafts without loading 64KB value blobs";
17
+
14
18
  /**
15
19
  * save is an upsert keyed on (tenantId, ownerId, draftKey) — same
16
20
  * lookup-then-create|update-with-race-retry idiom as
@@ -20,20 +24,6 @@ import {
20
24
  * winner's row, and updates it (with the winner's version) instead of
21
25
  * erroring.
22
26
  */
23
- function isDraftKeyConflict(failure: WriteFailure): boolean {
24
- const error = failure.error as {
25
- code?: string;
26
- details?: { constraintName?: string };
27
- }; // @cast-boundary error-details
28
- return (
29
- error.code === "unique_violation" &&
30
- error.details?.constraintName === FORM_DRAFT_UNIQUE_KEY_CONSTRAINT
31
- );
32
- }
33
-
34
- const SAVE_DRAFT_COUNT_REASON =
35
- "COUNT(*) of the caller's own drafts without loading 64KB value blobs";
36
-
37
27
  export const saveDraftWrite = defineWriteHandler({
38
28
  name: "save",
39
29
  schema: saveDraftPayloadSchema,
@@ -69,7 +59,7 @@ export const saveDraftWrite = defineWriteHandler({
69
59
 
70
60
  const result = await formDraftExecutor.create({ ownerId, draftKey, draft }, event.user, ctx.db);
71
61
  if (result.isSuccess) return result;
72
- if (!isDraftKeyConflict(result)) return result;
62
+ if (!isUniqueConstraintFailure(result, FORM_DRAFT_UNIQUE_KEY_CONSTRAINT)) return result;
73
63
 
74
64
  const winner = await lookupDraft(ctx.db, event.user.tenantId, ownerId, draftKey);
75
65
  if (!winner) return result;
@@ -228,12 +228,8 @@ export function createInboundMailFoundationFeature(options: InboundMailRoleOptio
228
228
 
229
229
  // Plugin extension point — providers register via
230
230
  // r.useExtension("inboundMailProvider", "<key>", plugin).
231
- r.extendsRegistrar(INBOUND_MAIL_PROVIDER_EXTENSION, {
232
- onRegister: () => {
233
- // No side-effects at register-time — lookup happens at runtime
234
- // via resolveInboundProviderForKey/-Account.
235
- },
236
- });
231
+ // Lookup happens at runtime via resolveInboundProviderForKey/-Account.
232
+ r.extendsRegistrar(INBOUND_MAIL_PROVIDER_EXTENSION, {});
237
233
 
238
234
  // Sync machinery: high-frequency tick state, deliberately NOT
239
235
  // event-sourced — r.storeTable keeps the migration DDL but excludes the
@@ -11,9 +11,10 @@
11
11
  // in the HMAC input, timing-safe comparison, expiry in the clear-text
12
12
  // segment).
13
13
 
14
- import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
14
+ import { randomUUID } from "node:crypto";
15
15
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
16
16
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
17
+ import { hmacSignatureMatches, signHmacSha256Base64Url } from "../shared/index.ts";
17
18
 
18
19
  const STATE_PURPOSE = "inbound-mail-oauth-connect";
19
20
 
@@ -34,10 +35,6 @@ export type VerifyOAuthStateResult =
34
35
  | { readonly ok: true; readonly payload: OAuthStatePayload }
35
36
  | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
36
37
 
37
- function sign(input: string, secret: string): string {
38
- return createHmac("sha256", secret).update(input).digest("base64url");
39
- }
40
-
41
38
  function hmacInput(payloadB64: string, expiresAtMs: number): string {
42
39
  return `${STATE_PURPOSE}:${payloadB64}.${expiresAtMs}`;
43
40
  }
@@ -51,7 +48,7 @@ export function signOAuthState(
51
48
  const full: OAuthStatePayload = { ...payload, nonce: randomUUID() };
52
49
  const payloadB64 = Buffer.from(JSON.stringify(full), "utf8").toString("base64url");
53
50
  const expiresAtMs = now.add({ minutes: ttlMinutes }).epochMilliseconds;
54
- const signature = sign(hmacInput(payloadB64, expiresAtMs), secret);
51
+ const signature = signHmacSha256Base64Url(hmacInput(payloadB64, expiresAtMs), secret);
55
52
  return `${payloadB64}.${expiresAtMs}.${signature}`;
56
53
  }
57
54
 
@@ -70,13 +67,8 @@ export function verifyOAuthState(
70
67
  return { ok: false, reason: "malformed" };
71
68
  }
72
69
 
73
- const expected = sign(hmacInput(payloadB64, expiresAtMs), secret);
74
- const expectedBuf = Buffer.from(expected, "base64url");
75
- const providedBuf = Buffer.from(providedSig, "base64url");
76
- // Check the length before timingSafeEqual: it throws on a length mismatch,
77
- // and the throw itself leaks information through timing.
78
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
79
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
70
+ const expected = signHmacSha256Base64Url(hmacInput(payloadB64, expiresAtMs), secret);
71
+ if (!hmacSignatureMatches(expected, providedSig)) return { ok: false, reason: "bad_signature" };
80
72
 
81
73
  if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
82
74
  return { ok: false, reason: "expired" };
@@ -16,8 +16,8 @@
16
16
  // unreadable. These hooks dispose of the rows.
17
17
 
18
18
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
19
- import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
20
- import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
19
+ import type { TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { archiveAndDeleteTenantRows } from "../tenant-lifecycle/index.ts";
21
21
  import { seenMessageTable, syncCursorTable } from "./entities.ts";
22
22
  import {
23
23
  INBOUND_MESSAGE_AGGREGATE_TYPE,
@@ -32,31 +32,21 @@ import {
32
32
  } from "./projection.ts";
33
33
  import { quarantineTable } from "./quarantine-entities.ts";
34
34
 
35
- const ARCHIVED_BY = "tenant-lifecycle:destroy";
36
- const REASON = "tenant_destroy";
37
-
38
35
  // archiveStream sits outside TenantDb's own methods — needs the escape hatch.
39
36
  export const INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON =
40
37
  "tenant-destroy archives each inbound-mail entity's event-streams before the projection rows are deleted";
41
38
 
42
- async function archiveAndDeleteRows(
39
+ function archiveAndDeleteRows(
43
40
  ctx: TenantDataHookCtx,
44
41
  table: EntityTableMeta,
45
42
  aggregateType: string,
46
43
  ): Promise<readonly string[]> {
47
- declareEscapeHatch({ reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON });
48
- const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
49
- for (const row of rows) {
50
- await archiveStream(ctx.db.unsafeRaw(), {
51
- tenantId: ctx.tenantId,
52
- aggregateId: row.id,
53
- aggregateType,
54
- archivedBy: ARCHIVED_BY,
55
- reason: REASON,
56
- });
57
- }
58
- await ctx.db.deleteMany(table, { tenantId: ctx.tenantId });
59
- return rows.map((row) => row.id);
44
+ return archiveAndDeleteTenantRows(
45
+ ctx,
46
+ table,
47
+ aggregateType,
48
+ INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
49
+ );
60
50
  }
61
51
 
62
52
  export async function mailAccountTenantDestroyHook(ctx: TenantDataHookCtx): Promise<void> {
@@ -2,8 +2,8 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
5
- import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
6
5
  import * as z from "zod";
6
+ import { parseSubject } from "../parse-subject.ts";
7
7
  import { tenantJobFailuresTable } from "../tenant-job-failure-table.ts";
8
8
 
9
9
  type TenantJobFailureRow = {
@@ -16,20 +16,6 @@ type TenantJobFailureRow = {
16
16
 
17
17
  const DEFAULT_LIMIT = 50;
18
18
 
19
- function isSubjectEntry(value: unknown): value is [string, unknown] {
20
- return Array.isArray(value) && typeof value[0] === "string";
21
- }
22
-
23
- function parseSubject(stored: string | null): Record<string, unknown> | null {
24
- if (stored === null) return null;
25
- // A corrupt subject must not fail the whole list — the record still tells
26
- // the tenant which job failed and why. parseJsonSafe only survives a
27
- // SyntaxError, so the shape needs its own check.
28
- const parsed = parseJsonSafe<unknown>(stored, null);
29
- if (!Array.isArray(parsed)) return null;
30
- return Object.fromEntries(parsed.filter(isSubjectEntry));
31
- }
32
-
33
19
  export function createTenantFailuresQuery(readRoles: readonly string[]) {
34
20
  return defineQueryHandler({
35
21
  name: "failures",
@@ -2,8 +2,8 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
5
- import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
6
5
  import * as z from "zod";
6
+ import { parseSubject } from "../parse-subject.ts";
7
7
  import { type TenantJobRunStatus, tenantJobRunsTable } from "../tenant-job-run-table.ts";
8
8
 
9
9
  type TenantJobRunRow = {
@@ -19,19 +19,6 @@ type TenantJobRunRow = {
19
19
 
20
20
  const DEFAULT_LIMIT = 50;
21
21
 
22
- function isSubjectEntry(value: unknown): value is [string, unknown] {
23
- return Array.isArray(value) && typeof value[0] === "string";
24
- }
25
-
26
- function parseSubject(stored: string | null): Record<string, unknown> | null {
27
- if (stored === null) return null;
28
- // A corrupt subject must not fail the whole list; parseJsonSafe only
29
- // survives a SyntaxError, so the shape needs its own check.
30
- const parsed = parseJsonSafe<unknown>(stored, null);
31
- if (!Array.isArray(parsed)) return null;
32
- return Object.fromEntries(parsed.filter(isSubjectEntry));
33
- }
34
-
35
22
  export function createTenantRunsQuery(readRoles: readonly string[]) {
36
23
  return defineQueryHandler({
37
24
  name: "tenant-runs",
@@ -153,6 +153,27 @@ async function encryptLogMessages<T extends { readonly message: string }>(
153
153
  }));
154
154
  }
155
155
 
156
+ async function insertEncryptedRunLogs(
157
+ db: DbConnection,
158
+ runId: string,
159
+ triggeredById: string | null,
160
+ logs: readonly { readonly level: string; readonly message: string; readonly timestamp: string }[],
161
+ ): Promise<void> {
162
+ // skip: empty log batch — the worker ran silent, no child rows to insert
163
+ if (logs.length === 0) return;
164
+ const encryptedLogs = await encryptLogMessages(logs, triggeredById);
165
+ await insertMany(
166
+ db,
167
+ jobRunLogsTable,
168
+ encryptedLogs.map((log) => ({
169
+ runId,
170
+ level: log.level,
171
+ message: log.message,
172
+ timestamp: parseJobInstant(log.timestamp),
173
+ })),
174
+ );
175
+ }
176
+
156
177
  // Mirrors encryptFailureError for the failure-path `error` column:
157
178
  // stored alongside logs[].message under the same triggering user's DEK, so
158
179
  // a log/error pair from the same failed run decrypt together or erase
@@ -349,14 +370,7 @@ async function recordTenantRunOutcome(
349
370
  // A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
350
371
  // seeing pending work, not a failure the next attempt may still resolve.
351
372
  if (result === "failed" && !outcome.finalAttempt) {
352
- await inTenantScope(key.tenantId, () =>
353
- updateMany(
354
- db,
355
- tenantJobRunsTable,
356
- { status: "queued", startedAt: null, finishedAt: null, updatedAt: now },
357
- { tenantId: key.tenantId, jobName, bullJobId },
358
- ),
359
- );
373
+ await markTenantRunQueued(db, key.tenantId, jobName, bullJobId, now);
360
374
  // skip: retry pending, the row stays active until the final attempt
361
375
  return;
362
376
  }
@@ -403,12 +417,22 @@ async function requeueTenantRun(
403
417
  const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
404
418
  // skip: system-tenant run, no tenant-scoped query reads it
405
419
  if (!key) return;
406
- await inTenantScope(key.tenantId, () =>
420
+ await markTenantRunQueued(db, key.tenantId, jobName, bullJobId, Temporal.Now.instant());
421
+ }
422
+
423
+ async function markTenantRunQueued(
424
+ db: DbConnection,
425
+ tenantId: string,
426
+ jobName: string,
427
+ bullJobId: string,
428
+ updatedAt: Temporal.Instant,
429
+ ): Promise<void> {
430
+ await inTenantScope(tenantId, () =>
407
431
  updateMany(
408
432
  db,
409
433
  tenantJobRunsTable,
410
- { status: "queued", startedAt: null, finishedAt: null, updatedAt: Temporal.Now.instant() },
411
- { tenantId: key.tenantId, jobName, bullJobId },
434
+ { status: "queued", startedAt: null, finishedAt: null, updatedAt },
435
+ { tenantId, jobName, bullJobId },
412
436
  ),
413
437
  );
414
438
  }
@@ -672,21 +696,7 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
672
696
  },
673
697
  { id: runId },
674
698
  );
675
- // skip: empty log batch — the worker ran silent. No child rows to
676
- // insert; the status update above already recorded completion.
677
- if (payload.logs.length > 0) {
678
- const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
679
- await insertMany(
680
- db,
681
- jobRunLogsTable,
682
- encryptedLogs.map((log) => ({
683
- runId,
684
- level: log.level,
685
- message: log.message,
686
- timestamp: parseJobInstant(log.timestamp),
687
- })),
688
- );
689
- }
699
+ await insertEncryptedRunLogs(db, runId, triggeredById, payload.logs);
690
700
  runIdByBullJobId.delete(bullJobId); // immediate cleanup on terminal callback
691
701
  },
692
702
 
@@ -746,20 +756,7 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
746
756
  },
747
757
  { id: runId },
748
758
  );
749
- // skip: empty log batch — mirror of onJobComplete
750
- if (payload.logs.length > 0) {
751
- const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
752
- await insertMany(
753
- db,
754
- jobRunLogsTable,
755
- encryptedLogs.map((log) => ({
756
- runId,
757
- level: log.level,
758
- message: log.message,
759
- timestamp: parseJobInstant(log.timestamp),
760
- })),
761
- );
762
- }
759
+ await insertEncryptedRunLogs(db, runId, triggeredById, payload.logs);
763
760
  runIdByBullJobId.delete(bullJobId); // immediate cleanup on terminal callback
764
761
  },
765
762
  };
@@ -0,0 +1,15 @@
1
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
2
+
3
+ function isSubjectEntry(value: unknown): value is [string, unknown] {
4
+ return Array.isArray(value) && typeof value[0] === "string";
5
+ }
6
+
7
+ // A corrupt subject must not fail the whole list: the record still tells the
8
+ // tenant which job failed. parseJsonSafe only survives a SyntaxError, so the
9
+ // shape needs its own check.
10
+ export function parseSubject(stored: string | null): Record<string, unknown> | null {
11
+ if (stored === null) return null;
12
+ const parsed = parseJsonSafe<unknown>(stored, null);
13
+ if (!Array.isArray(parsed)) return null;
14
+ return Object.fromEntries(parsed.filter(isSubjectEntry));
15
+ }
@@ -21,6 +21,7 @@ import {
21
21
  defineFeature,
22
22
  type FeatureRegistrar,
23
23
  } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { resolveFeatureAccess } from "../shared/index.ts";
24
25
  import { accountEntity, scheduleEntity, transactionEntity } from "./entity.ts";
25
26
  import { createConfirmSchedulePeriodHandler } from "./handlers/confirm-schedule-period.write.ts";
26
27
  import { createCreateTransactionHandler } from "./handlers/create-transaction.write.ts";
@@ -166,18 +167,12 @@ export type LedgerFeatureOptions = {
166
167
  readonly toggleable?: LedgerToggleable;
167
168
  };
168
169
 
169
- function resolveAccess(opts: LedgerFeatureOptions): AccessRule {
170
- if (opts.access !== undefined) return opts.access;
171
- if (opts.roles !== undefined) return { roles: opts.roles };
172
- return DEFAULT_LEDGER_ACCESS;
173
- }
174
-
175
170
  // Options wrapper. Without options returns the module-level singleton (no
176
171
  // rebuild). access/roles/toggleable build a fresh feature-definition.
177
172
  export function createLedgerFeature(opts: LedgerFeatureOptions = {}): typeof defaultLedgerFeature {
178
173
  if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
179
174
  return defaultLedgerFeature;
180
175
  }
181
- const access = resolveAccess(opts);
176
+ const access = resolveFeatureAccess(opts, DEFAULT_LEDGER_ACCESS);
182
177
  return defineFeature(LEDGER_FEATURE_NAME, (r) => registerLedger(r, access, opts.toggleable));
183
178
  }
@@ -141,13 +141,9 @@ export function createMailFoundationFeature() {
141
141
  // Plugin extension-point. Provider-features register here. The
142
142
  // entityName at registration time becomes the value tenants pick in
143
143
  // `provider` config-key (e.g. "smtp", "brevo-api").
144
- r.extendsRegistrar(MAIL_TRANSPORT_EXTENSION, {
145
- onRegister: () => {
146
- // No side-effects at register-time — the registry stores the
147
- // usage, factory looks it up at request-time. Same shape as
148
- // delivery's extendsRegistrar.
149
- },
150
- });
144
+ // The registry stores the usage, factory looks it up at request-time. Same shape as
145
+ // delivery's extendsRegistrar.
146
+ r.extendsRegistrar(MAIL_TRANSPORT_EXTENSION, {});
151
147
 
152
148
  // Empty = this tenant has no own provider: tenantMailTransport yields null and
153
149
  // channel-email uses the app transport, so empty is a valid choice a tenant can
@@ -1,4 +1,5 @@
1
1
  import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import { createLruCache } from "@cosmicdrift/kumiko-framework/utils";
2
3
  import { renderSafeMarkdown } from "../page-render/index.ts";
3
4
  import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
4
5
  import type { BodyFormat } from "./constants.ts";
@@ -29,8 +30,7 @@ export type PageBodyRendererDeps = {
29
30
  };
30
31
 
31
32
  // Every write bumps the version, so a (page, version) key never serves stale
32
- // HTML. A Map keeps insertion order: delete+reinsert on a hit makes the first
33
- // key the least recently used one.
33
+ // HTML.
34
34
  function cacheKeyOf(page: RenderablePageBody): string {
35
35
  return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join(
36
36
  "\u0000",
@@ -47,7 +47,7 @@ export function createPageBodyRenderer(
47
47
  const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
48
48
  const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
49
49
  const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
50
- const cache = new Map<string, string>();
50
+ const cache = createLruCache<string, string>({ maxEntries });
51
51
 
52
52
  function renderUncached(page: RenderablePageBody, log: WarnLogger): string {
53
53
  if (page.bodyFormat === "markdown") return renderMarkdown(page.body);
@@ -63,18 +63,10 @@ export function createPageBodyRenderer(
63
63
  return (page, log = defaultLog) => {
64
64
  const key = cacheKeyOf(page);
65
65
  const cached = cache.get(key);
66
- if (cached !== undefined) {
67
- cache.delete(key);
68
- cache.set(key, cached);
69
- return cached;
70
- }
66
+ if (cached !== undefined) return cached;
71
67
  const html = renderUncached(page, log);
72
68
  if (html.length > MAX_CACHED_BODY_LENGTH) return html;
73
69
  cache.set(key, html);
74
- if (cache.size > maxEntries) {
75
- const oldest = cache.keys().next();
76
- if (!oldest.done) cache.delete(oldest.value);
77
- }
78
70
  return html;
79
71
  };
80
72
  }
@@ -20,7 +20,7 @@ import {
20
20
  type EntityDefinition,
21
21
  type FeatureRegistrar,
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
- import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.ts";
23
+ import { assertNoWhereRuleInOwnershipWrite, resolveFeatureAccess } from "../shared/index.ts";
24
24
  import { createNoteEntryEntity, noteMentionEntity } from "./entity.ts";
25
25
  import { createNoteEntryExecutor } from "./executor.ts";
26
26
  import { createAddNoteHandler } from "./handlers/add-note.write.ts";
@@ -104,12 +104,6 @@ export type NotesHistoryFeatureOptions = {
104
104
  readonly parents?: readonly string[];
105
105
  };
106
106
 
107
- function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
108
- if (opts.access !== undefined) return opts.access;
109
- if (opts.roles !== undefined) return { roles: opts.roles };
110
- return DEFAULT_NOTES_HISTORY_ACCESS;
111
- }
112
-
113
107
  // Options wrapper. Without options returns the module-level singleton (no
114
108
  // rebuild). access/roles/ownership build a fresh feature-definition.
115
109
  export function createNotesHistoryFeature(
@@ -135,6 +129,11 @@ export function createNotesHistoryFeature(
135
129
  );
136
130
  }
137
131
  return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
138
- registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents),
132
+ registerNotesHistory(
133
+ r,
134
+ resolveFeatureAccess(opts, DEFAULT_NOTES_HISTORY_ACCESS),
135
+ opts.ownership,
136
+ opts.parents,
137
+ ),
139
138
  );
140
139
  }
@@ -4,6 +4,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import * as z from "zod";
5
5
  import { decryptStoredPii } from "../../shared/index.ts";
6
6
  import { isExpiredAt } from "../expiry.ts";
7
+ import { parseScopeNames } from "../parse-scope-names.ts";
7
8
  import { apiTokenTable } from "../schema/api-token.ts";
8
9
 
9
10
  // `sort` arrives raw from the client's query string. selectMany's orderBy has
@@ -28,15 +29,6 @@ function tokenStatus(row: {
28
29
  return "active";
29
30
  }
30
31
 
31
- function parseScopeNames(raw: string): string[] {
32
- try {
33
- const parsed: unknown = JSON.parse(raw);
34
- return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
35
- } catch {
36
- return [];
37
- }
38
- }
39
-
40
32
  // The caller's own tokens — metadata only, never the hash or plaintext.
41
33
  // Includes revoked/expired rows so the UI can show history; `prefix` is the
42
34
  // only fragment of the secret ever exposed.
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import { generateToken, PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
2
+ import { hashOpaqueTokenSync } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { PAT_PREFIX_DISPLAY_LENGTH } from "./web/constants.ts";
4
4
 
5
5
  // A PAT is high-entropy (32 random bytes from generateToken). A single SHA-256
@@ -8,7 +8,7 @@ import { PAT_PREFIX_DISPLAY_LENGTH } from "./web/constants.ts";
8
8
  // passwords). Only the hash is persisted — the plaintext exists once, in the
9
9
  // create response.
10
10
  export function hashPatToken(rawToken: string): string {
11
- return createHash("sha256").update(rawToken).digest("hex");
11
+ return hashOpaqueTokenSync(rawToken);
12
12
  }
13
13
 
14
14
  export function mintPatToken(): { raw: string; hash: string; prefix: string } {
@@ -0,0 +1,9 @@
1
+ // Drops non-string entries rather than rejecting the whole array.
2
+ export function parseScopeNames(raw: string): string[] {
3
+ try {
4
+ const parsed: unknown = JSON.parse(raw);
5
+ return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
6
+ } catch {
7
+ return [];
8
+ }
9
+ }