@cosmicdrift/kumiko-samples 0.388.0 → 0.389.0

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