@cosmicdrift/kumiko-bundled-features 0.353.0 → 0.354.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 (181) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +9 -3
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/tool-dispatch.js +1 -1
  9. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  10. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  11. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  12. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  13. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  14. package/dist/auth-email-password/handlers/login.write.js +1 -0
  15. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  16. package/dist/auth-email-password/handlers/signup-confirm.write.js +4 -1
  17. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  18. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  19. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +2 -0
  22. package/dist/auth-email-password/seeding.d.ts +2 -0
  23. package/dist/auth-email-password/seeding.js +30 -8
  24. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  25. package/dist/auth-email-password/signup-activation.js +41 -0
  26. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  27. package/dist/auth-email-password/signup-token-store.js +8 -3
  28. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  29. package/dist/auth-email-password/web/auth-gate.js +5 -5
  30. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  31. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  32. package/dist/auth-email-password/web/login-screen.js +2 -2
  33. package/dist/auth-email-password/web/user-menu.js +1 -1
  34. package/dist/auth-mfa/feature.js +5 -0
  35. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  36. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  37. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  38. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  39. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  40. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  41. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  42. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  43. package/dist/channel-in-app/in-app-channel.js +1 -1
  44. package/dist/channel-in-app/tables.js +4 -2
  45. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  46. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  47. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  48. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  49. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  50. package/dist/delivery/delivery-service.js +60 -12
  51. package/dist/delivery/unsubscribe.js +66 -14
  52. package/dist/feature-toggles/feature.js +2 -3
  53. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  54. package/dist/feature-toggles/toggle-runtime.js +30 -6
  55. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  56. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  57. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  58. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  59. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  60. package/dist/files-tenant-data/hooks.js +58 -22
  61. package/dist/folders/handlers/delete-folder.write.js +31 -4
  62. package/dist/foundation-shared/index.d.ts +1 -1
  63. package/dist/foundation-shared/index.js +1 -1
  64. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  65. package/dist/foundation-shared/mail-host-policy.js +13 -4
  66. package/dist/inbound-provider-imap/feature.js +2 -0
  67. package/dist/inbound-provider-imap/imap-client.js +3 -4
  68. package/dist/jobs/constants.d.ts +1 -0
  69. package/dist/jobs/constants.js +1 -0
  70. package/dist/jobs/db/queries/retention.d.ts +1 -0
  71. package/dist/jobs/db/queries/retention.js +5 -0
  72. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  73. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  74. package/dist/jobs/feature.js +7 -1
  75. package/dist/jobs/handlers/list.query.js +1 -1
  76. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  77. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  78. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  79. package/dist/jobs/index.d.ts +2 -0
  80. package/dist/jobs/index.js +1 -0
  81. package/dist/jobs/job-run-logger.d.ts +2 -2
  82. package/dist/jobs/job-run-logger.js +98 -9
  83. package/dist/jobs/tenant-job-failure-table.js +15 -2
  84. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  85. package/dist/jobs/tenant-job-run-table.js +34 -0
  86. package/dist/ledger/entity.d.ts +3 -0
  87. package/dist/ledger/entity.js +13 -5
  88. package/dist/ledger/feature.js +3 -0
  89. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  90. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  91. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  92. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  93. package/dist/ledger/schedule-subject-pair.js +24 -0
  94. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  95. package/dist/mail-transport-smtp/feature.js +4 -13
  96. package/dist/notes-history/entity.js +4 -0
  97. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  98. package/dist/sessions/feature.d.ts +1 -0
  99. package/dist/sessions/feature.js +9 -8
  100. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  101. package/dist/sessions/handlers/detail.query.js +2 -2
  102. package/dist/sessions/handlers/list.query.d.ts +1 -1
  103. package/dist/sessions/handlers/list.query.js +5 -3
  104. package/dist/sessions/screens.d.ts +2 -2
  105. package/dist/sessions/screens.js +6 -7
  106. package/dist/shared/encrypt-for-direct-write.js +10 -2
  107. package/dist/shared/index.d.ts +2 -2
  108. package/dist/shared/index.js +2 -2
  109. package/dist/shared/parent-visibility.d.ts +2 -1
  110. package/dist/shared/parent-visibility.js +21 -0
  111. package/dist/shared/row-bound-grant.js +16 -2
  112. package/dist/shared/session-locale-field.d.ts +1 -0
  113. package/dist/shared/session-locale-field.js +6 -0
  114. package/dist/template-resolver/api.d.ts +2 -2
  115. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  116. package/dist/template-resolver/handlers/list.query.js +2 -2
  117. package/dist/template-resolver/table.d.ts +8 -0
  118. package/dist/template-resolver/user-content-table.d.ts +4 -0
  119. package/dist/tenant/feature.js +7 -1
  120. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  121. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +40 -0
  123. package/dist/tenant/invitation-table.js +6 -1
  124. package/dist/tenant/invited-membership.d.ts +8 -6
  125. package/dist/tenant/invited-membership.js +28 -7
  126. package/dist/tenant/schema/tenant.d.ts +2 -0
  127. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  128. package/dist/tenant-handover/transfer-graph.js +8 -75
  129. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  130. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  131. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  132. package/dist/tenant-lifecycle/constants.js +5 -0
  133. package/dist/tenant-lifecycle/events.d.ts +5 -0
  134. package/dist/tenant-lifecycle/events.js +5 -0
  135. package/dist/tenant-lifecycle/feature.js +5 -3
  136. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
  137. package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
  138. package/dist/tenant-lifecycle/stages.d.ts +6 -1
  139. package/dist/tenant-lifecycle/stages.js +26 -3
  140. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  141. package/dist/tenant-lifecycle/testing.js +42 -0
  142. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  143. package/dist/user/handlers/detail.query.js +2 -0
  144. package/dist/user/handlers/list.query.js +4 -0
  145. package/dist/user/handlers/update.write.js +1 -0
  146. package/dist/user/schema/user.d.ts +2 -0
  147. package/dist/user/seeding.d.ts +1 -0
  148. package/dist/user/seeding.js +20 -0
  149. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  150. package/dist/user-data-rights/index.d.ts +1 -0
  151. package/dist/user-data-rights/index.js +1 -0
  152. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  153. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  154. package/dist/user-profile/handlers/change-email.write.js +8 -2
  155. package/dist/waitlist/entity.d.ts +2 -0
  156. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  157. package/dist/workflow-runner/event-trigger.js +60 -15
  158. package/dist/workflow-runner/handlers/resume-run.write.js +5 -2
  159. package/dist/workflow-runner/runner.d.ts +2 -1
  160. package/dist/workflow-runner/runner.js +5 -1
  161. package/package.json +35 -10
  162. package/src/auth-email-password/changes.json +24 -0
  163. package/src/auth-mfa/changes.json +7 -0
  164. package/src/billing-foundation/changes.json +6 -0
  165. package/src/channel-in-app/changes.json +8 -1
  166. package/src/crypto-shredding/changes.json +12 -0
  167. package/src/delivery/changes.json +6 -0
  168. package/src/files/changes.json +24 -0
  169. package/src/files-tenant-data/changes.json +5 -0
  170. package/src/folders/changes.json +6 -0
  171. package/src/inbound-provider-imap/changes.json +6 -0
  172. package/src/jobs/changes.json +30 -0
  173. package/src/ledger/changes.json +23 -0
  174. package/src/notes-history/changes.json +13 -0
  175. package/src/template-resolver/changes.json +5 -0
  176. package/src/tenant/changes.json +12 -0
  177. package/src/tenant-handover/changes.json +6 -0
  178. package/src/tenant-lifecycle/changes.json +22 -0
  179. package/src/tier-engine/changes.json +5 -0
  180. package/src/user/changes.json +15 -0
  181. package/src/user-data-rights/changes.json +6 -0
@@ -0,0 +1,72 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
5
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
6
+ import * as z from "zod";
7
+ import { tenantJobRunsTable } from "../tenant-job-run-table.js";
8
+ const DEFAULT_LIMIT = 50;
9
+ function isSubjectEntry(value) {
10
+ return Array.isArray(value) && typeof value[0] === "string";
11
+ }
12
+ function parseSubject(stored) {
13
+ if (stored === null)
14
+ return null;
15
+ // A corrupt subject must not fail the whole list; parseJsonSafe only
16
+ // survives a SyntaxError, so the shape needs its own check.
17
+ const parsed = parseJsonSafe(stored, null);
18
+ if (!Array.isArray(parsed))
19
+ return null;
20
+ return Object.fromEntries(parsed.filter(isSubjectEntry));
21
+ }
22
+ export const tenantRunsQuery = defineQueryHandler({
23
+ name: "tenant-runs",
24
+ description: "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
25
+ schema: z.object({
26
+ jobName: z.string().optional(),
27
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
28
+ limit: z.number().int().min(1).max(200).optional(),
29
+ }),
30
+ // Every membership rank: a run state carries a job name, a status and
31
+ // times, nothing a team member of the tenant may not see.
32
+ access: { roles: access.roles("User", "Editor", ...access.admin) },
33
+ handler: async (query, ctx) => {
34
+ if (!ctx.systemDb) {
35
+ throw new InternalError({
36
+ message: "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
37
+ });
38
+ }
39
+ // assertTenantMatch returns the same unfiltered system-mode db — the
40
+ // explicit tenantId below is the filter, assertRowsTenant the second net.
41
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
42
+ const where = { tenantId: [query.user.tenantId] };
43
+ if (query.payload.jobName)
44
+ where["jobName"] = query.payload.jobName;
45
+ if (query.payload.subject)
46
+ where["subject"] = serializeJobSubject(query.payload.subject);
47
+ const limit = query.payload.limit ?? DEFAULT_LIMIT;
48
+ const newestFirst = { col: "updatedAt", direction: "desc" };
49
+ const activeRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["queued", "running"] } }, { orderBy: newestFirst, limit });
50
+ const finishedRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["completed", "failed"] } }, { orderBy: newestFirst, limit });
51
+ const seenFinishedKeys = new Set();
52
+ const finishedNewestPerKey = finishedRows.filter((row) => {
53
+ const key = JSON.stringify([row.jobName, row.subject]);
54
+ if (seenFinishedKeys.has(key))
55
+ return false;
56
+ seenFinishedKeys.add(key);
57
+ return true;
58
+ });
59
+ const activeFirst = [...activeRows, ...finishedNewestPerKey];
60
+ return {
61
+ rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
62
+ jobName: row.jobName,
63
+ subject: parseSubject(row.subject),
64
+ status: row.status,
65
+ queuedAt: row.queuedAt,
66
+ startedAt: row.startedAt,
67
+ finishedAt: row.finishedAt,
68
+ })),
69
+ nextCursor: null,
70
+ };
71
+ },
72
+ });
@@ -5,3 +5,5 @@ export { createJobRunLogger } from "./job-run-logger.js";
5
5
  export type { JobLogLevel, JobRunStatus } from "./job-run-table.js";
6
6
  export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
7
7
  export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
8
+ export type { TenantJobRunStatus } from "./tenant-job-run-table.js";
9
+ export { tenantJobRunsTable } from "./tenant-job-run-table.js";
@@ -3,3 +3,4 @@ export { createJobsFeature } from "./feature.js";
3
3
  export { createJobRunLogger } from "./job-run-logger.js";
4
4
  export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
5
5
  export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
6
+ export { tenantJobRunsTable } from "./tenant-job-run-table.js";
@@ -1,10 +1,10 @@
1
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
4
4
  export type JobRunLoggerOptions = {
5
5
  readonly db: DbConnection;
6
6
  readonly registry: Registry;
7
7
  };
8
- export type JobRunLoggerCallbacks = Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed">;
8
+ export type JobRunLoggerCallbacks = Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped">;
9
9
  export declare function encryptFailureError(error: string, triggeredById: string | null): Promise<string>;
10
10
  export declare function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks;
@@ -1,5 +1,6 @@
1
- import { deleteMany, fetchOne, insertMany, insertOne, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { deleteMany, fetchOne, insertMany, insertOnConflictDoNothing, insertOne, transaction, updateMany, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { configuredPiiSubjectKms, encryptPiiValueForSubject, KeyErasedError, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
3
4
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
5
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
5
6
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
@@ -8,6 +9,7 @@ import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.
8
9
  import { parseJobInstant } from "./job-instant.js";
9
10
  import { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
10
11
  import { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
12
+ import { tenantJobRunsTable } from "./tenant-job-run-table.js";
11
13
  // Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
12
14
  // bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
13
15
  // every connection in the pool.
@@ -92,6 +94,9 @@ async function encryptStartedPayload(payload, triggeredById) {
92
94
  return payload;
93
95
  return encryptOrSentinel(kms, triggeredById, payload, "payload");
94
96
  }
97
+ // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
98
+ // framework's other fixed advisory-lock keys.
99
+ const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
95
100
  // fw#3079 — which row the tenant-visible failure record lives in: one per
96
101
  // (tenant, job, subject), so the next outcome of the same work replaces or
97
102
  // clears it. `subject` is null for a job that declares no subjectFields.
@@ -112,14 +117,18 @@ async function recordTenantJobFailure(db, jobName, outcome) {
112
117
  // may still resolve.
113
118
  if (!where || !messageKey || outcome?.finalAttempt !== true)
114
119
  return;
115
- // ponytail: delete-then-insert instead of an upsert — two runs of the same
116
- // key finishing at once can leave two rows, and the query returns the
117
- // newest. Add a unique index + ON CONFLICT if that ever matters.
118
- await deleteMany(db, tenantJobFailuresTable, where);
119
- await insertOne(db, tenantJobFailuresTable, {
120
- ...where,
121
- messageKey,
122
- failedAt: Temporal.Now.instant(),
120
+ // Delete-then-insert under a per-key lock instead of an upsert: the two
121
+ // partial unique indexes (subject NULL vs. set) are no ON CONFLICT target.
122
+ // The lock makes the later of two racing final failures replace the earlier
123
+ // one; the indexes are the backstop against any writer that skips the lock.
124
+ await transaction(db, async (tx) => {
125
+ await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_FAILURE_LOCK_NAMESPACE, JSON.stringify([where["tenantId"], where["jobName"], where["subject"]]));
126
+ await deleteMany(tx, tenantJobFailuresTable, where);
127
+ await insertOne(tx, tenantJobFailuresTable, {
128
+ ...where,
129
+ messageKey,
130
+ failedAt: Temporal.Now.instant(),
131
+ });
123
132
  });
124
133
  }
125
134
  async function clearTenantJobFailure(db, jobName, outcome) {
@@ -129,7 +138,78 @@ async function clearTenantJobFailure(db, jobName, outcome) {
129
138
  return;
130
139
  await deleteMany(db, tenantJobFailuresTable, where);
131
140
  }
141
+ function tenantRunKey(jobName, tenantId, subject) {
142
+ // skip: tenant-less run (cron resolves to SYSTEM_TENANT_ID) — no tenant-scoped query could read it
143
+ if (tenantId === SYSTEM_TENANT_ID)
144
+ return null;
145
+ return { tenantId, jobName, subject };
146
+ }
147
+ async function recordTenantRunQueued(db, jobName, bullJobId, meta) {
148
+ const key = tenantRunKey(jobName, meta.tenantId, meta.subject);
149
+ // skip: system-tenant run, no tenant-scoped query reads it
150
+ if (!key)
151
+ return;
152
+ const now = Temporal.Now.instant();
153
+ // DO NOTHING: a fast worker can report the start before this enqueue hook
154
+ // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
155
+ await insertOnConflictDoNothing(db, tenantJobRunsTable, { ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"] });
156
+ }
157
+ async function recordTenantRunStarted(db, jobName, bullJobId, meta) {
158
+ // skip: job did not opt in to tenantVisibleRun
159
+ if (!meta.tenantVisibleRun)
160
+ return;
161
+ const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
162
+ // skip: system-tenant run, no tenant-scoped query reads it
163
+ if (!key)
164
+ return;
165
+ const now = Temporal.Now.instant();
166
+ await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: "running", startedAt: now, updatedAt: now }, {
167
+ conflictKeys: ["bullJobId"],
168
+ update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
169
+ });
170
+ }
171
+ async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
172
+ // skip: job did not opt in to tenantVisibleRun
173
+ if (!outcome?.tenantVisibleRun)
174
+ return;
175
+ const key = tenantRunKey(jobName, outcome.tenantId, outcome.tenantVisibleRun.subject);
176
+ // skip: system-tenant run, no tenant-scoped query reads it
177
+ if (!key)
178
+ return;
179
+ const now = Temporal.Now.instant();
180
+ // A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
181
+ // seeing pending work, not a failure the next attempt may still resolve.
182
+ if (result === "failed" && !outcome.finalAttempt) {
183
+ await updateMany(db, tenantJobRunsTable, { status: "queued", startedAt: null, finishedAt: null, updatedAt: now }, { bullJobId });
184
+ // skip: retry pending, the row stays active until the final attempt
185
+ return;
186
+ }
187
+ await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } });
188
+ await deleteMany(db, tenantJobRunsTable, {
189
+ ...key,
190
+ status: { in: ["completed", "failed"] },
191
+ bullJobId: { ne: bullJobId },
192
+ });
193
+ }
194
+ async function dropTenantRunQueued(db, bullJobId) {
195
+ await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
196
+ }
132
197
  const log = createFallbackLogger("job-run-logger");
198
+ // The tenant run state is an optional side write: a throw here must not
199
+ // fail the enqueue, skip the run-row write or turn a completed run into a
200
+ // BullMQ retry.
201
+ async function isolateTenantRunWrite(write, jobName, bullJobId) {
202
+ try {
203
+ await write();
204
+ }
205
+ catch (e) {
206
+ log.error("tenant job run state write failed", {
207
+ jobName,
208
+ bullJobId,
209
+ error: e instanceof Error ? e.message : String(e),
210
+ });
211
+ }
212
+ }
133
213
  // The tenant failure record is an optional side write: a throw here must not
134
214
  // skip the run-row update or turn a completed run into a BullMQ retry.
135
215
  async function isolateTenantFailureWrite(write, jobName, bullJobId) {
@@ -191,7 +271,14 @@ export function createJobRunLogger(opts) {
191
271
  return { runId, triggeredById };
192
272
  }
193
273
  return {
274
+ onJobQueued: async (jobName, bullJobId, meta) => {
275
+ await isolateTenantRunWrite(() => recordTenantRunQueued(db, jobName, bullJobId, meta), jobName, bullJobId);
276
+ },
277
+ onJobDropped: async (jobName, bullJobId) => {
278
+ await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
279
+ },
194
280
  onJobStart: async (jobName, bullJobId, meta) => {
281
+ await isolateTenantRunWrite(() => recordTenantRunStarted(db, jobName, bullJobId, meta), jobName, bullJobId);
195
282
  const runId = generateId();
196
283
  const triggeredById = meta.triggeredById ?? null;
197
284
  cachePut(bullJobId, runId, triggeredById);
@@ -227,6 +314,7 @@ export function createJobRunLogger(opts) {
227
314
  // clears the tenant's failure record even when the run row itself is
228
315
  // unreachable (the state-loss return below).
229
316
  await isolateTenantFailureWrite(() => clearTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
317
+ await isolateTenantRunWrite(() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "completed"), jobName, bullJobId);
230
318
  const resolved = await resolveRun(bullJobId);
231
319
  // skip: state loss between start + complete (worker restart, cache
232
320
  // evicted AND DB has no matching bull_job_id). Rare edge case; we
@@ -272,6 +360,7 @@ export function createJobRunLogger(opts) {
272
360
  // Mirror of onJobComplete: recorded independently of the run row, so a
273
361
  // tenant still learns their job failed if the row is unreachable.
274
362
  await isolateTenantFailureWrite(() => recordTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
363
+ await isolateTenantRunWrite(() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "failed"), jobName, bullJobId);
275
364
  const resolved = await resolveRun(bullJobId);
276
365
  // skip: same rare state-loss case as in onJobComplete — drop the
277
366
  // failure write rather than forge a run row from scratch.
@@ -1,5 +1,5 @@
1
1
  import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
2
+ import { index, instant, table as pgTable, serial, sql, text, uniqueIndex, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
3
  // The tenant's own view of a failed job (fw#3079). Direct-write store like
4
4
  // store_job_runs: job-run-logger.ts writes it from the BullMQ callbacks,
5
5
  // outside any dispatcher transaction.
@@ -21,5 +21,18 @@ export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
21
21
  subject: text("subject"),
22
22
  messageKey: text("message_key").notNull(),
23
23
  failedAt: instant("failed_at").default(sql `now()`).notNull(),
24
- });
24
+ }, (t) => [
25
+ // One row per key, also the lookup for the delete before every write;
26
+ // subject NULL needs its own partial index because NULLs
27
+ // are distinct in a plain unique index.
28
+ uniqueIndex("store_tenant_job_failures_tenant_job_subject_uidx")
29
+ .on(t.tenantId, t.jobName, t.subject)
30
+ .where(sql `subject IS NOT NULL`),
31
+ uniqueIndex("store_tenant_job_failures_tenant_job_null_subject_uidx")
32
+ .on(t.tenantId, t.jobName)
33
+ .where(sql `subject IS NULL`),
34
+ // The tenant list sorts by failed_at; retention filters on it alone.
35
+ index("store_tenant_job_failures_tenant_failed_at_idx").on(t.tenantId, t.failedAt),
36
+ index("store_tenant_job_failures_failed_at_idx").on(t.failedAt),
37
+ ]);
25
38
  export const tenantJobFailuresTableMeta = requireEntityTableMeta(tenantJobFailuresTable, "tenantJobFailuresTable");
@@ -0,0 +1,4 @@
1
+ import { type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
3
+ export declare const tenantJobRunsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
4
+ export declare const tenantJobRunsTableMeta: EntityTableMeta;
@@ -0,0 +1,34 @@
1
+ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { index, instant, table as pgTable, serial, sql, text, uniqueIndex, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
+ // The tenant's own view of a job's run state (fw#3616). Direct-write store
4
+ // like store_job_runs, written by job-run-logger.ts from the runner callbacks.
5
+ //
6
+ // store_job_runs cannot serve the tenant: it anchors every run on the system
7
+ // tenant, has no subject, and gets its row only when a run starts, so a queued
8
+ // job is invisible there. This table holds no payload, error text or logs by
9
+ // design — the tenant learns only that work is waiting, running or finished.
10
+ //
11
+ // One row per BullMQ job. Active rows (queued/running) are kept as they are;
12
+ // a finished row replaces earlier finished rows of the same (tenant, job,
13
+ // subject), so only the latest outcome stays. `subject` is the canonical JSON
14
+ // of the job's declared subjectFields, or NULL for none — stored in clear, so
15
+ // a job must not declare a PII field as its subject.
16
+ export const tenantJobRunsTable = pgTable("store_tenant_job_runs", {
17
+ id: serial("id").primaryKey(),
18
+ tenantId: uuid("tenant_id").notNull(),
19
+ jobName: text("job_name").notNull(),
20
+ subject: text("subject"),
21
+ bullJobId: text("bull_job_id").notNull(),
22
+ status: text("status").notNull(),
23
+ queuedAt: instant("queued_at"),
24
+ startedAt: instant("started_at"),
25
+ finishedAt: instant("finished_at"),
26
+ updatedAt: instant("updated_at").default(sql `now()`).notNull(),
27
+ }, (t) => [
28
+ uniqueIndex("store_tenant_job_runs_bull_job_id_unique").on(t.bullJobId),
29
+ // Finishing a run prunes older outcomes by this key; the tenant query reads by it.
30
+ index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
31
+ // Stale sweep and retention filter on age alone.
32
+ index("store_tenant_job_runs_updated_at_idx").on(t.updatedAt),
33
+ ]);
34
+ export const tenantJobRunsTableMeta = requireEntityTableMeta(tenantJobRunsTable, "tenantJobRunsTable");
@@ -42,6 +42,7 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
42
42
  readonly optionsQuery?: string;
43
43
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
44
44
  readonly optionsAvailabilityQuery?: string;
45
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"asset" | "equity" | "expense" | "income" | "liability">[] | undefined;
45
46
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
46
47
  required: true;
47
48
  };
@@ -167,6 +168,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
167
168
  readonly optionsQuery?: string;
168
169
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
169
170
  readonly optionsAvailabilityQuery?: string;
171
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"draft" | "posted">[] | undefined;
170
172
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
171
173
  required: true;
172
174
  };
@@ -299,6 +301,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
299
301
  readonly optionsQuery?: string;
300
302
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
301
303
  readonly optionsAvailabilityQuery?: string;
304
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"monthly">[] | undefined;
302
305
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
303
306
  required: true;
304
307
  };
@@ -94,6 +94,8 @@ export const transactionEntity = createEntity({
94
94
  filterable: true,
95
95
  }),
96
96
  },
97
+ // Subject ids are only unique within their type, so lookups filter on both
98
+ // columns; a subjectId-only filter would not be served by this index.
97
99
  indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
98
100
  });
99
101
  // schedule — a recurring booking template (Dauerauftrag): "book `amount` from
@@ -147,8 +149,8 @@ export const scheduleEntity = createEntity({
147
149
  filterable: true,
148
150
  }),
149
151
  // Caller-assigned stable key (e.g. one id per lease position) to find the
150
- // schedule again. Not unique: callers keep their own find-then-create;
151
- // `description` stays pure display text instead of a lookup key.
152
+ // schedule again; unique per tenant when set. `description` stays pure
153
+ // display text instead of a lookup key.
152
154
  sourceRef: createTextField({
153
155
  maxLength: 128,
154
156
  personal: false,
@@ -156,10 +158,16 @@ export const scheduleEntity = createEntity({
156
158
  filterable: true,
157
159
  }),
158
160
  },
159
- // A separate non-unique index: changing the subject index would drop and
160
- // recreate it, an additional one is a plain CREATE INDEX.
161
+ // Find-then-create by sourceRef races across tabs/devices; only the DB closes
162
+ // that gap. Partial because sourceRef is optional. A new name (not the old
163
+ // non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX.
161
164
  indexes: [
162
165
  { columns: ["tenantId", "subjectType", "subjectId"] },
163
- { columns: ["tenantId", "sourceRef"] },
166
+ {
167
+ columns: ["tenantId", "sourceRef"],
168
+ unique: true,
169
+ where: sql `"source_ref" IS NOT NULL`,
170
+ name: "read_ledger_schedules_tenant_id_source_ref_uidx",
171
+ },
164
172
  ],
165
173
  });
@@ -18,6 +18,7 @@ import { createConfirmSchedulePeriodHandler } from "./handlers/confirm-schedule-
18
18
  import { createCreateTransactionHandler } from "./handlers/create-transaction.write.js";
19
19
  import { createBalanceSheetHandler, createBalancesReportHandler, createIncomeStatementHandler, } from "./handlers/reports.query.js";
20
20
  import { createReverseTransactionHandler } from "./handlers/reverse-transaction.write.js";
21
+ import { requireScheduleSubjectPair } from "./schedule-subject-pair.js";
21
22
  function registerLedger(r, access, toggleable) {
22
23
  r.describe("Double-entry bookkeeping primitive. Owns two event-sourced entities — the per-tenant `account` chart of accounts (`read_ledger_accounts`, self-referential via parentId, typed asset/liability/equity/income/expense) and immutable `transaction` journal entries (`read_ledger_transactions`) whose balanced posting lines are embedded as jsonb (Σ amount = 0, signed integer minor units). The account catalog uses the generic entity handlers (create, update, list, detail); create-transaction books a balanced entry (Σ=0 and ≥2 distinct accounts enforced at the command boundary, referential integrity against accounts checked) and reverse-transaction books its Storno mirror — there is deliberately NO transaction update/delete, so a posted entry is an immutable fact and the audit trail stays intact. Recurring schedules (`read_ledger_schedules`) layer Dauerauftrag templates on top — a schedule names debit/credit accounts, an amount, a monthly interval and an optional caller-assigned `sourceRef` key (filterable, to find a schedule again), from which the Soll (forecast) is a pure projection (projectSchedule) needing no bookings, and confirm-schedule-period materialises one period as an idempotent, reversal-aware balanced entry referencing scheduleReference(id, period); only confirming writes. Balances and reports (balance sheet, P&L, cashflow) derive as pure queries over the postings. Everything financial — banking, accounting, rent cashflow, credits, invoices — models as accounts + balanced transactions on top. Pin roles with createLedgerFeature({ roles }) or adopt the host model with { access }; pass { toggleable: { default: false } } to tier-gate the whole feature.");
23
24
  r.uiHints({
@@ -72,6 +73,8 @@ function registerLedger(r, access, toggleable) {
72
73
  access,
73
74
  description: "Changes a recurring booking template's description, accounts, amount, interval or date window; use it when a standing order's terms change, already confirmed periods stay booked as they were.",
74
75
  }));
76
+ r.hook("preSave", "schedule:create", requireScheduleSubjectPair);
77
+ r.hook("preSave", "schedule:update", requireScheduleSubjectPair);
75
78
  r.writeHandler(createConfirmSchedulePeriodHandler(access));
76
79
  r.queryHandler(defineEntityListHandler("schedule", scheduleEntity, {
77
80
  access,
@@ -1,3 +1,4 @@
1
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
1
2
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
3
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
3
4
  import { DEFAULT_LEDGER_ACCESS } from "../constants.js";
@@ -5,6 +6,10 @@ import { accountExecutor, scheduleExecutor, transactionExecutor, transactionTabl
5
6
  import { findReversedIds, isoMonth, scheduleReference } from "../recurring.js";
6
7
  import { confirmSchedulePeriodPayloadSchema, } from "../schemas.js";
7
8
  import { readSubjectReference } from "../subject-reference.js";
9
+ // pg_advisory_xact_lock namespace (int4): 'lcsp' as ASCII, disjoint from the
10
+ // framework's other fixed advisory-lock keys.
11
+ const CONFIRM_PERIOD_LOCK_NAMESPACE = 0x6c637370;
12
+ const CONFIRM_PERIOD_LOCK_REASON = "takes the per-(tenant, reference) advisory lock so parallel confirms cannot both pass the already-booked check; TenantDb has no lock API";
8
13
  // confirm-schedule-period — turn ONE projected period of a schedule into a posted,
9
14
  // balanced transaction (debit +amount / credit −amount), tagged with
10
15
  // scheduleReference(scheduleId, period) so the host can merge Soll vs. Ist.
@@ -19,6 +24,7 @@ export function createConfirmSchedulePeriodHandler(access = DEFAULT_LEDGER_ACCES
19
24
  name: "confirm-schedule-period",
20
25
  schema: confirmSchedulePeriodPayloadSchema,
21
26
  access,
27
+ escapeHatch: { grants: ["unsafeRaw"], reason: CONFIRM_PERIOD_LOCK_REASON },
22
28
  description: "Turns one month of a recurring schedule into a posted balanced entry between its debit and credit account, returning the existing booking instead when that period is already booked and not reversed; use it to move a forecast period into the actual books.",
23
29
  handler: async (event, ctx) => {
24
30
  const payload = event.payload; // @cast-boundary engine-payload
@@ -42,9 +48,9 @@ export function createConfirmSchedulePeriodHandler(access = DEFAULT_LEDGER_ACCES
42
48
  // normally 0 or 1 row, never O(tenant's-full-history); (2) any Storno
43
49
  // that mirrors one of those candidates (a tx is reversed when ANOTHER
44
50
  // tx's `reference` names its id).
45
- // ponytail: read-then-write, so two confirms racing the same period could
46
- // double-book; add a unique index on (tenantId, reference) when concurrent
47
- // confirms become real.
51
+ // No unique index on (tenantId, reference): a Storno makes re-confirming the
52
+ // same reference legitimate, so the check-then-write is serialized by a lock.
53
+ await acquireNamespacedAdvisoryLock(ctx.db.unsafeRaw(), CONFIRM_PERIOD_LOCK_NAMESPACE, `${event.user.tenantId}:${reference}`);
48
54
  const candidates = await ctx.db.selectMany(transactionTable, {
49
55
  tenantId: event.user.tenantId,
50
56
  reference,
@@ -14,6 +14,7 @@ export function createCreateTransactionHandler(access = DEFAULT_LEDGER_ACCESS) {
14
14
  schema: createTransactionPayloadSchema,
15
15
  access,
16
16
  description: "Books one balanced journal entry from a date, description and at least two posting lines summing to zero, after verifying every named account exists; use it to record any financial movement in the tenant's books.",
17
+ agent: { risk: "high" },
17
18
  handler: async (event, ctx) => {
18
19
  const payload = event.payload; // @cast-boundary engine-payload
19
20
  for (const accountId of new Set(payload.lines.map((l) => l.accountId))) {
@@ -16,6 +16,7 @@ export function createReverseTransactionHandler(access = DEFAULT_LEDGER_ACCESS)
16
16
  schema: reverseTransactionPayloadSchema,
17
17
  access,
18
18
  description: "Books the mirror image of a posted journal entry as a new entry referencing the original, refusing entries that are not posted or already reversed; use it as the only way to correct a booking, since entries themselves are immutable.",
19
+ agent: { risk: "high" },
19
20
  handler: async (event, ctx) => {
20
21
  const payload = event.payload; // @cast-boundary engine-payload
21
22
  const original = await transactionExecutor.detail({ id: payload.id }, event.user, ctx.db);
@@ -0,0 +1,2 @@
1
+ import type { PreSaveHookFn } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare const requireScheduleSubjectPair: PreSaveHookFn;
@@ -0,0 +1,24 @@
1
+ import { ValidationError } from "@cosmicdrift/kumiko-framework/errors";
2
+ function isSet(value) {
3
+ return value !== undefined && value !== null && value !== "";
4
+ }
5
+ // subjectId is only unique within its subjectType, so a schedule with one of
6
+ // the two would be found by a lone-subjectId filter among other object types.
7
+ // The effective pair is checked (changes over the stored row) because an
8
+ // update may touch only one of the two fields.
9
+ export const requireScheduleSubjectPair = async (changes, ctx) => {
10
+ const effective = (key) => key in changes ? changes[key] : ctx.previous[key];
11
+ const hasType = isSet(effective("subjectType"));
12
+ const hasId = isSet(effective("subjectId"));
13
+ if (hasType === hasId)
14
+ return changes;
15
+ throw new ValidationError({
16
+ fields: [
17
+ {
18
+ path: hasType ? "subjectId" : "subjectType",
19
+ code: "required",
20
+ i18nKey: "errors.validation.required",
21
+ },
22
+ ],
23
+ });
24
+ };
@@ -1,8 +1,7 @@
1
1
  import type { lookup } from "node:dns/promises";
2
- import * as z from "zod";
3
- export declare const mailTransportSmtpEnvSchema: z.ZodObject<{
4
- KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS: z.ZodOptional<z.ZodString>;
5
- }, z.core.$strip>;
2
+ export declare const mailTransportSmtpEnvSchema: import("zod").ZodObject<{
3
+ KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS: import("zod").ZodOptional<import("zod").ZodString>;
4
+ }, import("zod/v4/core").$strip>;
6
5
  export declare function setSmtpMailHostLookup(fn: typeof lookup | undefined): void;
7
6
  export declare const mailTransportSmtpFeature: import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
8
7
  readonly exports: {
@@ -25,29 +25,20 @@
25
25
  // - `config` — für die Tenant-Config-Keys
26
26
  // - `secrets` — für das verschlüsselte SMTP-Password
27
27
  import { createSmtpTransport, } from "@cosmicdrift/kumiko-bundled-features/channel-email";
28
- import { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, readAllowedPrivateMailHostsFromEnv, requireDefined, requireNonEmpty, requireSecretSet, resolveMailConnectTarget, } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
28
+ import { BlockedHostError, HostResolutionError, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, requireDefined, requireNonEmpty, requireSecretSet, resolveMailConnectTarget, } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
29
29
  import { MAIL_TRANSPORT_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
30
30
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
31
31
  import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
32
32
  import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
33
33
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
34
- import * as z from "zod";
35
34
  const FEATURE_NAME = "mail-transport-smtp";
36
35
  const log = createFallbackLogger(FEATURE_NAME);
37
36
  // Operator escape hatch for an internal relay or a dev/test SMTP server
38
37
  // (mailpit, MailHog): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, an operator env
39
38
  // var never a tenant-config value, so a tenant can never grant themselves
40
- // the private-host bypass. Declared here (not duplicated in
41
- // inbound-provider-imap's envSchema) since both features read the same
42
- // var and composeEnvSchema rejects two features declaring the same key —
43
- // see foundation-shared/mail-host-policy.ts for the shared reader + the
44
- // guard this feeds.
45
- export const mailTransportSmtpEnvSchema = z.object({
46
- [MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
47
- .string()
48
- .optional()
49
- .describe("Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared with inbound-provider-imap — never a tenant-config value."),
50
- });
39
+ // the private-host bypass. The schema instance is shared with
40
+ // inbound-provider-imap (see foundation-shared/mail-host-policy.ts).
41
+ export const mailTransportSmtpEnvSchema = mailAllowedPrivateHostsEnvSchema;
51
42
  // Test-only DNS seam — production never calls this, resolveMailConnectTarget
52
43
  // defaults to the real resolver. Lets tests pin deterministic, network-free
53
44
  // host resolutions instead of depending on real DNS for a placeholder host.
@@ -81,6 +81,8 @@ export function createNoteEntryEntity(access, parents) {
81
81
  find: "none",
82
82
  }),
83
83
  },
84
+ // Every host detail screen reads its notes by tenant + host entity.
85
+ indexes: [{ columns: ["tenantId", "entityType", "entityId"] }],
84
86
  });
85
87
  }
86
88
  export const noteEntryEntity = createNoteEntryEntity();
@@ -109,6 +111,8 @@ export function createNoteMentionEntity() {
109
111
  }),
110
112
  subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
111
113
  },
114
+ // The forget cascade looks mentions up by subject within one tenant.
115
+ indexes: [{ columns: ["tenantId", "subjectId"] }],
112
116
  });
113
117
  }
114
118
  export const noteMentionEntity = createNoteMentionEntity();
@@ -41,6 +41,7 @@ export function createPatCreateHandler(opts = {}) {
41
41
  },
42
42
  },
43
43
  escapeHatch: {
44
+ grants: ["systemIdentity"],
44
45
  reason: "Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
45
46
  "user:findForAuth) to re-verify the current password before minting the token. Also " +
46
47
  "reads the MFA enrollment of the user being re-authenticated via the mfaVerifier callback.",
@@ -3,6 +3,7 @@ import { type SessionAllOthersRevoker, type SessionMassRevoker } from "./session
3
3
  export type SessionsFeatureOptions = {
4
4
  readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
5
5
  readonly expiresInMs?: number;
6
+ readonly adminAccess?: "admin" | "systemAdmin";
6
7
  };
7
8
  export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
8
9
  export type BindRevokeAllOtherSessions = (revoker: SessionAllOthersRevoker) => void;