@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.
- package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
- package/dist/agent-tools/agent-doc-lint.js +61 -1
- package/dist/agent-tools/agent-manifest.d.ts +1 -0
- package/dist/agent-tools/agent-manifest.js +9 -3
- package/dist/agent-tools/feature.js +7 -2
- package/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/tool-dispatch.js +1 -1
- package/dist/auth-email-password/handlers/change-password.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
- package/dist/auth-email-password/handlers/login.write.js +1 -0
- package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-confirm.write.js +4 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +2 -0
- package/dist/auth-email-password/seeding.d.ts +2 -0
- package/dist/auth-email-password/seeding.js +30 -8
- package/dist/auth-email-password/signup-activation.d.ts +24 -0
- package/dist/auth-email-password/signup-activation.js +41 -0
- package/dist/auth-email-password/signup-token-store.d.ts +5 -0
- package/dist/auth-email-password/signup-token-store.js +8 -3
- package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
- package/dist/auth-email-password/web/auth-gate.js +5 -5
- package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
- package/dist/auth-email-password/web/auth-redirect.js +14 -0
- package/dist/auth-email-password/web/login-screen.js +2 -2
- package/dist/auth-email-password/web/user-menu.js +1 -1
- package/dist/auth-mfa/feature.js +5 -0
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
- package/dist/auth-mfa/handlers/verify.write.js +1 -0
- package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
- package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
- package/dist/billing-foundation/billing-info-query.d.ts +5 -2
- package/dist/channel-in-app/in-app-channel.js +1 -1
- package/dist/channel-in-app/tables.js +4 -2
- package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
- package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
- package/dist/delivery/db/queries/address-opt-outs.js +12 -7
- package/dist/delivery/delivery-service.js +60 -12
- package/dist/delivery/unsubscribe.js +66 -14
- package/dist/feature-toggles/feature.js +2 -3
- package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
- package/dist/feature-toggles/toggle-runtime.js +30 -6
- package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
- package/dist/feature-toggles/toggle-sync-signal.js +29 -10
- package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
- package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
- package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
- package/dist/files-tenant-data/hooks.js +58 -22
- package/dist/folders/handlers/delete-folder.write.js +31 -4
- package/dist/foundation-shared/index.d.ts +1 -1
- package/dist/foundation-shared/index.js +1 -1
- package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
- package/dist/foundation-shared/mail-host-policy.js +13 -4
- package/dist/inbound-provider-imap/feature.js +2 -0
- package/dist/inbound-provider-imap/imap-client.js +3 -4
- package/dist/jobs/constants.d.ts +1 -0
- package/dist/jobs/constants.js +1 -0
- package/dist/jobs/db/queries/retention.d.ts +1 -0
- package/dist/jobs/db/queries/retention.js +5 -0
- package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
- package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
- package/dist/jobs/feature.js +7 -1
- package/dist/jobs/handlers/list.query.js +1 -1
- package/dist/jobs/handlers/tenant-failures.query.js +1 -1
- package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
- package/dist/jobs/handlers/tenant-runs.query.js +72 -0
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-run-logger.d.ts +2 -2
- package/dist/jobs/job-run-logger.js +98 -9
- package/dist/jobs/tenant-job-failure-table.js +15 -2
- package/dist/jobs/tenant-job-run-table.d.ts +4 -0
- package/dist/jobs/tenant-job-run-table.js +34 -0
- package/dist/ledger/entity.d.ts +3 -0
- package/dist/ledger/entity.js +13 -5
- package/dist/ledger/feature.js +3 -0
- package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
- package/dist/ledger/handlers/create-transaction.write.js +1 -0
- package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
- package/dist/ledger/schedule-subject-pair.d.ts +2 -0
- package/dist/ledger/schedule-subject-pair.js +24 -0
- package/dist/mail-transport-smtp/feature.d.ts +3 -4
- package/dist/mail-transport-smtp/feature.js +4 -13
- package/dist/notes-history/entity.js +4 -0
- package/dist/personal-access-tokens/handlers/create.write.js +1 -0
- package/dist/sessions/feature.d.ts +1 -0
- package/dist/sessions/feature.js +9 -8
- package/dist/sessions/handlers/detail.query.d.ts +1 -1
- package/dist/sessions/handlers/detail.query.js +2 -2
- package/dist/sessions/handlers/list.query.d.ts +1 -1
- package/dist/sessions/handlers/list.query.js +5 -3
- package/dist/sessions/screens.d.ts +2 -2
- package/dist/sessions/screens.js +6 -7
- package/dist/shared/encrypt-for-direct-write.js +10 -2
- package/dist/shared/index.d.ts +2 -2
- package/dist/shared/index.js +2 -2
- package/dist/shared/parent-visibility.d.ts +2 -1
- package/dist/shared/parent-visibility.js +21 -0
- package/dist/shared/row-bound-grant.js +16 -2
- package/dist/shared/session-locale-field.d.ts +1 -0
- package/dist/shared/session-locale-field.js +6 -0
- package/dist/template-resolver/api.d.ts +2 -2
- package/dist/template-resolver/handlers/list.query.d.ts +2 -0
- package/dist/template-resolver/handlers/list.query.js +2 -2
- package/dist/template-resolver/table.d.ts +8 -0
- package/dist/template-resolver/user-content-table.d.ts +4 -0
- package/dist/tenant/feature.js +7 -1
- package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
- package/dist/tenant/handlers/update-member-roles.write.js +1 -0
- package/dist/tenant/invitation-table.d.ts +40 -0
- package/dist/tenant/invitation-table.js +6 -1
- package/dist/tenant/invited-membership.d.ts +8 -6
- package/dist/tenant/invited-membership.js +28 -7
- package/dist/tenant/schema/tenant.d.ts +2 -0
- package/dist/tenant-handover/transfer-graph.d.ts +2 -21
- package/dist/tenant-handover/transfer-graph.js +8 -75
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +13 -0
- package/dist/tenant-lifecycle/constants.d.ts +3 -0
- package/dist/tenant-lifecycle/constants.js +5 -0
- package/dist/tenant-lifecycle/events.d.ts +5 -0
- package/dist/tenant-lifecycle/events.js +5 -0
- package/dist/tenant-lifecycle/feature.js +5 -3
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
- package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
- package/dist/tenant-lifecycle/stages.d.ts +6 -1
- package/dist/tenant-lifecycle/stages.js +26 -3
- package/dist/tenant-lifecycle/testing.d.ts +8 -0
- package/dist/tenant-lifecycle/testing.js +42 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/user/handlers/detail.query.js +2 -0
- package/dist/user/handlers/list.query.js +4 -0
- package/dist/user/handlers/update.write.js +1 -0
- package/dist/user/schema/user.d.ts +2 -0
- package/dist/user/seeding.d.ts +1 -0
- package/dist/user/seeding.js +20 -0
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -0
- package/dist/user-data-rights/index.js +1 -0
- package/dist/user-data-rights/schema/download-attempt.js +17 -9
- package/dist/user-data-rights/schema/export-job.d.ts +2 -0
- package/dist/user-profile/handlers/change-email.write.js +8 -2
- package/dist/waitlist/entity.d.ts +2 -0
- package/dist/workflow-runner/event-trigger.d.ts +1 -0
- package/dist/workflow-runner/event-trigger.js +60 -15
- package/dist/workflow-runner/handlers/resume-run.write.js +5 -2
- package/dist/workflow-runner/runner.d.ts +2 -1
- package/dist/workflow-runner/runner.js +5 -1
- package/package.json +35 -10
- package/src/auth-email-password/changes.json +24 -0
- package/src/auth-mfa/changes.json +7 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-in-app/changes.json +8 -1
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +6 -0
- package/src/files/changes.json +24 -0
- package/src/files-tenant-data/changes.json +5 -0
- package/src/folders/changes.json +6 -0
- package/src/inbound-provider-imap/changes.json +6 -0
- package/src/jobs/changes.json +30 -0
- package/src/ledger/changes.json +23 -0
- package/src/notes-history/changes.json +13 -0
- package/src/template-resolver/changes.json +5 -0
- package/src/tenant/changes.json +12 -0
- package/src/tenant-handover/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +22 -0
- package/src/tier-engine/changes.json +5 -0
- package/src/user/changes.json +15 -0
- 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
|
+
});
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/jobs/index.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
await
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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");
|
package/dist/ledger/entity.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/ledger/entity.js
CHANGED
|
@@ -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
|
|
151
|
-
//
|
|
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
|
-
//
|
|
160
|
-
//
|
|
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
|
-
{
|
|
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
|
});
|
package/dist/ledger/feature.js
CHANGED
|
@@ -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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
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,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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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,
|
|
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.
|
|
41
|
-
// inbound-provider-imap
|
|
42
|
-
|
|
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;
|