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