@cosmicdrift/kumiko-samples 0.388.0 → 0.389.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 +1 -1
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/index.ts +1 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +42 -26
- package/packages/bundled-features/src/audit/feature.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
- package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +20 -7
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
- package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/index.ts +5 -0
- package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
- package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
- package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
- package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
- package/packages/bundled-features/src/jobs/feature.ts +1 -0
- package/packages/bundled-features/src/ledger/web/index.ts +1 -1
- package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
- package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
- package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
- package/packages/bundled-features/src/managed-pages/index.ts +1 -0
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -0
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
- package/packages/bundled-features/src/sessions/changes.json +7 -0
- package/packages/bundled-features/src/sessions/feature.ts +1 -0
- package/packages/bundled-features/src/sessions/index.ts +1 -1
- package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
- package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
- package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
- package/packages/bundled-features/src/tags/feature.ts +1 -0
- package/packages/bundled-features/src/template-resolver/changes.json +36 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +30 -5
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
- package/packages/bundled-features/src/template-resolver/seeding.ts +10 -3
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +52 -8
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/tenant/feature.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
- package/packages/bundled-features/src/user/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
- package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
- package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +27 -8
- package/packages/bundled-features/src/waitlist/import.ts +23 -3
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +2 -1
- package/packages/bundled-features/src/waitlist/options.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
- package/samples/apps/styleguide/src/app/client.tsx +2 -0
- package/samples/apps/styleguide/src/app/seed.ts +6 -4
- package/samples/apps/styleguide/src/app/server.ts +4 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
- package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
- package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
- package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
- package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
- package/samples/apps/use-all-bundled/bin/main.ts +2 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +16 -0
- package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
- package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
- package/samples/apps/use-all-bundled/package.json +2 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
- package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
- package/samples/recipes/golden-path/src/feature.ts +2 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -0
- package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
- package/samples/recipes/list-table-options/README.md +50 -0
- package/samples/recipes/list-table-options/package.json +11 -0
- package/samples/recipes/list-table-options/src/feature.ts +221 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
- package/samples/recipes/state-machine/src/events.ts +3 -2
- package/samples/recipes/tenant-isolation/README.md +1 -0
- package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
|
@@ -13,6 +13,7 @@ import { type FormEvent, type ReactNode, useState } from "react";
|
|
|
13
13
|
import { resetPassword } from "./auth-client.ts";
|
|
14
14
|
import { passwordPairIssue } from "./auth-form-logic.ts";
|
|
15
15
|
import { AuthCard, useUrlToken } from "./auth-form-primitives.tsx";
|
|
16
|
+
import { buildLoginRedirectUrl, readReturnToFromWindow } from "./auth-redirect.ts";
|
|
16
17
|
|
|
17
18
|
export type ResetPasswordScreenProps = {
|
|
18
19
|
readonly title?: string;
|
|
@@ -37,6 +38,7 @@ export function ResetPasswordScreen({
|
|
|
37
38
|
const [submitting, setSubmitting] = useState(false);
|
|
38
39
|
const [done, setDone] = useState(false);
|
|
39
40
|
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
const [afterResetHref, setAfterResetHref] = useState(loginHref);
|
|
40
42
|
|
|
41
43
|
const doSubmit = async (): Promise<void> => {
|
|
42
44
|
setError(null);
|
|
@@ -53,6 +55,10 @@ export function ResetPasswordScreen({
|
|
|
53
55
|
const res = await resetPassword(token, newPassword);
|
|
54
56
|
setSubmitting(false);
|
|
55
57
|
if (res.ok) {
|
|
58
|
+
const returnTo = readReturnToFromWindow();
|
|
59
|
+
if (returnTo !== null) {
|
|
60
|
+
setAfterResetHref(buildLoginRedirectUrl(loginHref, returnTo, window.location.origin));
|
|
61
|
+
}
|
|
56
62
|
setDone(true);
|
|
57
63
|
return;
|
|
58
64
|
}
|
|
@@ -97,7 +103,7 @@ export function ResetPasswordScreen({
|
|
|
97
103
|
<p className="font-medium text-foreground">{t("auth.resetPassword.successTitle")}</p>
|
|
98
104
|
<p className="mt-1">{t("auth.resetPassword.successBody")}</p>
|
|
99
105
|
</Banner>
|
|
100
|
-
<Link href={
|
|
106
|
+
<Link href={afterResetHref} variant="button">
|
|
101
107
|
{t("auth.resetPassword.goToLogin")}
|
|
102
108
|
</Link>
|
|
103
109
|
</>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Shared by the server (mail link) and the web pages (landing page), so it is plain string logic:
|
|
2
|
+
// no DOM, no window, no `new URL` (React Native's URL getters throw).
|
|
3
|
+
|
|
4
|
+
export const RETURN_TO_QUERY_PARAM = "returnTo";
|
|
5
|
+
export const MAX_RETURN_PATH_LENGTH = 2048;
|
|
6
|
+
|
|
7
|
+
const DOT_SEGMENTS = new Set([".", "..", ".%2e", "%2e.", "%2e%2e", "%2e"]);
|
|
8
|
+
|
|
9
|
+
function hasForbiddenCharacter(candidate: string): boolean {
|
|
10
|
+
for (let i = 0; i < candidate.length; i++) {
|
|
11
|
+
const code = candidate.charCodeAt(i);
|
|
12
|
+
if (code <= 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return true;
|
|
13
|
+
if (code === 0x5c) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A dot segment is resolved by URL normalization, so "/.//evil" would become "//evil".
|
|
19
|
+
function hasDotSegment(candidate: string): boolean {
|
|
20
|
+
const pathEnd = candidate.search(/[?#]/);
|
|
21
|
+
const path = pathEnd === -1 ? candidate : candidate.slice(0, pathEnd);
|
|
22
|
+
return path.split("/").some((segment) => DOT_SEGMENTS.has(segment.toLowerCase()));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isSafeReturnPath(candidate: unknown): candidate is string {
|
|
26
|
+
if (typeof candidate !== "string") return false;
|
|
27
|
+
if (candidate.length < 1 || candidate.length > MAX_RETURN_PATH_LENGTH) return false;
|
|
28
|
+
if (!candidate.startsWith("/") || candidate[1] === "/") return false;
|
|
29
|
+
return !hasForbiddenCharacter(candidate) && !hasDotSegment(candidate);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readReturnToFromSearch(search: string): string | null {
|
|
33
|
+
const candidate = new URLSearchParams(search).get(RETURN_TO_QUERY_PARAM);
|
|
34
|
+
return isSafeReturnPath(candidate) ? candidate : null;
|
|
35
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
useSessionEndedSignal,
|
|
15
15
|
} from "@cosmicdrift/kumiko-renderer";
|
|
16
16
|
import { readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
|
|
17
|
+
import { clearRememberedListState } from "@cosmicdrift/kumiko-renderer/web";
|
|
17
18
|
import {
|
|
18
19
|
createContext,
|
|
19
20
|
type ReactNode,
|
|
@@ -216,6 +217,8 @@ export function SessionProvider({
|
|
|
216
217
|
|
|
217
218
|
const logout = useCallback<SessionApi["logout"]>(async () => {
|
|
218
219
|
await logoutApi();
|
|
220
|
+
// The reload below would hide the identity change from the list-state boot.
|
|
221
|
+
clearRememberedListState();
|
|
219
222
|
if (postLogoutUrl !== undefined && typeof window !== "undefined") {
|
|
220
223
|
// State stays untouched: flipping to unauthenticated would let an AuthGate
|
|
221
224
|
// with loginUrl race this navigation to its own redirect.
|
|
@@ -251,6 +254,7 @@ export function SessionProvider({
|
|
|
251
254
|
const userEmail = profile?.email;
|
|
252
255
|
const userDisplayName = profile?.displayName;
|
|
253
256
|
const userTimeZone = profile?.effectiveTimeZone;
|
|
257
|
+
const activeTenantId = state.activeTenantId ?? undefined;
|
|
254
258
|
const currentUser = useMemo<CurrentUser | undefined>(
|
|
255
259
|
() =>
|
|
256
260
|
userId === undefined || userEmail === undefined || userDisplayName === undefined
|
|
@@ -260,8 +264,9 @@ export function SessionProvider({
|
|
|
260
264
|
email: userEmail,
|
|
261
265
|
displayName: userDisplayName,
|
|
262
266
|
...(userTimeZone !== undefined && { timeZone: userTimeZone }),
|
|
267
|
+
...(activeTenantId !== undefined && { tenantId: activeTenantId }),
|
|
263
268
|
},
|
|
264
|
-
[userId, userEmail, userDisplayName, userTimeZone],
|
|
269
|
+
[userId, userEmail, userDisplayName, userTimeZone, activeTenantId],
|
|
265
270
|
);
|
|
266
271
|
return (
|
|
267
272
|
<SessionContext.Provider value={api}>
|
|
@@ -21,7 +21,7 @@ import { type FormEvent, type ReactNode, useState } from "react";
|
|
|
21
21
|
import { confirmSignup, type SignupConfirmSuccess } from "./auth-client.ts";
|
|
22
22
|
import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic.ts";
|
|
23
23
|
import { AuthCard, useUrlToken } from "./auth-form-primitives.tsx";
|
|
24
|
-
import { buildLoginRedirectUrl } from "./auth-redirect.ts";
|
|
24
|
+
import { buildLoginRedirectUrl, readReturnToFromWindow } from "./auth-redirect.ts";
|
|
25
25
|
|
|
26
26
|
export type SignupCompleteScreenProps = {
|
|
27
27
|
readonly title?: string;
|
|
@@ -74,15 +74,23 @@ export function SignupCompleteScreen({
|
|
|
74
74
|
const res = await confirmSignup(token, password);
|
|
75
75
|
setSubmitting(false);
|
|
76
76
|
if (res.ok) {
|
|
77
|
+
// The page the signup started from beats the server's landing path.
|
|
78
|
+
const returnTo = readReturnToFromWindow();
|
|
77
79
|
if (res.data.kind === "mfa-pending") {
|
|
78
80
|
setMfaPending(true);
|
|
81
|
+
const next = returnTo ?? res.data.landingPath;
|
|
79
82
|
setContinueHref(
|
|
80
|
-
|
|
83
|
+
next === undefined
|
|
81
84
|
? loginHref
|
|
82
|
-
: buildLoginRedirectUrl(loginHref,
|
|
85
|
+
: buildLoginRedirectUrl(loginHref, next, window.location.origin),
|
|
83
86
|
);
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
89
|
+
// A claimed handover keeps its app-defined target.
|
|
90
|
+
if (returnTo !== null && res.data.handover === undefined) {
|
|
91
|
+
setContinueHref(returnTo);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
86
94
|
// Cookies are already set (auto-login). Show a confirmation with an
|
|
87
95
|
// explicit continue button instead of navigating away silently —
|
|
88
96
|
// the user otherwise gets no signal that activation worked.
|
|
@@ -16,6 +16,8 @@ import { type FormEvent, type ReactNode, useState } from "react";
|
|
|
16
16
|
import { requestSignup } from "./auth-client.ts";
|
|
17
17
|
import { retryAfterMinutes } from "./auth-form-logic.ts";
|
|
18
18
|
import { AuthCard } from "./auth-form-primitives.tsx";
|
|
19
|
+
import { readReturnToFromWindow } from "./auth-redirect.ts";
|
|
20
|
+
import { isSafeReturnPath } from "./return-path.ts";
|
|
19
21
|
|
|
20
22
|
export type SignupScreenProps = {
|
|
21
23
|
readonly title?: string;
|
|
@@ -23,12 +25,20 @@ export type SignupScreenProps = {
|
|
|
23
25
|
/** href for the "Already have an account?" link. App-specific; defaults to
|
|
24
26
|
* "/login". */
|
|
25
27
|
readonly loginHref?: string;
|
|
28
|
+
/** Page the activation mail should lead to after signup; defaults to the `returnTo` query
|
|
29
|
+
* parameter of this page. Unsafe values are dropped. */
|
|
30
|
+
readonly returnTo?: string;
|
|
26
31
|
};
|
|
27
32
|
|
|
33
|
+
function resolveReturnTo(prop: string | undefined): string | undefined {
|
|
34
|
+
return (isSafeReturnPath(prop) ? prop : readReturnToFromWindow()) ?? undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
export function SignupScreen({
|
|
29
38
|
title,
|
|
30
39
|
subtitle,
|
|
31
40
|
loginHref = "/login",
|
|
41
|
+
returnTo,
|
|
32
42
|
}: SignupScreenProps): ReactNode {
|
|
33
43
|
const t = useTranslation();
|
|
34
44
|
const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
|
|
@@ -41,7 +51,7 @@ export function SignupScreen({
|
|
|
41
51
|
setSubmitting(true);
|
|
42
52
|
setError(null);
|
|
43
53
|
try {
|
|
44
|
-
const res = await requestSignup(email);
|
|
54
|
+
const res = await requestSignup(email, undefined, resolveReturnTo(returnTo));
|
|
45
55
|
if (res.ok) {
|
|
46
56
|
setDone(true);
|
|
47
57
|
} else if (res.error.reason === "rate_limited") {
|
|
@@ -73,7 +83,7 @@ export function SignupScreen({
|
|
|
73
83
|
setSubmitting(true);
|
|
74
84
|
setError(null);
|
|
75
85
|
try {
|
|
76
|
-
await requestSignup(email);
|
|
86
|
+
await requestSignup(email, undefined, resolveReturnTo(returnTo));
|
|
77
87
|
} finally {
|
|
78
88
|
setSubmitting(false);
|
|
79
89
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "requiredPolicy \"systemAdmins\"",
|
|
6
|
+
"detail": "The new auth-mfa:config:required value systemAdmins (between optional and admins) forces MFA enrollment only for users whose merged global and tenant roles contain SystemAdmin. TenantAdmins and other roles log in as before; enrolled users are always challenged regardless of policy. No locale labels are involved, the option list has none."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -2,19 +2,15 @@
|
|
|
2
2
|
// they haven't opted in themselves? Default "optional" means the feature
|
|
3
3
|
// is fully opt-in (existing tenants/users unaffected — no migration step).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// mfa-status-checker.ts
|
|
7
|
-
// for a blocked user to complete enrollment yet (that flow ships in PR3's
|
|
8
|
-
// UI). Flipping this away from "optional" before PR3 locks out every
|
|
9
|
-
// unenrolled matching user with no recovery path. Fine for a tenant that
|
|
10
|
-
// enrolls its admins out-of-band first; a footgun otherwise.
|
|
5
|
+
// Every non-optional policy blocks login for an unenrolled matching user until
|
|
6
|
+
// they finish the pre-auth enrollment step (mfa-status-checker.ts setupRequired).
|
|
11
7
|
import {
|
|
12
8
|
access,
|
|
13
9
|
type ConfigKeyHandle,
|
|
14
10
|
createTenantConfig,
|
|
15
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
12
|
|
|
17
|
-
export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"] as const;
|
|
13
|
+
export const MFA_REQUIRED_POLICIES = ["optional", "systemAdmins", "admins", "all"] as const;
|
|
18
14
|
export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
|
|
19
15
|
|
|
20
16
|
export const mfaRequiredConfigHandle: ConfigKeyHandle<"select"> = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
1
2
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
@@ -31,8 +32,8 @@ export type MfaStatusChecker = (
|
|
|
31
32
|
ctx: HandlerContext,
|
|
32
33
|
userId: string,
|
|
33
34
|
tenantId: TenantId,
|
|
34
|
-
// Merged global+tenant roles — needed to evaluate the "admins"
|
|
35
|
-
//
|
|
35
|
+
// Merged global+tenant roles — needed to evaluate the "admins" and "systemAdmins"
|
|
36
|
+
// policies. Must be the MERGED set (see login.write.ts caller): a
|
|
36
37
|
// SystemAdmin whose admin-ness lives only in global roles would be
|
|
37
38
|
// missed if the caller passed tenant-membership roles alone.
|
|
38
39
|
roles: readonly string[],
|
|
@@ -84,9 +85,11 @@ export function createMfaStatusChecker(opts: {
|
|
|
84
85
|
opts.challengeTokenSecret,
|
|
85
86
|
).token;
|
|
86
87
|
if (policy === "all") return { setupRequired: true, preauthSetupToken: buildSetupToken() };
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const isEnforced =
|
|
89
|
+
policy === "systemAdmins"
|
|
90
|
+
? roles.includes(ROLES.SystemAdmin)
|
|
91
|
+
: roles.some((role) => access.admin.includes(role));
|
|
92
|
+
return isEnforced
|
|
90
93
|
? { setupRequired: true, preauthSetupToken: buildSetupToken() }
|
|
91
94
|
: { required: false };
|
|
92
95
|
};
|
|
@@ -350,6 +350,21 @@ function bookCalendarDelta(
|
|
|
350
350
|
);
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
// Asked after a missed or lost claim. A released tombstone committed since the first read does not
|
|
354
|
+
// count: the caller's own tombstone insert then conflicts with it, and the retry claims that row.
|
|
355
|
+
async function hasSettledTombstone(
|
|
356
|
+
txDb: TenantDb,
|
|
357
|
+
user: SessionUser,
|
|
358
|
+
id: string,
|
|
359
|
+
): Promise<boolean> {
|
|
360
|
+
const settled = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
|
|
361
|
+
id,
|
|
362
|
+
tenantId: user.tenantId,
|
|
363
|
+
settledAt: { ne: null },
|
|
364
|
+
});
|
|
365
|
+
return settled !== undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
353
368
|
async function settleInTransaction(
|
|
354
369
|
txDb: TenantDb,
|
|
355
370
|
user: SessionUser,
|
|
@@ -386,12 +401,10 @@ async function settleInTransaction(
|
|
|
386
401
|
);
|
|
387
402
|
return booked;
|
|
388
403
|
}
|
|
389
|
-
|
|
390
|
-
if (await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow)) {
|
|
391
|
-
return NOTHING_TO_RELEASE;
|
|
392
|
-
}
|
|
404
|
+
if (await hasSettledTombstone(txDb, user, id)) return NOTHING_TO_RELEASE;
|
|
393
405
|
// Row swept or released: its amount was given back, so the full actual amount is booked and a
|
|
394
|
-
// tombstone makes a repeated settle a no-op. A parallel settle inserting first fails
|
|
406
|
+
// tombstone makes a repeated settle a no-op. A parallel settle or consume inserting first fails
|
|
407
|
+
// this insert.
|
|
395
408
|
// Booking first: a lost counter race returns a result instead of throwing, so the transaction
|
|
396
409
|
// commits and a tombstone written before it would turn the retry into a no-op.
|
|
397
410
|
const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount);
|
|
@@ -497,7 +510,7 @@ async function consumeClaimedRow(
|
|
|
497
510
|
}
|
|
498
511
|
|
|
499
512
|
// Row already expired away: the units are booked directly and a released tombstone remembers them
|
|
500
|
-
// so a later settle subtracts them. A parallel consume inserting first fails this insert.
|
|
513
|
+
// so a later settle subtracts them. A parallel consume or settle inserting first fails this insert.
|
|
501
514
|
async function consumeWithoutRow(
|
|
502
515
|
txDb: TenantDb,
|
|
503
516
|
user: SessionUser,
|
|
@@ -542,7 +555,7 @@ async function consumeInTransaction(
|
|
|
542
555
|
}
|
|
543
556
|
const claimed = open && (await claimReservationRow(txDb, user, open));
|
|
544
557
|
if (claimed) return consumeClaimedRow(txDb, user, claimed, options);
|
|
545
|
-
if (await txDb
|
|
558
|
+
if (await hasSettledTombstone(txDb, user, id)) {
|
|
546
559
|
throw new CapReservationSettledError(options.capName);
|
|
547
560
|
}
|
|
548
561
|
return consumeWithoutRow(txDb, user, id, options);
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Settle and consume racing on a swept reservation book their units",
|
|
6
|
+
"detail": "After the sweep deleted a reservation's row, a consume could commit its released tombstone between the two reads of a parallel settle or consume. That call took the released tombstone for a settled one, so settle reported success without booking its amount and a second consume was rejected with cap_reservation_settled. Only a settled tombstone counts as settled now; a released one runs into the insert conflict and the retry books against it."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Settle after a sweep survives a lost counter race",
|
|
12
|
+
"detail": "The amount is booked first and the tombstone written afterwards, so a version conflict on the counter retries the whole settle instead of leaving a tombstone that turns the retry into a no-op and drops the usage from the cap."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.388.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -71,6 +71,7 @@ export function createCapCounterFeature() {
|
|
|
71
71
|
return defineFeature(
|
|
72
72
|
CAP_COUNTER_FEATURE,
|
|
73
73
|
(r) => {
|
|
74
|
+
r.adoptsBundledLists();
|
|
74
75
|
r.describe(
|
|
75
76
|
"Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.",
|
|
76
77
|
);
|
|
@@ -61,6 +61,7 @@ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): Featur
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
|
|
64
|
+
r.adoptsBundledLists();
|
|
64
65
|
r.describe(
|
|
65
66
|
"Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; tenant admins (or the roles in `usageVisibleTo`) get their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
|
|
66
67
|
);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "config:write:set passes readCascade to validators",
|
|
6
|
+
"detail": "The set handler binds readCascade to the config resolver, the tenant-scoped db and the secrets reader, so validators see the same cascade as the value queries."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
writeFailure,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
20
20
|
import * as z from "zod";
|
|
21
|
-
import { requireConfigEncryption, requireSystemDb } from "../feature.ts";
|
|
21
|
+
import { requireConfigEncryption, requireConfigResolver, requireSystemDb } from "../feature.ts";
|
|
22
22
|
import { MASKED } from "../read-redaction.ts";
|
|
23
23
|
import { configValueEntity, configValuesTable } from "../table.ts";
|
|
24
24
|
import {
|
|
@@ -106,13 +106,30 @@ export const setWrite = defineWriteHandler({
|
|
|
106
106
|
if (patternError) return writeFailure(patternError);
|
|
107
107
|
|
|
108
108
|
if (keyDef.validate) {
|
|
109
|
+
const validateDb = scopeTenantDbToTenant(db, tenantId);
|
|
109
110
|
try {
|
|
110
111
|
await keyDef.validate(event.payload.value, {
|
|
111
112
|
key: event.payload.key,
|
|
112
113
|
scope,
|
|
113
114
|
tenantId,
|
|
114
115
|
userId,
|
|
115
|
-
db:
|
|
116
|
+
db: validateDb,
|
|
117
|
+
readCascade: (qualifiedKey) => {
|
|
118
|
+
const cascadeKeyDef = ctx.registry.getConfigKey(qualifiedKey);
|
|
119
|
+
if (!cascadeKeyDef) {
|
|
120
|
+
throw new InternalError({
|
|
121
|
+
message: `[config:write:set] readCascade: unknown config key "${qualifiedKey}"`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return requireConfigResolver(ctx, "config:write:set").getCascade(
|
|
125
|
+
qualifiedKey,
|
|
126
|
+
cascadeKeyDef,
|
|
127
|
+
tenantId,
|
|
128
|
+
event.user.id,
|
|
129
|
+
validateDb,
|
|
130
|
+
ctx.secrets,
|
|
131
|
+
);
|
|
132
|
+
},
|
|
116
133
|
});
|
|
117
134
|
} catch (error) {
|
|
118
135
|
if (isKumikoError(error)) return writeFailure(error);
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Tests pin the erasure write purpose in forget-subject and the forget cleanup",
|
|
6
|
+
"detail": "A pre-KMS user whose key was already erased still ends deleted, loses its tokens and gets the erased sentinel in the update event."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "The subject file purge reads candidate rows in pages",
|
|
12
|
+
"detail": "A user owning many records no longer loads them all into memory at once; every page is purged, not only the first."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.388.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -562,7 +562,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
562
562
|
// - user.updated as lifecycle event (updateUserLifecycle), so a
|
|
563
563
|
// read_users rebuild doesn't wipe the flip
|
|
564
564
|
// - sessions don't need active revocation — session-callbacks
|
|
565
|
-
// re-validate user
|
|
565
|
+
// re-validate the user row on every request (isPrincipalRowBlocked
|
|
566
566
|
// blocks Deleted).
|
|
567
567
|
// Both idempotent (status set / revokedAt IS NULL filter), retry after
|
|
568
568
|
// crash recovery is safe. User-feature guard: without the user feature
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Centralised parser for `fieldDefinition.serializedField`.
|
|
2
2
|
//
|
|
3
3
|
// The column is stored as `text` in B1 (entity.ts) — what the db row hands
|
|
4
|
-
// back depends on the driver:
|
|
4
|
+
// back depends on the driver: text normally arrives as a `string`, but
|
|
5
5
|
// jsonb-tolerant drivers and middleware can deliver an already-parsed
|
|
6
6
|
// object. This helper normalises both, validates the shape, and centralises
|
|
7
7
|
// the single boundary cast so callers don't sprinkle `as { … }`-narrowings
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Unsubscribe link origin is looked up once per notify call",
|
|
6
|
+
"detail": "The tenantOrigin callback runs once per tenant for a notify call with many recipients, and a fallback warning is logged once. A callback that hangs is still not guarded by a timeout, so keep it fast."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "buildSystemChannelContext for system tenant mails outside a handler",
|
|
12
|
+
"detail": "buildSystemChannelContext(deps, { config, secrets }) returns a ChannelContext for the system tenant whose readAttachment reads file and inline attachments. SystemWireDeps (wire hook and runWorkerApp wireComponents) has a new required filesForTenant that deps can pass straight in."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.388.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -62,6 +62,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
62
62
|
const resolvedAccess = options?.access ?? access.admin;
|
|
63
63
|
const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
|
|
64
64
|
return defineFeature("delivery", (r) => {
|
|
65
|
+
r.adoptsBundledLists();
|
|
65
66
|
r.describe(
|
|
66
67
|
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
|
|
67
68
|
);
|
|
@@ -73,6 +73,11 @@ export {
|
|
|
73
73
|
} from "./handlers/unsubscribe-address.write.ts";
|
|
74
74
|
export { currentNotificationType } from "./notification-type-aliases.ts";
|
|
75
75
|
export { createDeliveryNotifyFactory } from "./notify-factory.ts";
|
|
76
|
+
export {
|
|
77
|
+
buildSystemChannelContext,
|
|
78
|
+
type SystemChannelContextDeps,
|
|
79
|
+
type SystemChannelContextOptions,
|
|
80
|
+
} from "./system-channel-context.ts";
|
|
76
81
|
export {
|
|
77
82
|
deliveryAttemptsTable,
|
|
78
83
|
notificationAddressOptOutEntity,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
5
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
6
|
+
import { buildChannelContext } from "./channel-context.ts";
|
|
7
|
+
import type { ChannelContext } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export type SystemChannelContextDeps = {
|
|
10
|
+
readonly db: DbConnection;
|
|
11
|
+
readonly registry: Registry;
|
|
12
|
+
readonly filesForTenant: (tenantId: string) => FileContext | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SystemChannelContextOptions = {
|
|
16
|
+
readonly config?: ConfigAccessor;
|
|
17
|
+
readonly secrets?: SecretsContext;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function buildSystemChannelContext(
|
|
21
|
+
deps: SystemChannelContextDeps,
|
|
22
|
+
options: SystemChannelContextOptions = {},
|
|
23
|
+
): ChannelContext {
|
|
24
|
+
return buildChannelContext(
|
|
25
|
+
deps.db,
|
|
26
|
+
deps.registry,
|
|
27
|
+
undefined,
|
|
28
|
+
SYSTEM_TENANT_ID,
|
|
29
|
+
options.secrets,
|
|
30
|
+
options.config,
|
|
31
|
+
deps.filesForTenant,
|
|
32
|
+
);
|
|
33
|
+
}
|
package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { Registry, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import {
|
|
5
|
+
appendEventInTenantDb,
|
|
6
|
+
getStreamVersionInTenantDb,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
|
+
import {
|
|
9
|
+
DOCUMENT_EXTRACT_AGGREGATE_TYPE,
|
|
10
|
+
documentExtractFailedPayloadSchema,
|
|
11
|
+
documentExtractReadyPayloadSchema,
|
|
12
|
+
} from "./events.ts";
|
|
13
|
+
|
|
14
|
+
export type ExistingDocumentExtract = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly meta: { readonly error?: unknown } | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AppendMissingExtractEventDeps = {
|
|
20
|
+
readonly tenantDb: TenantDb;
|
|
21
|
+
readonly registry: Registry;
|
|
22
|
+
readonly actor: SessionUser;
|
|
23
|
+
readonly tenantId: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AppendMissingExtractEventOptions = {
|
|
27
|
+
readonly readyEventQn: string;
|
|
28
|
+
readonly failedEventQn: string;
|
|
29
|
+
readonly mapError?: (error: string) => string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AppendMissingExtractEventResult =
|
|
33
|
+
| "appended-ready"
|
|
34
|
+
| "appended-failed"
|
|
35
|
+
| "already-terminal";
|
|
36
|
+
|
|
37
|
+
// Version 1 of the stream is the extract's created event; anything beyond it is the terminal event.
|
|
38
|
+
const CREATED_ONLY_STREAM_VERSION = 1;
|
|
39
|
+
const UNKNOWN_EXTRACT_ERROR = "unknown extract error";
|
|
40
|
+
|
|
41
|
+
// A previous delivery created the extract but died before appending its ready/failed event.
|
|
42
|
+
// Without the event, consumers waiting on it hang forever.
|
|
43
|
+
export async function appendMissingDocumentExtractEvent(
|
|
44
|
+
deps: AppendMissingExtractEventDeps,
|
|
45
|
+
existing: ExistingDocumentExtract,
|
|
46
|
+
fileRefId: string,
|
|
47
|
+
options: AppendMissingExtractEventOptions,
|
|
48
|
+
): Promise<AppendMissingExtractEventResult> {
|
|
49
|
+
if (deps.tenantDb.tenantId !== deps.tenantId) {
|
|
50
|
+
throw new InternalError({
|
|
51
|
+
message: `document-ingest-foundation: tenantDb is bound to tenant ${deps.tenantDb.tenantId}, not ${deps.tenantId}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const version = await getStreamVersionInTenantDb(deps.tenantDb, existing.id);
|
|
55
|
+
// A missing stream means a wrong id; appending would make the terminal event the first event of a new stream.
|
|
56
|
+
if (version === 0) {
|
|
57
|
+
throw new InternalError({
|
|
58
|
+
message: `document-ingest-foundation: no document extract stream for id ${existing.id}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// skip: the terminal event was already written, nothing to catch up
|
|
62
|
+
if (version > CREATED_ONLY_STREAM_VERSION) return "already-terminal";
|
|
63
|
+
const metaError = existing.meta?.error;
|
|
64
|
+
const failed = typeof metaError === "string";
|
|
65
|
+
await appendEventInTenantDb(
|
|
66
|
+
deps.tenantDb,
|
|
67
|
+
{
|
|
68
|
+
aggregateId: existing.id,
|
|
69
|
+
aggregateType: DOCUMENT_EXTRACT_AGGREGATE_TYPE,
|
|
70
|
+
expectedVersion: version,
|
|
71
|
+
type: failed ? options.failedEventQn : options.readyEventQn,
|
|
72
|
+
payload: failed
|
|
73
|
+
? documentExtractFailedPayloadSchema.parse({
|
|
74
|
+
documentExtractId: existing.id,
|
|
75
|
+
fileRefId,
|
|
76
|
+
error: options.mapError?.(metaError) || metaError || UNKNOWN_EXTRACT_ERROR,
|
|
77
|
+
})
|
|
78
|
+
: documentExtractReadyPayloadSchema.parse({ documentExtractId: existing.id, fileRefId }),
|
|
79
|
+
metadata: { userId: deps.actor.id },
|
|
80
|
+
},
|
|
81
|
+
{ registry: deps.registry },
|
|
82
|
+
);
|
|
83
|
+
return failed ? "appended-failed" : "appended-ready";
|
|
84
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "appendMissingDocumentExtractEvent and shared ready/failed payload schemas",
|
|
6
|
+
"detail": "A provider whose job died after creating the extract calls appendMissingDocumentExtractEvent(deps, existing, fileRefId, { readyEventQn, failedEventQn, mapError }) to append the missing terminal event; it returns appended-ready, appended-failed or already-terminal. documentExtractReadyPayloadSchema and documentExtractFailedPayloadSchema are exported so providers register their own event names with the shared payload shape."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -47,3 +47,18 @@ export const documentIngestSkippedPayloadSchema = z.object({
|
|
|
47
47
|
reason: z.enum(["file-too-large", "unsupported-mime-type"]),
|
|
48
48
|
});
|
|
49
49
|
export type DocumentIngestSkippedPayload = z.infer<typeof documentIngestSkippedPayloadSchema>;
|
|
50
|
+
|
|
51
|
+
// Terminal events of a document-extract stream. The QNs belong to the provider that registers
|
|
52
|
+
// them; the foundation only owns the shared payload shape.
|
|
53
|
+
export const documentExtractReadyPayloadSchema = z.object({
|
|
54
|
+
documentExtractId: z.string().min(1),
|
|
55
|
+
fileRefId: z.string().min(1),
|
|
56
|
+
});
|
|
57
|
+
export type DocumentExtractReadyPayload = z.infer<typeof documentExtractReadyPayloadSchema>;
|
|
58
|
+
|
|
59
|
+
export const documentExtractFailedPayloadSchema = z.object({
|
|
60
|
+
documentExtractId: z.string().min(1),
|
|
61
|
+
fileRefId: z.string().min(1),
|
|
62
|
+
error: z.string().min(1),
|
|
63
|
+
});
|
|
64
|
+
export type DocumentExtractFailedPayload = z.infer<typeof documentExtractFailedPayloadSchema>;
|