@cosmicdrift/kumiko-bundled-features 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.
Files changed (136) hide show
  1. package/dist/agent-tools/index.d.ts +1 -1
  2. package/dist/agent-tools/index.js +1 -1
  3. package/dist/agent-tools/tool-dispatch.d.ts +8 -3
  4. package/dist/agent-tools/tool-dispatch.js +36 -25
  5. package/dist/audit/feature.js +1 -0
  6. package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
  7. package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
  8. package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
  9. package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
  10. package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
  11. package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
  12. package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
  13. package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
  15. package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
  16. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  17. package/dist/auth-email-password/magic-link-mail.js +7 -3
  18. package/dist/auth-email-password/signup-activation.d.ts +1 -0
  19. package/dist/auth-email-password/signup-activation.js +1 -0
  20. package/dist/auth-email-password/web/auth-client.d.ts +2 -2
  21. package/dist/auth-email-password/web/auth-client.js +8 -4
  22. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
  23. package/dist/auth-email-password/web/auth-redirect.js +20 -25
  24. package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
  25. package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
  26. package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
  27. package/dist/auth-email-password/web/index.d.ts +1 -0
  28. package/dist/auth-email-password/web/index.js +1 -0
  29. package/dist/auth-email-password/web/login-screen.js +11 -6
  30. package/dist/auth-email-password/web/reset-password-screen.js +7 -1
  31. package/dist/auth-email-password/web/return-path.d.ts +4 -0
  32. package/dist/auth-email-password/web/return-path.js +34 -0
  33. package/dist/auth-email-password/web/session.js +6 -1
  34. package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
  35. package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
  36. package/dist/auth-email-password/web/signup-screen.js +8 -3
  37. package/dist/auth-mfa/config.d.ts +1 -1
  38. package/dist/auth-mfa/config.js +3 -7
  39. package/dist/auth-mfa/mfa-status-checker.js +5 -3
  40. package/dist/cap-counter/cap-reservation.js +15 -6
  41. package/dist/cap-counter/feature.js +1 -0
  42. package/dist/cap-overview/feature.js +1 -0
  43. package/dist/config/handlers/set.write.js +12 -2
  44. package/dist/crypto-shredding/handlers/forget-subject.write.js +1 -1
  45. package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
  46. package/dist/delivery/feature.js +1 -0
  47. package/dist/delivery/index.d.ts +1 -0
  48. package/dist/delivery/index.js +1 -0
  49. package/dist/delivery/system-channel-context.d.ts +15 -0
  50. package/dist/delivery/system-channel-context.js +5 -0
  51. package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
  52. package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
  53. package/dist/document-ingest-foundation/events.d.ts +11 -0
  54. package/dist/document-ingest-foundation/events.js +11 -0
  55. package/dist/document-ingest-foundation/index.d.ts +2 -1
  56. package/dist/document-ingest-foundation/index.js +2 -1
  57. package/dist/feature-toggles/feature.js +1 -0
  58. package/dist/host-routing/validate-options.js +3 -0
  59. package/dist/inbound-mail-foundation/feature.js +1 -0
  60. package/dist/jobs/feature.js +1 -0
  61. package/dist/ledger/web/index.js +1 -1
  62. package/dist/legal-pages/seed.d.ts +3 -1
  63. package/dist/legal-pages/seed.js +2 -2
  64. package/dist/mail-transport-smtp/config-keys.js +1 -1
  65. package/dist/mail-transport-smtp/feature.js +6 -5
  66. package/dist/managed-pages/constants.d.ts +1 -0
  67. package/dist/managed-pages/constants.js +1 -0
  68. package/dist/managed-pages/feature.js +3 -1
  69. package/dist/managed-pages/index.d.ts +1 -0
  70. package/dist/managed-pages/index.js +1 -0
  71. package/dist/notes-history/handlers/add-note.write.js +1 -1
  72. package/dist/personal-access-tokens/feature.js +1 -0
  73. package/dist/secrets/write-gate.js +8 -2
  74. package/dist/sessions/feature.js +1 -0
  75. package/dist/sessions/index.d.ts +1 -1
  76. package/dist/sessions/index.js +1 -1
  77. package/dist/sessions/session-callbacks.d.ts +0 -2
  78. package/dist/sessions/session-callbacks.js +1 -3
  79. package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
  80. package/dist/shared/purge-files-reaching-subject.js +19 -2
  81. package/dist/shared/run-in-sub-transaction.js +5 -5
  82. package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
  83. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  84. package/dist/tags/feature.js +1 -0
  85. package/dist/template-resolver/document-ops.d.ts +5 -0
  86. package/dist/template-resolver/document-ops.js +18 -2
  87. package/dist/template-resolver/feature.js +2 -2
  88. package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
  89. package/dist/template-resolver/handlers/layout-source.query.js +26 -6
  90. package/dist/template-resolver/handlers/overview.query.js +15 -7
  91. package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
  92. package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
  93. package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
  94. package/dist/template-resolver/seeding.d.ts +1 -0
  95. package/dist/template-resolver/seeding.js +5 -2
  96. package/dist/template-resolver/store-template-resource.js +46 -6
  97. package/dist/template-resolver/web/i18n.js +3 -0
  98. package/dist/template-resolver/web/qualified-names.d.ts +1 -0
  99. package/dist/template-resolver/web/qualified-names.js +1 -0
  100. package/dist/tenant/feature.js +1 -0
  101. package/dist/tenant/handlers/team-list.query.js +3 -3
  102. package/dist/tenant-lifecycle/stages.js +22 -1
  103. package/dist/user/feature.js +1 -0
  104. package/dist/user-data-rights/feature.js +1 -0
  105. package/dist/user-data-rights/index.d.ts +1 -1
  106. package/dist/user-data-rights/index.js +1 -1
  107. package/dist/user-data-rights/web/constants.d.ts +6 -0
  108. package/dist/user-data-rights/web/constants.js +6 -0
  109. package/dist/user-data-rights/web/index.d.ts +1 -0
  110. package/dist/user-data-rights/web/index.js +1 -0
  111. package/dist/waitlist/feature.js +1 -0
  112. package/dist/waitlist/handlers/submit.write.js +13 -9
  113. package/dist/waitlist/import.d.ts +2 -1
  114. package/dist/waitlist/import.js +22 -2
  115. package/dist/waitlist/legacy-migrate.js +2 -2
  116. package/dist/waitlist/options.d.ts +2 -1
  117. package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
  118. package/package.json +11 -11
  119. package/src/agent-tools/changes.json +6 -0
  120. package/src/auth-email-password/changes.json +12 -0
  121. package/src/auth-mfa/changes.json +6 -0
  122. package/src/cap-counter/changes.json +12 -0
  123. package/src/config/changes.json +6 -0
  124. package/src/crypto-shredding/changes.json +12 -0
  125. package/src/delivery/changes.json +12 -0
  126. package/src/document-ingest-foundation/changes.json +6 -0
  127. package/src/host-routing/changes.json +6 -0
  128. package/src/mail-transport-smtp/changes.json +6 -0
  129. package/src/managed-pages/changes.json +6 -0
  130. package/src/secrets/changes.json +6 -0
  131. package/src/sessions/changes.json +7 -0
  132. package/src/step-dispatcher/changes.json +6 -0
  133. package/src/template-resolver/changes.json +36 -0
  134. package/src/tenant-lifecycle/changes.json +6 -0
  135. package/src/user-data-rights/changes.json +12 -0
  136. package/src/waitlist/changes.json +18 -0
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Login handler reason codes map 1:1 to i18n keys (reasonToI18nKey): a new
3
3
  // reason code needs a bundle entry plus a line in that mapping.
4
- import { useOptionalDispatcher, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
4
+ import { prependBasePath, useBasePath, useOptionalDispatcher, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
5
5
  import { useState } from "react";
6
6
  import { requestEmailVerification, } from "./auth-client.js";
7
7
  import { AuthCard } from "./auth-form-primitives.js";
8
8
  import { DEFAULT_AUTH_PATHS, DEFAULT_FORGOT_PASSWORD_PATH } from "./auth-paths.js";
9
- import { followNextOrLandingAfterLogin } from "./auth-redirect.js";
9
+ import { appendReturnTo, followNextOrLandingAfterLogin, readNextFromWindow, } from "./auth-redirect.js";
10
10
  import { AuthQueries } from "./constants.js";
11
11
  import { useSession } from "./session.js";
12
12
  // Maps login-handler reason codes to i18n keys and optional interpolation parameters.
@@ -51,14 +51,19 @@ function LoginScreenBody({ title, subtitle, submitLabel, forgotPasswordHref, unl
51
51
  // Hides the resend button once the email input changes; otherwise resend
52
52
  // would silently go to the edited, possibly mistyped address.
53
53
  const [failedLoginEmail, setFailedLoginEmail] = useState(null);
54
+ const basePath = useBasePath();
55
+ // The page the user was sent away from rides along so the reset or signup mail leads back to it.
56
+ const returnTo = readNextFromWindow();
54
57
  const effectiveForgotHref = forgotPasswordHref === undefined
55
58
  ? loginOptions?.passwordReset === true
56
- ? DEFAULT_FORGOT_PASSWORD_PATH
59
+ ? appendReturnTo(prependBasePath(DEFAULT_FORGOT_PASSWORD_PATH, basePath), returnTo)
57
60
  : null
58
- : forgotPasswordHref;
61
+ : forgotPasswordHref === null
62
+ ? null
63
+ : appendReturnTo(forgotPasswordHref, returnTo);
59
64
  const effectiveUnlockHref = unlockAccountHref === undefined
60
65
  ? loginOptions?.accountUnlock === true
61
- ? DEFAULT_AUTH_PATHS.unlockAccount
66
+ ? prependBasePath(DEFAULT_AUTH_PATHS.unlockAccount, basePath)
62
67
  : null
63
68
  : unlockAccountHref;
64
69
  const doSubmit = async () => {
@@ -123,7 +128,7 @@ function LoginScreenBody({ title, subtitle, submitLabel, forgotPasswordHref, unl
123
128
  email.trim().length > 0 &&
124
129
  email === failedLoginEmail && (_jsx("span", { className: "self-start", children: _jsx(Button, { variant: "link", onClick: () => void onResend(), disabled: resendStatus.kind === "sending", children: resendStatus.kind === "sending"
125
130
  ? t("auth.login.submitting")
126
- : t("auth.login.resendVerification") }) })), error.reason === "account_locked" && effectiveUnlockHref !== null && (_jsx("span", { className: "self-start", children: _jsx(Link, { href: effectiveUnlockHref, variant: "default", children: t("auth.login.unlockAccount") }) })), resendStatus.kind === "rateLimited" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendRateLimited") })), resendStatus.kind === "error" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendError") }))] }) })) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.login.submitting") : effectiveSubmit })] }), effectiveForgotHref !== null && (_jsx(Link, { href: effectiveForgotHref, variant: "muted", className: "self-center", children: t("auth.login.forgotPassword") })), signupHref !== undefined && (_jsx(Link, { href: signupHref, variant: "muted", className: "self-center", children: t("auth.signup.title") })), legalLinks !== undefined && legalLinks.length > 0 && (_jsx("nav", { "data-testid": "login-legal-links", className: "flex items-center justify-center gap-3 pt-2 border-t border-border/50", children: legalLinks.map((link) => (_jsx(Link, { href: link.href, variant: "muted", className: "text-xs", children: link.label }, link.href))) }))] }));
131
+ : t("auth.login.resendVerification") }) })), error.reason === "account_locked" && effectiveUnlockHref !== null && (_jsx("span", { className: "self-start", children: _jsx(Link, { href: effectiveUnlockHref, variant: "default", children: t("auth.login.unlockAccount") }) })), resendStatus.kind === "rateLimited" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendRateLimited") })), resendStatus.kind === "error" && (_jsx("span", { className: "text-xs", children: t("auth.login.resendError") }))] }) })) : null, _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.login.submitting") : effectiveSubmit })] }), effectiveForgotHref !== null && (_jsx(Link, { href: effectiveForgotHref, variant: "muted", className: "self-center", children: t("auth.login.forgotPassword") })), signupHref !== undefined && (_jsx(Link, { href: appendReturnTo(signupHref, returnTo), variant: "muted", className: "self-center", children: t("auth.signup.title") })), legalLinks !== undefined && legalLinks.length > 0 && (_jsx("nav", { "data-testid": "login-legal-links", className: "flex items-center justify-center gap-3 pt-2 border-t border-border/50", children: legalLinks.map((link) => (_jsx(Link, { href: link.href, variant: "muted", className: "text-xs", children: link.label }, link.href))) }))] }));
127
132
  }
128
133
  function LoginScreenWithQueriedOptions(props) {
129
134
  const { data } = useQuery(AuthQueries.loginOptions, {}, { enabled: props.forgotPasswordHref === undefined || props.unlockAccountHref === undefined });
@@ -13,6 +13,7 @@ import { useState } from "react";
13
13
  import { resetPassword } from "./auth-client.js";
14
14
  import { passwordPairIssue } from "./auth-form-logic.js";
15
15
  import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
16
+ import { buildLoginRedirectUrl, readReturnToFromWindow } from "./auth-redirect.js";
16
17
  export function ResetPasswordScreen({ title, token: tokenProp, loginHref = "/login", }) {
17
18
  const t = useTranslation();
18
19
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
@@ -22,6 +23,7 @@ export function ResetPasswordScreen({ title, token: tokenProp, loginHref = "/log
22
23
  const [submitting, setSubmitting] = useState(false);
23
24
  const [done, setDone] = useState(false);
24
25
  const [error, setError] = useState(null);
26
+ const [afterResetHref, setAfterResetHref] = useState(loginHref);
25
27
  const doSubmit = async () => {
26
28
  setError(null);
27
29
  const issue = passwordPairIssue(newPassword, confirmPassword);
@@ -37,6 +39,10 @@ export function ResetPasswordScreen({ title, token: tokenProp, loginHref = "/log
37
39
  const res = await resetPassword(token, newPassword);
38
40
  setSubmitting(false);
39
41
  if (res.ok) {
42
+ const returnTo = readReturnToFromWindow();
43
+ if (returnTo !== null) {
44
+ setAfterResetHref(buildLoginRedirectUrl(loginHref, returnTo, window.location.origin));
45
+ }
40
46
  setDone(true);
41
47
  return;
42
48
  }
@@ -62,5 +68,5 @@ export function ResetPasswordScreen({ title, token: tokenProp, loginHref = "/log
62
68
  if (token === "") {
63
69
  return (_jsxs(AuthCard, { title: effectiveTitle, children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.missingToken") }), _jsx(Link, { href: loginHref, variant: "muted", children: t("auth.resetPassword.goToLogin") })] }));
64
70
  }
65
- return (_jsx(AuthCard, { title: effectiveTitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.resetPassword.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.resetPassword.successBody") })] }), _jsx(Link, { href: loginHref, variant: "button", children: t("auth.resetPassword.goToLogin") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "reset-new-password", label: t("auth.resetPassword.newPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-new-password", name: "reset-new-password", value: newPassword, onChange: setNewPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "reset-confirm-password", label: t("auth.resetPassword.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-confirm-password", name: "reset-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.resetPassword.submitting") : t("auth.resetPassword.submit") })] })] })) }));
71
+ return (_jsx(AuthCard, { title: effectiveTitle, children: done ? (_jsxs(_Fragment, { children: [_jsxs(Banner, { variant: "info", children: [_jsx("p", { className: "font-medium text-foreground", children: t("auth.resetPassword.successTitle") }), _jsx("p", { className: "mt-1", children: t("auth.resetPassword.successBody") })] }), _jsx(Link, { href: afterResetHref, variant: "button", children: t("auth.resetPassword.goToLogin") })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("auth.resetPassword.intro") }), _jsxs(Form, { onSubmit: onSubmit, children: [_jsx(Field, { id: "reset-new-password", label: t("auth.resetPassword.newPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-new-password", name: "reset-new-password", value: newPassword, onChange: setNewPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), _jsx(Field, { id: "reset-confirm-password", label: t("auth.resetPassword.confirmPassword"), required: true, children: _jsx(Input, { kind: "password", id: "reset-confirm-password", name: "reset-confirm-password", value: confirmPassword, onChange: setConfirmPassword, disabled: submitting, required: true, autoComplete: "new-password" }) }), error !== null && _jsx(Banner, { variant: "error", children: error }), _jsx(Button, { type: "submit", loading: submitting, disabled: submitting, children: submitting ? t("auth.resetPassword.submitting") : t("auth.resetPassword.submit") })] })] })) }));
66
72
  }
@@ -0,0 +1,4 @@
1
+ export declare const RETURN_TO_QUERY_PARAM = "returnTo";
2
+ export declare const MAX_RETURN_PATH_LENGTH = 2048;
3
+ export declare function isSafeReturnPath(candidate: unknown): candidate is string;
4
+ export declare function readReturnToFromSearch(search: string): string | null;
@@ -0,0 +1,34 @@
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
+ export const RETURN_TO_QUERY_PARAM = "returnTo";
4
+ export const MAX_RETURN_PATH_LENGTH = 2048;
5
+ const DOT_SEGMENTS = new Set([".", "..", ".%2e", "%2e.", "%2e%2e", "%2e"]);
6
+ function hasForbiddenCharacter(candidate) {
7
+ for (let i = 0; i < candidate.length; i++) {
8
+ const code = candidate.charCodeAt(i);
9
+ if (code <= 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f))
10
+ return true;
11
+ if (code === 0x5c)
12
+ return true;
13
+ }
14
+ return false;
15
+ }
16
+ // A dot segment is resolved by URL normalization, so "/.//evil" would become "//evil".
17
+ function hasDotSegment(candidate) {
18
+ const pathEnd = candidate.search(/[?#]/);
19
+ const path = pathEnd === -1 ? candidate : candidate.slice(0, pathEnd);
20
+ return path.split("/").some((segment) => DOT_SEGMENTS.has(segment.toLowerCase()));
21
+ }
22
+ export function isSafeReturnPath(candidate) {
23
+ if (typeof candidate !== "string")
24
+ return false;
25
+ if (candidate.length < 1 || candidate.length > MAX_RETURN_PATH_LENGTH)
26
+ return false;
27
+ if (!candidate.startsWith("/") || candidate[1] === "/")
28
+ return false;
29
+ return !hasForbiddenCharacter(candidate) && !hasDotSegment(candidate);
30
+ }
31
+ export function readReturnToFromSearch(search) {
32
+ const candidate = new URLSearchParams(search).get(RETURN_TO_QUERY_PARAM);
33
+ return isSafeReturnPath(candidate) ? candidate : null;
34
+ }
@@ -10,6 +10,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  // and transitions.
11
11
  import { CurrentUserProvider, useSessionEndedSignal, } from "@cosmicdrift/kumiko-renderer";
12
12
  import { readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
13
+ import { clearRememberedListState } from "@cosmicdrift/kumiko-renderer/web";
13
14
  import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
14
15
  import { AuthRequestError, fetchCurrentUser, fetchTenants, login as loginApi, logout as logoutApi, switchTenant as switchTenantApi, } from "./auth-client.js";
15
16
  // Exported so screens that render on BOTH authenticated and anonymous public
@@ -127,6 +128,8 @@ export function SessionProvider({ children, postLogoutUrl, }) {
127
128
  }, [doRefresh]);
128
129
  const logout = useCallback(async () => {
129
130
  await logoutApi();
131
+ // The reload below would hide the identity change from the list-state boot.
132
+ clearRememberedListState();
130
133
  if (postLogoutUrl !== undefined && typeof window !== "undefined") {
131
134
  // State stays untouched: flipping to unauthenticated would let an AuthGate
132
135
  // with loginUrl race this navigation to its own redirect.
@@ -160,6 +163,7 @@ export function SessionProvider({ children, postLogoutUrl, }) {
160
163
  const userEmail = profile?.email;
161
164
  const userDisplayName = profile?.displayName;
162
165
  const userTimeZone = profile?.effectiveTimeZone;
166
+ const activeTenantId = state.activeTenantId ?? undefined;
163
167
  const currentUser = useMemo(() => userId === undefined || userEmail === undefined || userDisplayName === undefined
164
168
  ? undefined
165
169
  : {
@@ -167,7 +171,8 @@ export function SessionProvider({ children, postLogoutUrl, }) {
167
171
  email: userEmail,
168
172
  displayName: userDisplayName,
169
173
  ...(userTimeZone !== undefined && { timeZone: userTimeZone }),
170
- }, [userId, userEmail, userDisplayName, userTimeZone]);
174
+ ...(activeTenantId !== undefined && { tenantId: activeTenantId }),
175
+ }, [userId, userEmail, userDisplayName, userTimeZone, activeTenantId]);
171
176
  return (_jsx(SessionContext.Provider, { value: api, children: _jsx(CurrentUserProvider, { user: currentUser, children: children }) }));
172
177
  }
173
178
  export function useSession() {
@@ -21,7 +21,7 @@ import { useState } from "react";
21
21
  import { confirmSignup } from "./auth-client.js";
22
22
  import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic.js";
23
23
  import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
24
- import { buildLoginRedirectUrl } from "./auth-redirect.js";
24
+ import { buildLoginRedirectUrl, readReturnToFromWindow } from "./auth-redirect.js";
25
25
  export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "/", loginHref = "/login", }) {
26
26
  const t = useTranslation();
27
27
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
@@ -47,11 +47,19 @@ export function SignupCompleteScreen({ title, token: tokenProp, loggedInHref = "
47
47
  const res = await confirmSignup(token, password);
48
48
  setSubmitting(false);
49
49
  if (res.ok) {
50
+ // The page the signup started from beats the server's landing path.
51
+ const returnTo = readReturnToFromWindow();
50
52
  if (res.data.kind === "mfa-pending") {
51
53
  setMfaPending(true);
52
- setContinueHref(res.data.landingPath === undefined
54
+ const next = returnTo ?? res.data.landingPath;
55
+ setContinueHref(next === undefined
53
56
  ? loginHref
54
- : buildLoginRedirectUrl(loginHref, res.data.landingPath, window.location.origin));
57
+ : buildLoginRedirectUrl(loginHref, next, window.location.origin));
58
+ return;
59
+ }
60
+ // A claimed handover keeps its app-defined target.
61
+ if (returnTo !== null && res.data.handover === undefined) {
62
+ setContinueHref(returnTo);
55
63
  return;
56
64
  }
57
65
  // Cookies are already set (auto-login). Show a confirmation with an
@@ -5,5 +5,8 @@ export type SignupScreenProps = {
5
5
  /** href for the "Already have an account?" link. App-specific; defaults to
6
6
  * "/login". */
7
7
  readonly loginHref?: string;
8
+ /** Page the activation mail should lead to after signup; defaults to the `returnTo` query
9
+ * parameter of this page. Unsafe values are dropped. */
10
+ readonly returnTo?: string;
8
11
  };
9
- export declare function SignupScreen({ title, subtitle, loginHref, }: SignupScreenProps): ReactNode;
12
+ export declare function SignupScreen({ title, subtitle, loginHref, returnTo, }: SignupScreenProps): ReactNode;
@@ -16,7 +16,12 @@ import { useState } from "react";
16
16
  import { requestSignup } from "./auth-client.js";
17
17
  import { retryAfterMinutes } from "./auth-form-logic.js";
18
18
  import { AuthCard } from "./auth-form-primitives.js";
19
- export function SignupScreen({ title, subtitle, loginHref = "/login", }) {
19
+ import { readReturnToFromWindow } from "./auth-redirect.js";
20
+ import { isSafeReturnPath } from "./return-path.js";
21
+ function resolveReturnTo(prop) {
22
+ return (isSafeReturnPath(prop) ? prop : readReturnToFromWindow()) ?? undefined;
23
+ }
24
+ export function SignupScreen({ title, subtitle, loginHref = "/login", returnTo, }) {
20
25
  const t = useTranslation();
21
26
  const { Form, Field, Input, Button, Banner, Link } = usePrimitives();
22
27
  const [email, setEmail] = useState("");
@@ -27,7 +32,7 @@ export function SignupScreen({ title, subtitle, loginHref = "/login", }) {
27
32
  setSubmitting(true);
28
33
  setError(null);
29
34
  try {
30
- const res = await requestSignup(email);
35
+ const res = await requestSignup(email, undefined, resolveReturnTo(returnTo));
31
36
  if (res.ok) {
32
37
  setDone(true);
33
38
  }
@@ -59,7 +64,7 @@ export function SignupScreen({ title, subtitle, loginHref = "/login", }) {
59
64
  setSubmitting(true);
60
65
  setError(null);
61
66
  try {
62
- await requestSignup(email);
67
+ await requestSignup(email, undefined, resolveReturnTo(returnTo));
63
68
  }
64
69
  finally {
65
70
  setSubmitting(false);
@@ -1,5 +1,5 @@
1
1
  import { type ConfigKeyHandle } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare const MFA_REQUIRED_POLICIES: readonly ["optional", "admins", "all"];
2
+ export declare const MFA_REQUIRED_POLICIES: readonly ["optional", "systemAdmins", "admins", "all"];
3
3
  export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
4
4
  export declare const mfaRequiredConfigHandle: ConfigKeyHandle<"select">;
5
5
  export declare function mfaRequiredConfigKey(defaultPolicy?: MfaRequiredPolicy): import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"select">;
@@ -2,14 +2,10 @@
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
- // ponytail: "admins"/"all" HARD-BLOCK login for an unenrolled user (see
6
- // mfa-status-checker.ts's setupRequired branch) — there is no in-band way
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 { access, createTenantConfig, } from "@cosmicdrift/kumiko-framework/engine";
12
- export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"];
8
+ export const MFA_REQUIRED_POLICIES = ["optional", "systemAdmins", "admins", "all"];
13
9
  export const mfaRequiredConfigHandle = {
14
10
  name: "auth-mfa:config:required",
15
11
  type: "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 { access, declareEscapeHatch, } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { mfaRequiredConfigHandle, mfaRequiredConfigKey } from "./config.js";
@@ -32,9 +33,10 @@ export function createMfaStatusChecker(opts) {
32
33
  const buildSetupToken = () => signMfaPreauthSetupToken({ userId, tenantId }, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES, opts.challengeTokenSecret).token;
33
34
  if (policy === "all")
34
35
  return { setupRequired: true, preauthSetupToken: buildSetupToken() };
35
- // policy === "admins"
36
- const isAdmin = roles.some((role) => access.admin.includes(role));
37
- return isAdmin
36
+ const isEnforced = policy === "systemAdmins"
37
+ ? roles.includes(ROLES.SystemAdmin)
38
+ : roles.some((role) => access.admin.includes(role));
39
+ return isEnforced
38
40
  ? { setupRequired: true, preauthSetupToken: buildSetupToken() }
39
41
  : { required: false };
40
42
  };
@@ -186,6 +186,16 @@ function bookCalendarDelta(txDb, user, period, delta) {
186
186
  return Promise.resolve({ isSuccess: true, data: {} });
187
187
  return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
188
188
  }
189
+ // Asked after a missed or lost claim. A released tombstone committed since the first read does not
190
+ // count: the caller's own tombstone insert then conflicts with it, and the retry claims that row.
191
+ async function hasSettledTombstone(txDb, user, id) {
192
+ const settled = await txDb.fetchOne(capReservationsTable, {
193
+ id,
194
+ tenantId: user.tenantId,
195
+ settledAt: { ne: null },
196
+ });
197
+ return settled !== undefined;
198
+ }
189
199
  async function settleInTransaction(txDb, user, id, options) {
190
200
  const ownRow = { id, tenantId: user.tenantId };
191
201
  const open = await txDb.fetchOne(capReservationsTable, {
@@ -205,12 +215,11 @@ async function settleInTransaction(txDb, user, id, options) {
205
215
  await txDb.updateMany(capReservationsTable, { settledAt: now, expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }) }, ownRow);
206
216
  return booked;
207
217
  }
208
- // The claim lost to a concurrent settle or release: a tombstone means the amount is booked.
209
- if (await txDb.fetchOne(capReservationsTable, ownRow)) {
218
+ if (await hasSettledTombstone(txDb, user, id))
210
219
  return NOTHING_TO_RELEASE;
211
- }
212
220
  // Row swept or released: its amount was given back, so the full actual amount is booked and a
213
- // tombstone makes a repeated settle a no-op. A parallel settle inserting first fails this insert.
221
+ // tombstone makes a repeated settle a no-op. A parallel settle or consume inserting first fails
222
+ // this insert.
214
223
  // Booking first: a lost counter race returns a result instead of throwing, so the transaction
215
224
  // commits and a tombstone written before it would turn the retry into a no-op.
216
225
  const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount);
@@ -286,7 +295,7 @@ async function consumeClaimedRow(txDb, user, claimed, options) {
286
295
  return NOTHING_TO_RELEASE;
287
296
  }
288
297
  // Row already expired away: the units are booked directly and a released tombstone remembers them
289
- // so a later settle subtracts them. A parallel consume inserting first fails this insert.
298
+ // so a later settle subtracts them. A parallel consume or settle inserting first fails this insert.
290
299
  async function consumeWithoutRow(txDb, user, id, options) {
291
300
  // Booking first, for the same reason as settle's missing-row path.
292
301
  const booked = await bookCalendarDelta(txDb, user, options, options.units);
@@ -320,7 +329,7 @@ async function consumeInTransaction(txDb, user, id, options) {
320
329
  const claimed = open && (await claimReservationRow(txDb, user, open));
321
330
  if (claimed)
322
331
  return consumeClaimedRow(txDb, user, claimed, options);
323
- if (await txDb.fetchOne(capReservationsTable, ownRow)) {
332
+ if (await hasSettledTombstone(txDb, user, id)) {
324
333
  throw new CapReservationSettledError(options.capName);
325
334
  }
326
335
  return consumeWithoutRow(txDb, user, id, options);
@@ -54,6 +54,7 @@ import { CAP_COUNTER_I18N } from "./i18n.js";
54
54
  import { capReservationsTableMeta } from "./tables.js";
55
55
  export function createCapCounterFeature() {
56
56
  return defineFeature(CAP_COUNTER_FEATURE, (r) => {
57
+ r.adoptsBundledLists();
57
58
  r.describe("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.");
58
59
  r.uiHints({
59
60
  displayLabel: "Cap Counter · Usage Limits",
@@ -36,6 +36,7 @@ export function createCapOverviewFeature(opts) {
36
36
  }
37
37
  }
38
38
  return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
39
+ r.adoptsBundledLists();
39
40
  r.describe("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.");
40
41
  r.uiHints({
41
42
  displayLabel: "Cap Overview · Tier & Usage Visibility",
@@ -4,7 +4,7 @@ import { createEventStoreExecutor, scopeTenantDbToTenant } from "@cosmicdrift/ku
4
4
  import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, isKumikoError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
- import { requireConfigEncryption, requireSystemDb } from "../feature.js";
7
+ import { requireConfigEncryption, requireConfigResolver, requireSystemDb } from "../feature.js";
8
8
  import { MASKED } from "../read-redaction.js";
9
9
  import { configValueEntity, configValuesTable } from "../table.js";
10
10
  import { findConfigRow, prepareConfigWrite, resolvePiiSubject, validateBounds, validateExtensionPlugin, validatePattern, validateScope, validateType, } from "../write-helpers.js";
@@ -69,13 +69,23 @@ export const setWrite = defineWriteHandler({
69
69
  if (patternError)
70
70
  return writeFailure(patternError);
71
71
  if (keyDef.validate) {
72
+ const validateDb = scopeTenantDbToTenant(db, tenantId);
72
73
  try {
73
74
  await keyDef.validate(event.payload.value, {
74
75
  key: event.payload.key,
75
76
  scope,
76
77
  tenantId,
77
78
  userId,
78
- db: scopeTenantDbToTenant(db, tenantId),
79
+ db: validateDb,
80
+ readCascade: (qualifiedKey) => {
81
+ const cascadeKeyDef = ctx.registry.getConfigKey(qualifiedKey);
82
+ if (!cascadeKeyDef) {
83
+ throw new InternalError({
84
+ message: `[config:write:set] readCascade: unknown config key "${qualifiedKey}"`,
85
+ });
86
+ }
87
+ return requireConfigResolver(ctx, "config:write:set").getCascade(qualifiedKey, cascadeKeyDef, tenantId, event.user.id, validateDb, ctx.secrets);
88
+ },
79
89
  });
80
90
  }
81
91
  catch (error) {
@@ -399,7 +399,7 @@ export const forgetSubjectWrite = defineWriteHandler({
399
399
  // - user.updated as lifecycle event (updateUserLifecycle), so a
400
400
  // read_users rebuild doesn't wipe the flip
401
401
  // - sessions don't need active revocation — session-callbacks
402
- // re-validate user.status on every request (isPrincipalBlocked
402
+ // re-validate the user row on every request (isPrincipalRowBlocked
403
403
  // blocks Deleted).
404
404
  // Both idempotent (status set / revokedAt IS NULL filter), retry after
405
405
  // 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: postgres-js returns text as a `string`, but
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
@@ -21,6 +21,7 @@ export function createDeliveryFeature(options) {
21
21
  const resolvedAccess = options?.access ?? access.admin;
22
22
  const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
23
23
  return defineFeature("delivery", (r) => {
24
+ r.adoptsBundledLists();
24
25
  r.describe("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.");
25
26
  r.uiHints({
26
27
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -12,6 +12,7 @@ export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.ts
12
12
  export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.ts";
13
13
  export { currentNotificationType } from "./notification-type-aliases.ts";
14
14
  export { createDeliveryNotifyFactory } from "./notify-factory.ts";
15
+ export { buildSystemChannelContext, type SystemChannelContextDeps, type SystemChannelContextOptions, } from "./system-channel-context.ts";
15
16
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.ts";
16
17
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.ts";
17
18
  export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.ts";
@@ -11,6 +11,7 @@ export { createDeliveryFeature } from "./feature.js";
11
11
  export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
12
12
  export { currentNotificationType } from "./notification-type-aliases.js";
13
13
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
14
+ export { buildSystemChannelContext, } from "./system-channel-context.js";
14
15
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
15
16
  export { createDeliveryTestContext } from "./testing.js";
16
17
  export { isDeliveryChannelPlugin, } from "./types.js";
@@ -0,0 +1,15 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
+ import type { ChannelContext } from "./types.ts";
6
+ export type SystemChannelContextDeps = {
7
+ readonly db: DbConnection;
8
+ readonly registry: Registry;
9
+ readonly filesForTenant: (tenantId: string) => FileContext | undefined;
10
+ };
11
+ export type SystemChannelContextOptions = {
12
+ readonly config?: ConfigAccessor;
13
+ readonly secrets?: SecretsContext;
14
+ };
15
+ export declare function buildSystemChannelContext(deps: SystemChannelContextDeps, options?: SystemChannelContextOptions): ChannelContext;
@@ -0,0 +1,5 @@
1
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { buildChannelContext } from "./channel-context.js";
3
+ export function buildSystemChannelContext(deps, options = {}) {
4
+ return buildChannelContext(deps.db, deps.registry, undefined, SYSTEM_TENANT_ID, options.secrets, options.config, deps.filesForTenant);
5
+ }
@@ -0,0 +1,21 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ export type ExistingDocumentExtract = {
4
+ readonly id: string;
5
+ readonly meta: {
6
+ readonly error?: unknown;
7
+ } | null;
8
+ };
9
+ export type AppendMissingExtractEventDeps = {
10
+ readonly tenantDb: TenantDb;
11
+ readonly registry: Registry;
12
+ readonly actor: SessionUser;
13
+ readonly tenantId: string;
14
+ };
15
+ export type AppendMissingExtractEventOptions = {
16
+ readonly readyEventQn: string;
17
+ readonly failedEventQn: string;
18
+ readonly mapError?: (error: string) => string;
19
+ };
20
+ export type AppendMissingExtractEventResult = "appended-ready" | "appended-failed" | "already-terminal";
21
+ export declare function appendMissingDocumentExtractEvent(deps: AppendMissingExtractEventDeps, existing: ExistingDocumentExtract, fileRefId: string, options: AppendMissingExtractEventOptions): Promise<AppendMissingExtractEventResult>;
@@ -0,0 +1,42 @@
1
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import { DOCUMENT_EXTRACT_AGGREGATE_TYPE, documentExtractFailedPayloadSchema, documentExtractReadyPayloadSchema, } from "./events.js";
4
+ // Version 1 of the stream is the extract's created event; anything beyond it is the terminal event.
5
+ const CREATED_ONLY_STREAM_VERSION = 1;
6
+ const UNKNOWN_EXTRACT_ERROR = "unknown extract error";
7
+ // A previous delivery created the extract but died before appending its ready/failed event.
8
+ // Without the event, consumers waiting on it hang forever.
9
+ export async function appendMissingDocumentExtractEvent(deps, existing, fileRefId, options) {
10
+ if (deps.tenantDb.tenantId !== deps.tenantId) {
11
+ throw new InternalError({
12
+ message: `document-ingest-foundation: tenantDb is bound to tenant ${deps.tenantDb.tenantId}, not ${deps.tenantId}`,
13
+ });
14
+ }
15
+ const version = await getStreamVersionInTenantDb(deps.tenantDb, existing.id);
16
+ // A missing stream means a wrong id; appending would make the terminal event the first event of a new stream.
17
+ if (version === 0) {
18
+ throw new InternalError({
19
+ message: `document-ingest-foundation: no document extract stream for id ${existing.id}`,
20
+ });
21
+ }
22
+ // skip: the terminal event was already written, nothing to catch up
23
+ if (version > CREATED_ONLY_STREAM_VERSION)
24
+ return "already-terminal";
25
+ const metaError = existing.meta?.error;
26
+ const failed = typeof metaError === "string";
27
+ await appendEventInTenantDb(deps.tenantDb, {
28
+ aggregateId: existing.id,
29
+ aggregateType: DOCUMENT_EXTRACT_AGGREGATE_TYPE,
30
+ expectedVersion: version,
31
+ type: failed ? options.failedEventQn : options.readyEventQn,
32
+ payload: failed
33
+ ? documentExtractFailedPayloadSchema.parse({
34
+ documentExtractId: existing.id,
35
+ fileRefId,
36
+ error: options.mapError?.(metaError) || metaError || UNKNOWN_EXTRACT_ERROR,
37
+ })
38
+ : documentExtractReadyPayloadSchema.parse({ documentExtractId: existing.id, fileRefId }),
39
+ metadata: { userId: deps.actor.id },
40
+ }, { registry: deps.registry });
41
+ return failed ? "appended-failed" : "appended-ready";
42
+ }
@@ -26,3 +26,14 @@ export declare const documentIngestSkippedPayloadSchema: z.ZodObject<{
26
26
  }>;
27
27
  }, z.core.$strip>;
28
28
  export type DocumentIngestSkippedPayload = z.infer<typeof documentIngestSkippedPayloadSchema>;
29
+ export declare const documentExtractReadyPayloadSchema: z.ZodObject<{
30
+ documentExtractId: z.ZodString;
31
+ fileRefId: z.ZodString;
32
+ }, z.core.$strip>;
33
+ export type DocumentExtractReadyPayload = z.infer<typeof documentExtractReadyPayloadSchema>;
34
+ export declare const documentExtractFailedPayloadSchema: z.ZodObject<{
35
+ documentExtractId: z.ZodString;
36
+ fileRefId: z.ZodString;
37
+ error: z.ZodString;
38
+ }, z.core.$strip>;
39
+ export type DocumentExtractFailedPayload = z.infer<typeof documentExtractFailedPayloadSchema>;
@@ -35,3 +35,14 @@ export const documentIngestSkippedPayloadSchema = z.object({
35
35
  size: z.number().int().min(0),
36
36
  reason: z.enum(["file-too-large", "unsupported-mime-type"]),
37
37
  });
38
+ // Terminal events of a document-extract stream. The QNs belong to the provider that registers
39
+ // them; the foundation only owns the shared payload shape.
40
+ export const documentExtractReadyPayloadSchema = z.object({
41
+ documentExtractId: z.string().min(1),
42
+ fileRefId: z.string().min(1),
43
+ });
44
+ export const documentExtractFailedPayloadSchema = z.object({
45
+ documentExtractId: z.string().min(1),
46
+ fileRefId: z.string().min(1),
47
+ error: z.string().min(1),
48
+ });
@@ -1,6 +1,7 @@
1
1
  export { documentIngestAggregateId } from "./aggregate-id.ts";
2
+ export { type AppendMissingExtractEventDeps, type AppendMissingExtractEventOptions, type AppendMissingExtractEventResult, appendMissingDocumentExtractEvent, type ExistingDocumentExtract, } from "./append-missing-extract-event.ts";
2
3
  export { type DocumentExtractMeta, documentExtractEntity, documentExtractsTable, type IngestPage, } from "./entity.ts";
3
- export { DOCUMENT_EXTRACT_AGGREGATE_TYPE, DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, type DocumentIngestRequestedPayload, type DocumentIngestSkippedPayload, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.ts";
4
+ export { DOCUMENT_EXTRACT_AGGREGATE_TYPE, DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, type DocumentExtractFailedPayload, type DocumentExtractReadyPayload, type DocumentIngestRequestedPayload, type DocumentIngestSkippedPayload, documentExtractFailedPayloadSchema, documentExtractReadyPayloadSchema, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.ts";
4
5
  export { createDocumentIngestFoundationFeature } from "./feature.ts";
5
6
  export { readIngestPages, writeIngestPages } from "./pages.ts";
6
7
  export { type DocumentIngestProviderOptions, documentIngestProviderOptionsSchema, documentIngestProviderTrigger, EXT_DOCUMENT_INGEST_PROVIDER, listIngestibleMimeTypes, type ResolvedDocumentIngestProvider, resolveDocumentIngestProviders, } from "./providers.ts";
@@ -1,7 +1,8 @@
1
1
  // Public API of the document-ingest-foundation bundled-feature.
2
2
  export { documentIngestAggregateId } from "./aggregate-id.js";
3
+ export { appendMissingDocumentExtractEvent, } from "./append-missing-extract-event.js";
3
4
  export { documentExtractEntity, documentExtractsTable, } from "./entity.js";
4
- export { DOCUMENT_EXTRACT_AGGREGATE_TYPE, DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
5
+ export { DOCUMENT_EXTRACT_AGGREGATE_TYPE, DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentExtractFailedPayloadSchema, documentExtractReadyPayloadSchema, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
5
6
  export { createDocumentIngestFoundationFeature } from "./feature.js";
6
7
  export { readIngestPages, writeIngestPages } from "./pages.js";
7
8
  export { documentIngestProviderOptionsSchema, documentIngestProviderTrigger, EXT_DOCUMENT_INGEST_PROVIDER, listIngestibleMimeTypes, resolveDocumentIngestProviders, } from "./providers.js";
@@ -8,6 +8,7 @@ import { FEATURE_TOGGLES_I18N } from "./i18n.js";
8
8
  import { FEATURE_TOGGLE_SET_EVENT_NAME, FeatureToggleHandlers, FeatureToggleQueries, TOGGLE_ADMIN_SCREEN_ID, } from "./web/constants.js";
9
9
  export function createFeatureTogglesFeature(options = {}) {
10
10
  return defineFeature("feature-toggles", (r) => {
11
+ r.adoptsBundledLists();
11
12
  r.describe('Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "shared"` syncs the snapshot across instances whenever a `toggle-set` event is appended.');
12
13
  r.uiHints({
13
14
  displayLabel: "Feature Toggles · Operator Switches",