@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.
- package/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/tool-dispatch.d.ts +8 -3
- package/dist/agent-tools/tool-dispatch.js +36 -25
- package/dist/audit/feature.js +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
- package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
- package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
- package/dist/auth-email-password/magic-link-mail.js +7 -3
- package/dist/auth-email-password/signup-activation.d.ts +1 -0
- package/dist/auth-email-password/signup-activation.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +2 -2
- package/dist/auth-email-password/web/auth-client.js +8 -4
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
- package/dist/auth-email-password/web/auth-redirect.js +20 -25
- package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
- package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
- package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
- package/dist/auth-email-password/web/index.d.ts +1 -0
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/login-screen.js +11 -6
- package/dist/auth-email-password/web/reset-password-screen.js +7 -1
- package/dist/auth-email-password/web/return-path.d.ts +4 -0
- package/dist/auth-email-password/web/return-path.js +34 -0
- package/dist/auth-email-password/web/session.js +6 -1
- package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
- package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
- package/dist/auth-email-password/web/signup-screen.js +8 -3
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +3 -7
- package/dist/auth-mfa/mfa-status-checker.js +5 -3
- package/dist/cap-counter/cap-reservation.js +15 -6
- package/dist/cap-counter/feature.js +1 -0
- package/dist/cap-overview/feature.js +1 -0
- package/dist/config/handlers/set.write.js +12 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.js +1 -1
- package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
- package/dist/delivery/feature.js +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/system-channel-context.d.ts +15 -0
- package/dist/delivery/system-channel-context.js +5 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
- package/dist/document-ingest-foundation/events.d.ts +11 -0
- package/dist/document-ingest-foundation/events.js +11 -0
- package/dist/document-ingest-foundation/index.d.ts +2 -1
- package/dist/document-ingest-foundation/index.js +2 -1
- package/dist/feature-toggles/feature.js +1 -0
- package/dist/host-routing/validate-options.js +3 -0
- package/dist/inbound-mail-foundation/feature.js +1 -0
- package/dist/jobs/feature.js +1 -0
- package/dist/ledger/web/index.js +1 -1
- package/dist/legal-pages/seed.d.ts +3 -1
- package/dist/legal-pages/seed.js +2 -2
- package/dist/mail-transport-smtp/config-keys.js +1 -1
- package/dist/mail-transport-smtp/feature.js +6 -5
- package/dist/managed-pages/constants.d.ts +1 -0
- package/dist/managed-pages/constants.js +1 -0
- package/dist/managed-pages/feature.js +3 -1
- package/dist/managed-pages/index.d.ts +1 -0
- package/dist/managed-pages/index.js +1 -0
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/personal-access-tokens/feature.js +1 -0
- package/dist/secrets/write-gate.js +8 -2
- package/dist/sessions/feature.js +1 -0
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/session-callbacks.d.ts +0 -2
- package/dist/sessions/session-callbacks.js +1 -3
- package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
- package/dist/shared/purge-files-reaching-subject.js +19 -2
- package/dist/shared/run-in-sub-transaction.js +5 -5
- package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
- package/dist/step-dispatcher/mail-runner.d.ts +1 -0
- package/dist/tags/feature.js +1 -0
- package/dist/template-resolver/document-ops.d.ts +5 -0
- package/dist/template-resolver/document-ops.js +18 -2
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
- package/dist/template-resolver/handlers/layout-source.query.js +26 -6
- package/dist/template-resolver/handlers/overview.query.js +15 -7
- package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
- package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
- package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
- package/dist/template-resolver/seeding.d.ts +1 -0
- package/dist/template-resolver/seeding.js +5 -2
- package/dist/template-resolver/store-template-resource.js +46 -6
- package/dist/template-resolver/web/i18n.js +3 -0
- package/dist/template-resolver/web/qualified-names.d.ts +1 -0
- package/dist/template-resolver/web/qualified-names.js +1 -0
- package/dist/tenant/feature.js +1 -0
- package/dist/tenant/handlers/team-list.query.js +3 -3
- package/dist/tenant-lifecycle/stages.js +22 -1
- package/dist/user/feature.js +1 -0
- package/dist/user-data-rights/feature.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -1
- package/dist/user-data-rights/index.js +1 -1
- package/dist/user-data-rights/web/constants.d.ts +6 -0
- package/dist/user-data-rights/web/constants.js +6 -0
- package/dist/user-data-rights/web/index.d.ts +1 -0
- package/dist/user-data-rights/web/index.js +1 -0
- package/dist/waitlist/feature.js +1 -0
- package/dist/waitlist/handlers/submit.write.js +13 -9
- package/dist/waitlist/import.d.ts +2 -1
- package/dist/waitlist/import.js +22 -2
- package/dist/waitlist/legacy-migrate.js +2 -2
- package/dist/waitlist/options.d.ts +2 -1
- package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
- package/package.json +11 -11
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/cap-counter/changes.json +12 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +12 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/host-routing/changes.json +6 -0
- package/src/mail-transport-smtp/changes.json +6 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/sessions/changes.json +7 -0
- package/src/step-dispatcher/changes.json +6 -0
- package/src/template-resolver/changes.json +36 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/user-data-rights/changes.json +12 -0
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
const next = returnTo ?? res.data.landingPath;
|
|
55
|
+
setContinueHref(next === undefined
|
|
53
56
|
? loginHref
|
|
54
|
-
: buildLoginRedirectUrl(loginHref,
|
|
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
|
-
|
|
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">;
|
package/dist/auth-mfa/config.js
CHANGED
|
@@ -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
|
-
//
|
|
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 { 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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
package/dist/delivery/feature.js
CHANGED
|
@@ -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",
|
package/dist/delivery/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/delivery/index.js
CHANGED
|
@@ -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",
|