@cosmicdrift/kumiko-bundled-features 0.353.0 → 0.354.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 (181) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +9 -3
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/tool-dispatch.js +1 -1
  9. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  10. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  11. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  12. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  13. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  14. package/dist/auth-email-password/handlers/login.write.js +1 -0
  15. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  16. package/dist/auth-email-password/handlers/signup-confirm.write.js +4 -1
  17. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  18. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  19. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +2 -0
  22. package/dist/auth-email-password/seeding.d.ts +2 -0
  23. package/dist/auth-email-password/seeding.js +30 -8
  24. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  25. package/dist/auth-email-password/signup-activation.js +41 -0
  26. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  27. package/dist/auth-email-password/signup-token-store.js +8 -3
  28. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  29. package/dist/auth-email-password/web/auth-gate.js +5 -5
  30. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  31. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  32. package/dist/auth-email-password/web/login-screen.js +2 -2
  33. package/dist/auth-email-password/web/user-menu.js +1 -1
  34. package/dist/auth-mfa/feature.js +5 -0
  35. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  36. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  37. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  38. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  39. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  40. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  41. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  42. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  43. package/dist/channel-in-app/in-app-channel.js +1 -1
  44. package/dist/channel-in-app/tables.js +4 -2
  45. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  46. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  47. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  48. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  49. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  50. package/dist/delivery/delivery-service.js +60 -12
  51. package/dist/delivery/unsubscribe.js +66 -14
  52. package/dist/feature-toggles/feature.js +2 -3
  53. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  54. package/dist/feature-toggles/toggle-runtime.js +30 -6
  55. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  56. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  57. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  58. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  59. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  60. package/dist/files-tenant-data/hooks.js +58 -22
  61. package/dist/folders/handlers/delete-folder.write.js +31 -4
  62. package/dist/foundation-shared/index.d.ts +1 -1
  63. package/dist/foundation-shared/index.js +1 -1
  64. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  65. package/dist/foundation-shared/mail-host-policy.js +13 -4
  66. package/dist/inbound-provider-imap/feature.js +2 -0
  67. package/dist/inbound-provider-imap/imap-client.js +3 -4
  68. package/dist/jobs/constants.d.ts +1 -0
  69. package/dist/jobs/constants.js +1 -0
  70. package/dist/jobs/db/queries/retention.d.ts +1 -0
  71. package/dist/jobs/db/queries/retention.js +5 -0
  72. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  73. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  74. package/dist/jobs/feature.js +7 -1
  75. package/dist/jobs/handlers/list.query.js +1 -1
  76. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  77. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  78. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  79. package/dist/jobs/index.d.ts +2 -0
  80. package/dist/jobs/index.js +1 -0
  81. package/dist/jobs/job-run-logger.d.ts +2 -2
  82. package/dist/jobs/job-run-logger.js +98 -9
  83. package/dist/jobs/tenant-job-failure-table.js +15 -2
  84. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  85. package/dist/jobs/tenant-job-run-table.js +34 -0
  86. package/dist/ledger/entity.d.ts +3 -0
  87. package/dist/ledger/entity.js +13 -5
  88. package/dist/ledger/feature.js +3 -0
  89. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  90. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  91. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  92. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  93. package/dist/ledger/schedule-subject-pair.js +24 -0
  94. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  95. package/dist/mail-transport-smtp/feature.js +4 -13
  96. package/dist/notes-history/entity.js +4 -0
  97. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  98. package/dist/sessions/feature.d.ts +1 -0
  99. package/dist/sessions/feature.js +9 -8
  100. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  101. package/dist/sessions/handlers/detail.query.js +2 -2
  102. package/dist/sessions/handlers/list.query.d.ts +1 -1
  103. package/dist/sessions/handlers/list.query.js +5 -3
  104. package/dist/sessions/screens.d.ts +2 -2
  105. package/dist/sessions/screens.js +6 -7
  106. package/dist/shared/encrypt-for-direct-write.js +10 -2
  107. package/dist/shared/index.d.ts +2 -2
  108. package/dist/shared/index.js +2 -2
  109. package/dist/shared/parent-visibility.d.ts +2 -1
  110. package/dist/shared/parent-visibility.js +21 -0
  111. package/dist/shared/row-bound-grant.js +16 -2
  112. package/dist/shared/session-locale-field.d.ts +1 -0
  113. package/dist/shared/session-locale-field.js +6 -0
  114. package/dist/template-resolver/api.d.ts +2 -2
  115. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  116. package/dist/template-resolver/handlers/list.query.js +2 -2
  117. package/dist/template-resolver/table.d.ts +8 -0
  118. package/dist/template-resolver/user-content-table.d.ts +4 -0
  119. package/dist/tenant/feature.js +7 -1
  120. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  121. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +40 -0
  123. package/dist/tenant/invitation-table.js +6 -1
  124. package/dist/tenant/invited-membership.d.ts +8 -6
  125. package/dist/tenant/invited-membership.js +28 -7
  126. package/dist/tenant/schema/tenant.d.ts +2 -0
  127. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  128. package/dist/tenant-handover/transfer-graph.js +8 -75
  129. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  130. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  131. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  132. package/dist/tenant-lifecycle/constants.js +5 -0
  133. package/dist/tenant-lifecycle/events.d.ts +5 -0
  134. package/dist/tenant-lifecycle/events.js +5 -0
  135. package/dist/tenant-lifecycle/feature.js +5 -3
  136. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
  137. package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
  138. package/dist/tenant-lifecycle/stages.d.ts +6 -1
  139. package/dist/tenant-lifecycle/stages.js +26 -3
  140. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  141. package/dist/tenant-lifecycle/testing.js +42 -0
  142. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  143. package/dist/user/handlers/detail.query.js +2 -0
  144. package/dist/user/handlers/list.query.js +4 -0
  145. package/dist/user/handlers/update.write.js +1 -0
  146. package/dist/user/schema/user.d.ts +2 -0
  147. package/dist/user/seeding.d.ts +1 -0
  148. package/dist/user/seeding.js +20 -0
  149. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  150. package/dist/user-data-rights/index.d.ts +1 -0
  151. package/dist/user-data-rights/index.js +1 -0
  152. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  153. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  154. package/dist/user-profile/handlers/change-email.write.js +8 -2
  155. package/dist/waitlist/entity.d.ts +2 -0
  156. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  157. package/dist/workflow-runner/event-trigger.js +60 -15
  158. package/dist/workflow-runner/handlers/resume-run.write.js +5 -2
  159. package/dist/workflow-runner/runner.d.ts +2 -1
  160. package/dist/workflow-runner/runner.js +5 -1
  161. package/package.json +35 -10
  162. package/src/auth-email-password/changes.json +24 -0
  163. package/src/auth-mfa/changes.json +7 -0
  164. package/src/billing-foundation/changes.json +6 -0
  165. package/src/channel-in-app/changes.json +8 -1
  166. package/src/crypto-shredding/changes.json +12 -0
  167. package/src/delivery/changes.json +6 -0
  168. package/src/files/changes.json +24 -0
  169. package/src/files-tenant-data/changes.json +5 -0
  170. package/src/folders/changes.json +6 -0
  171. package/src/inbound-provider-imap/changes.json +6 -0
  172. package/src/jobs/changes.json +30 -0
  173. package/src/ledger/changes.json +23 -0
  174. package/src/notes-history/changes.json +13 -0
  175. package/src/template-resolver/changes.json +5 -0
  176. package/src/tenant/changes.json +12 -0
  177. package/src/tenant-handover/changes.json +6 -0
  178. package/src/tenant-lifecycle/changes.json +22 -0
  179. package/src/tier-engine/changes.json +5 -0
  180. package/src/user/changes.json +15 -0
  181. package/src/user-data-rights/changes.json +6 -0
@@ -4,6 +4,7 @@ export declare const AgentDocGapKinds: {
4
4
  readonly handlerWithoutDescription: "handler-without-description";
5
5
  readonly customScreenWithoutDescription: "custom-screen-without-description";
6
6
  readonly exposedEntityWithoutDescription: "exposed-entity-without-description";
7
+ readonly handlerWithoutTranslation: "handler-without-translation";
7
8
  };
8
9
  export type AgentDocGapKind = (typeof AgentDocGapKinds)[keyof typeof AgentDocGapKinds];
9
10
  export type AgentDocGap = {
@@ -13,4 +14,5 @@ export type AgentDocGap = {
13
14
  readonly message: string;
14
15
  };
15
16
  export declare function findAgentDocGaps(features: readonly FeatureDefinition[]): readonly AgentDocGap[];
17
+ export declare function findHandlerTranslationGaps(features: readonly FeatureDefinition[]): readonly AgentDocGap[];
16
18
  export declare function formatAgentDocGap(gap: AgentDocGap): string;
@@ -1,10 +1,12 @@
1
- import { isAgentVisibleScreen, QnTypes, qn, resolveAgentExposure, toKebab, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { handlerFieldLabelKey, handlerTitleKey, isAgentVisibleScreen, QnTypes, qn, resolveAgentExposure, toKebab, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import { systemOnlyPropertyNames } from "./agent-manifest.js";
3
4
  export const AgentDocGapKinds = {
4
5
  handlerSchemaNotExpressible: "handler-schema-not-expressible",
5
6
  handlerWithoutDescription: "handler-without-description",
6
7
  customScreenWithoutDescription: "custom-screen-without-description",
7
8
  exposedEntityWithoutDescription: "exposed-entity-without-description",
9
+ handlerWithoutTranslation: "handler-without-translation",
8
10
  };
9
11
  // No shared screen-type union exists in the codebase to import (ScreenDefinition
10
12
  // is a discriminated union keyed by inline string literals) — name the one
@@ -107,6 +109,57 @@ function exposedEntityDocGaps(feature) {
107
109
  }
108
110
  return gaps;
109
111
  }
112
+ function actionFormHandlerQns(features) {
113
+ const handlerQns = new Set();
114
+ for (const feature of features) {
115
+ for (const screen of Object.values(feature.screens ?? {})) {
116
+ if (screen.type === "actionForm")
117
+ handlerQns.add(screen.handler);
118
+ }
119
+ }
120
+ return handlerQns;
121
+ }
122
+ function agentVisibleInputFieldNames(schema) {
123
+ if (!(schema instanceof z.ZodObject))
124
+ return [];
125
+ const fieldNames = Object.keys(schema.shape);
126
+ try {
127
+ const systemOnly = new Set(systemOnlyPropertyNames(z.toJSONSchema(schema, { io: "input" })));
128
+ return fieldNames.filter((field) => !systemOnly.has(field));
129
+ }
130
+ catch {
131
+ // skip: a schema without JSON Schema form is reported as its own gap kind
132
+ return fieldNames;
133
+ }
134
+ }
135
+ function translationGapsOfFeature(feature, coveredHandlerQns) {
136
+ const gaps = [];
137
+ // The client resolves translation keys verbatim (no feature prefix), so only
138
+ // fully spelled-out keys count.
139
+ const translated = new Set(Object.keys(feature.translations ?? {}));
140
+ for (const [name, def] of Object.entries(feature.writeHandlers ?? {})) {
141
+ if (!resolveAgentExposure(def, "write").expose)
142
+ continue;
143
+ if (feature.handlerEntityMappings?.[name] !== undefined)
144
+ continue;
145
+ const handlerQn = qn(toKebab(feature.name), QnTypes.write, toKebab(name));
146
+ if (coveredHandlerQns.has(handlerQn))
147
+ continue;
148
+ const missing = [
149
+ handlerTitleKey(handlerQn),
150
+ ...agentVisibleInputFieldNames(def.schema).map((field) => handlerFieldLabelKey(handlerQn, field)),
151
+ ].filter((key) => !translated.has(key));
152
+ if (missing.length === 0)
153
+ continue;
154
+ gaps.push({
155
+ qn: handlerQn,
156
+ feature: feature.name,
157
+ kind: AgentDocGapKinds.handlerWithoutTranslation,
158
+ message: `This write handler has no entity and no actionForm screen, so its title and field labels can only come from translations — missing keys: ${missing.join(", ")}.`,
159
+ });
160
+ }
161
+ return gaps;
162
+ }
110
163
  export function findAgentDocGaps(features) {
111
164
  const gaps = [];
112
165
  for (const feature of features) {
@@ -119,6 +172,13 @@ export function findAgentDocGaps(features) {
119
172
  // depend on it (mirrors agent-manifest.ts's sortedByKey rationale).
120
173
  return [...gaps].sort((a, b) => compareByCodePoint(a.qn, b.qn));
121
174
  }
175
+ // Kept out of findAgentDocGaps on purpose: the published agent-manifest guard
176
+ // fails on every gap that function returns, and these are advisory.
177
+ export function findHandlerTranslationGaps(features) {
178
+ const coveredHandlerQns = actionFormHandlerQns(features);
179
+ const gaps = features.flatMap((feature) => translationGapsOfFeature(feature, coveredHandlerQns));
180
+ return [...gaps].sort((a, b) => compareByCodePoint(a.qn, b.qn));
181
+ }
122
182
  export function formatAgentDocGap(gap) {
123
183
  return `${gap.qn} — ${gap.message}`;
124
184
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentManifest, AgentManifestOptions, RegistryManifestView } from "./types.js";
2
+ export declare function systemOnlyPropertyNames(jsonSchema: Record<string, unknown>): readonly string[];
2
3
  /** Registry snapshot → AI-agent manifest. Pure, deterministic, no I/O — mirrors
3
4
  * `buildToolCatalog`'s contract, just describing the app's shape instead of
4
5
  * generating callable tools from it. */
@@ -119,13 +119,19 @@ function buildFeatures(features) {
119
119
  }
120
120
  // Agent dispatch never runs as a system identity, so properties the engine only
121
121
  // honors for system callers would be silently dropped — hide them from the tool.
122
- function withoutSystemOnlyProperties(jsonSchema) {
122
+ export function systemOnlyPropertyNames(jsonSchema) {
123
123
  const properties = jsonSchema["properties"];
124
124
  if (typeof properties !== "object" || properties === null)
125
- return jsonSchema;
126
- const hidden = Object.entries(properties)
125
+ return [];
126
+ return Object.entries(properties)
127
127
  .filter(([, property]) => property[SYSTEM_ONLY_JSON_SCHEMA_KEY] === true)
128
128
  .map(([name]) => name);
129
+ }
130
+ function withoutSystemOnlyProperties(jsonSchema) {
131
+ const properties = jsonSchema["properties"];
132
+ if (typeof properties !== "object" || properties === null)
133
+ return jsonSchema;
134
+ const hidden = systemOnlyPropertyNames(jsonSchema);
129
135
  if (hidden.length === 0)
130
136
  return jsonSchema;
131
137
  const required = jsonSchema["required"];
@@ -1,5 +1,5 @@
1
1
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
- import { findAgentDocGaps, formatAgentDocGap } from "./agent-doc-lint.js";
2
+ import { findAgentDocGaps, findHandlerTranslationGaps, formatAgentDocGap, } from "./agent-doc-lint.js";
3
3
  export const AGENT_TOOLS_FEATURE_NAME = "agent-tools";
4
4
  export function createAgentToolsFeature() {
5
5
  return defineFeature(AGENT_TOOLS_FEATURE_NAME, (r) => {
@@ -14,8 +14,13 @@ export function createAgentToolsFeature() {
14
14
  // would turn every incrementally-documented app into an outage.
15
15
  r.bootCheck(({ features }) => {
16
16
  const gaps = findAgentDocGaps(features);
17
+ const warningCount = findHandlerTranslationGaps(features).length;
18
+ if (warningCount > 0) {
19
+ // biome-ignore lint/suspicious/noConsole: operator-visibility at boot for agent-doc gaps
20
+ console.warn(`[agent-tools] ${warningCount} agent-exposed write handler(s) lack title/field-label translations — run \`kumiko agent lint\` for details.`);
21
+ }
17
22
  if (gaps.length === 0) {
18
- // skip: no documentation gaps, nothing to warn about
23
+ // skip: no blocking documentation gaps, nothing more to warn about
19
24
  return;
20
25
  }
21
26
  // biome-ignore lint/suspicious/noConsole: operator-visibility at boot for agent-doc gaps
@@ -1,5 +1,5 @@
1
1
  export type { AgentDocGap, AgentDocGapKind } from "./agent-doc-lint.js";
2
- export { AgentDocGapKinds, findAgentDocGaps, formatAgentDocGap } from "./agent-doc-lint.js";
2
+ export { AgentDocGapKinds, findAgentDocGaps, findHandlerTranslationGaps, formatAgentDocGap, } from "./agent-doc-lint.js";
3
3
  export { buildAgentManifest } from "./agent-manifest.js";
4
4
  export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature.js";
5
5
  export { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "./tool-catalog.js";
@@ -1,4 +1,4 @@
1
- export { AgentDocGapKinds, findAgentDocGaps, formatAgentDocGap } from "./agent-doc-lint.js";
1
+ export { AgentDocGapKinds, findAgentDocGaps, findHandlerTranslationGaps, formatAgentDocGap, } from "./agent-doc-lint.js";
2
2
  export { buildAgentManifest } from "./agent-manifest.js";
3
3
  export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature.js";
4
4
  export { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "./tool-catalog.js";
@@ -1,4 +1,4 @@
1
- import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/api";
1
+ import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
2
2
  import { FILTER_OPS, isRecord } from "./filter-ops.js";
3
3
  const SEARCH_RESULT_LIMIT = 10;
4
4
  const DEFAULT_LIST_LIMIT = 10;
@@ -16,6 +16,7 @@ export const changePasswordWrite = defineWriteHandler({
16
16
  }),
17
17
  access: { roles: access.authenticated },
18
18
  escapeHatch: {
19
+ grants: ["systemIdentity"],
19
20
  reason: "Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, ...) " +
20
21
  "and writes the new hash via ctx.writeAs(SYSTEM, user:update) — the caller's own " +
21
22
  "identity can't reach either, field-access on passwordHash is privileged-only.",
@@ -29,7 +29,7 @@ import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../.
29
29
  // kumiko-lint-ignore cross-feature-import invite-flow
30
30
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
31
31
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
32
- import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
32
+ import { grantInvitedMembershipRole } from "../../tenant/invited-membership.js";
33
33
  // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
34
34
  import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
35
35
  // kumiko-lint-ignore cross-feature-import login-style password-check
@@ -118,7 +118,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
118
118
  userId,
119
119
  tenantId: invitationTenantId,
120
120
  role: invitationRole,
121
- invitationIssuedAt: invitationIssuedAt(invitation),
121
+ invitation,
122
122
  });
123
123
  if (!grant.isSuccess)
124
124
  return grant;
@@ -23,7 +23,7 @@ import { decryptStoredPii } from "../../shared/index.js";
23
23
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
24
24
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
25
25
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add (like provisionSignupAccount)
26
- import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership.js";
26
+ import { grantInvitedMembershipRole } from "../../tenant/invited-membership.js";
27
27
  // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
28
28
  import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
29
29
  // kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
@@ -93,7 +93,7 @@ export function createInviteAcceptHandler() {
93
93
  userId: event.user.id,
94
94
  tenantId: invitationTenantId,
95
95
  role: invitationRole,
96
- invitationIssuedAt: invitationIssuedAt(invitation),
96
+ invitation,
97
97
  });
98
98
  if (!grant.isSuccess)
99
99
  return grant;
@@ -24,10 +24,14 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
24
24
  import * as z from "zod";
25
25
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link-Pattern), DB-row-owner ist tenant-feature
26
26
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
27
+ // kumiko-lint-ignore cross-feature-import membership version read is owned by the tenant feature
28
+ import { membershipVersionOf } from "../../tenant/invited-membership.js";
27
29
  // kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
28
30
  import { findForbiddenMembershipRole, findForbiddenRoleAssignment, reservedMembershipRoleError, unassignableMembershipRoleError, } from "../../tenant/membership-roles.js";
29
31
  // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
30
32
  import { GLOBAL_ROLE_OPTIONS } from "../../user/constants.js";
33
+ // kumiko-lint-ignore cross-feature-import invitee lookup by email reads the user row
34
+ import { userTable } from "../../user/schema/user.js";
31
35
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants.js";
32
36
  import { renderInviteEmail } from "../email-templates.js";
33
37
  import { inviteAlreadyAccepted } from "../errors.js";
@@ -61,6 +65,16 @@ function checkAssignableInviteRole(opts, inviterRoles, role, ctx) {
61
65
  }
62
66
  return undefined;
63
67
  }
68
+ // 0 = the invitee has no membership in the tenant yet (or no account at all).
69
+ async function currentMembershipVersion(ctx, tenantId, email) {
70
+ const user = await ctx.db.global(userTable).fetchOne({
71
+ email,
72
+ isDeleted: false,
73
+ });
74
+ if (!user)
75
+ return 0;
76
+ return membershipVersionOf(ctx.db, tenantId, user.id);
77
+ }
64
78
  async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAccepted = false) {
65
79
  if (!ctx.redis) {
66
80
  return writeFailure(new InternalError({ message: "invite-create requires ctx.redis for token store" }));
@@ -72,6 +86,7 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
72
86
  // The unique index allows one row per (tenantId, email). Whatever its
73
87
  // status, a re-invite resets it to pending with a fresh token.
74
88
  const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
89
+ const membershipVersion = await currentMembershipVersion(ctx, tenantId, email);
75
90
  let invitationId;
76
91
  if (existing) {
77
92
  // Provisioning callers (waitlist) must not flip an accepted invite back to
@@ -96,6 +111,7 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
96
111
  globalRoles: [...request.globalRoles],
97
112
  status: INVITATION_STATUS.pending,
98
113
  invitedBy: inviter.id,
114
+ membershipVersion,
99
115
  expiresAt,
100
116
  },
101
117
  }, inviter, ctx.db);
@@ -109,6 +125,7 @@ async function issueInvitation(opts, ttlSeconds, request, inviter, ctx, refuseAc
109
125
  globalRoles: [...request.globalRoles],
110
126
  status: INVITATION_STATUS.pending,
111
127
  invitedBy: inviter.id,
128
+ membershipVersion,
112
129
  expiresAt,
113
130
  }, inviter, ctx.db);
114
131
  if (!createResult.isSuccess)
@@ -21,7 +21,7 @@ import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-f
21
21
  import { buildSessionRoles, createSystemUser, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
23
23
  import * as z from "zod";
24
- import { decryptStoredPii } from "../../shared/index.js";
24
+ import { decryptStoredPii, registrationLocaleFromRequest } from "../../shared/index.js";
25
25
  // kumiko-lint-ignore cross-feature-import invite-flow
26
26
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
27
27
  // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
@@ -92,11 +92,13 @@ export function createInviteSignupCompleteHandler(opts = {}) {
92
92
  // @cast-boundary db-runner — helpers use only the query API that
93
93
  // DbConnection and DbTx share.
94
94
  const dbConn = ctx.db.unsafeRaw();
95
+ const registrationLocale = registrationLocaleFromRequest();
95
96
  const { id: userId } = await seedUserWithPassword(dbConn, {
96
97
  email: invitationEmail,
97
98
  password: event.payload.password,
98
99
  displayName: invitationEmail.split("@")[0] ?? invitationEmail,
99
100
  emailVerified: true,
101
+ ...registrationLocale,
100
102
  ...(invitationGlobalRoles.length > 0 && { roles: invitationGlobalRoles }),
101
103
  });
102
104
  // Membership-Add via seed-helper (gleiches Pattern wie
@@ -136,6 +138,7 @@ export function createInviteSignupCompleteHandler(opts = {}) {
136
138
  id: userId,
137
139
  tenantId: invitationTenantId,
138
140
  roles: mergedRoles,
141
+ ...registrationLocale,
139
142
  };
140
143
  committed = true;
141
144
  return {
@@ -151,6 +151,7 @@ export function createLoginHandler(opts = {}) {
151
151
  access: { roles: ["anonymous"] },
152
152
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
153
153
  escapeHatch: {
154
+ grants: ["systemIdentity", "unsafeRaw"],
154
155
  reason: "Unauthenticated login has no caller identity yet — it looks up the user row by " +
155
156
  "email via ctx.queryAs(SYSTEM, ...) and resolves tenant membership via " +
156
157
  "ctx.resolveActiveMembership before a session exists. It also reads the MFA " +
@@ -23,6 +23,7 @@ export function createResetPasswordHandler(opts) {
23
23
  access: { roles: ["anonymous"] },
24
24
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
25
25
  escapeHatch: {
26
+ grants: ["systemIdentity"],
26
27
  reason: "Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
27
28
  "target user via ctx.writeAs(SYSTEM, user:update) to set the new password hash.",
28
29
  },
@@ -24,7 +24,7 @@ import { ConflictError, InternalError, reraiseAsKumikoError, writeFailure, } fro
24
24
  import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
25
25
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
26
26
  import * as z from "zod";
27
- import { findSignupHandoverProvider, SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE, } from "../../shared/index.js";
27
+ import { findSignupHandoverProvider, registrationLocaleFromRequest, SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE, } from "../../shared/index.js";
28
28
  // kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
29
29
  import { tenantTable } from "../../tenant/schema/tenant.js";
30
30
  import { invalidSignupToken, signupEmailAlreadyRegistered } from "../errors.js";
@@ -116,6 +116,7 @@ export function createSignupConfirmHandler(opts = {}) {
116
116
  // Display-Name aus email-prefix als sinnvolles Default; User kann
117
117
  // den Tenant-Namen + sein eigenes displayName später ändern.
118
118
  const displayName = email.split("@")[0] ?? email;
119
+ const registrationLocale = registrationLocaleFromRequest();
119
120
  let provisioned;
120
121
  try {
121
122
  provisioned = await provisionSignupAccount(dbConn, {
@@ -126,6 +127,7 @@ export function createSignupConfirmHandler(opts = {}) {
126
127
  tenantKey,
127
128
  // Generated slug as default name: no personal data (the email) in the tenant name.
128
129
  tenantName: tenantKey,
130
+ ...registrationLocale,
129
131
  },
130
132
  // #1463: seedTenant's postSave hooks (tier-engine's auto-default-
131
133
  // tier, an app's auto-default-compliance) must fire on self-signup
@@ -150,6 +152,7 @@ export function createSignupConfirmHandler(opts = {}) {
150
152
  id: provisioned.userId,
151
153
  tenantId: provisioned.tenantId,
152
154
  roles: [...INITIAL_SIGNUP_ROLES],
155
+ ...registrationLocale,
153
156
  };
154
157
  // Claim BEFORE spending the signup token or the binding below and
155
158
  // before `committed` flips: if the claim throws, the `finally` still
@@ -20,18 +20,14 @@
20
20
  // stays always-200 + resend-idempotent so the request path leaks nothing;
21
21
  // suppressing the link on the request side would be defense-in-depth, but
22
22
  // with an enumeration risk of its own (separate concern).
23
- import { generateToken } from "@cosmicdrift/kumiko-framework/api";
24
23
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
25
24
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
- import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
27
25
  import * as z from "zod";
28
26
  import { findSignupHandoverProvider } from "../../shared/index.js";
29
- import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants.js";
30
- import { renderActivationEmail } from "../email-templates.js";
31
- import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
27
+ import { resolveHandlerMailLocale } from "../magic-link-mail.js";
32
28
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.js";
33
- import { invalidateExistingSignupToken, normalizeEmail, storeSignupHandover, storeSignupToken, takeOverSignupHandoverForResend, } from "../signup-token-store.js";
34
- const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
29
+ import { issueSignupActivation } from "../signup-activation.js";
30
+ import { storeSignupHandover, takeOverSignupHandoverForResend } from "../signup-token-store.js";
35
31
  const SignupRequestSchema = z.object({
36
32
  email: z.email(),
37
33
  // Cross-device try-first handover (kumiko-framework#3035 follow-up,
@@ -47,8 +43,6 @@ const SignupRequestSchema = z.object({
47
43
  });
48
44
  const HANDOVER_GRANT_VERIFY_REASON = "verifies a tenant-handover grant against its root row in the source tenant (read-only anchor lookup) before binding it to the signup token";
49
45
  export function createSignupRequestHandler(opts) {
50
- const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES;
51
- const ttlSeconds = ttlMinutes * 60;
52
46
  return defineWriteHandler({
53
47
  name: "signup-request",
54
48
  schema: SignupRequestSchema,
@@ -93,84 +87,64 @@ export function createSignupRequestHandler(opts) {
93
87
  // the visitor just retyped their email) must still carry the earlier
94
88
  // verified grant over to the new token instead of silently dropping it.
95
89
  const priorBinding = await takeOverSignupHandoverForResend(ctx.redis, email);
96
- // At most one live signup token per email: invalidate whatever's
97
- // there before minting the new one (see signup-token-store.ts).
98
- await invalidateExistingSignupToken(ctx.redis, email);
99
- // 32 random bytes = 256 bits unguessable randomness, base64url
100
- // encoded to 43 chars. Math.random used to be a bug here:
101
- // xorshift128+ has ~128 bits of state that's reconstructible after
102
- // ~5 observed outputs — an attacker could trigger their own
103
- // signup-requests and predict other users' tokens. generateToken
104
- // uses randomBytes from node:crypto, the same source as CSRF/
105
- // session tokens.
106
- const token = generateToken();
107
- const issuedAt = Temporal.Now.instant();
108
- const expiresAt = issuedAt.add({ seconds: ttlSeconds });
109
- const expiresAtIso = expiresAt.toString();
110
- await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
111
- // A handover grant in this request re-verifies and replaces the prior
112
- // binding; anything short of a fresh, verified grant (no provider
113
- // mounted, or verification failed) falls back to it instead —
114
- // dropping a still-live binding on a bad request would strand the
115
- // visitor's run. A throw here must never skip the mail below, or the
116
- // signup itself silently breaks for a bug in a provider this handler
117
- // doesn't own.
118
- let binding = priorBinding;
119
- if (event.payload.handover) {
120
- const { entityType, token: grantToken } = event.payload.handover;
121
- const provider = findSignupHandoverProvider(ctx.registry);
122
- if (!provider) {
123
- ctx.log?.warn("signup-request: handover payload present but no signupHandover provider mounted");
124
- }
125
- else {
126
- try {
127
- const verified = await provider.verifyGrant({
128
- db: ctx.db.unsafeRaw(),
129
- registry: ctx.registry,
130
- entityType,
131
- token: grantToken,
132
- });
133
- if (verified) {
134
- binding = verified;
90
+ const locale = resolveHandlerMailLocale(ctx, opts.locale);
91
+ const redis = ctx.redis;
92
+ const issued = await issueSignupActivation({
93
+ redis,
94
+ notify: ctx.notify,
95
+ email,
96
+ appUrl: opts.appUrl,
97
+ timeZone: ctx.tz.user,
98
+ ...(opts.tokenTtlMinutes !== undefined && { tokenTtlMinutes: opts.tokenTtlMinutes }),
99
+ ...(opts.appName !== undefined && { appName: opts.appName }),
100
+ ...(locale !== undefined && { locale }),
101
+ // A handover grant in this request re-verifies and replaces the prior
102
+ // binding; anything short of a fresh, verified grant (no provider
103
+ // mounted, or verification failed) falls back to it instead —
104
+ // dropping a still-live binding on a bad request would strand the
105
+ // visitor's run. A throw here must never skip the mail, or the
106
+ // signup itself silently breaks for a bug in a provider this handler
107
+ // doesn't own.
108
+ onTokenStored: async ({ token, ttlSeconds }) => {
109
+ let binding = priorBinding;
110
+ if (event.payload.handover) {
111
+ const { entityType, token: grantToken } = event.payload.handover;
112
+ const provider = findSignupHandoverProvider(ctx.registry);
113
+ if (!provider) {
114
+ ctx.log?.warn("signup-request: handover payload present but no signupHandover provider mounted");
135
115
  }
136
116
  else {
137
- ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
117
+ try {
118
+ const verified = await provider.verifyGrant({
119
+ db: ctx.db.unsafeRaw(),
120
+ registry: ctx.registry,
121
+ entityType,
122
+ token: grantToken,
123
+ });
124
+ if (verified) {
125
+ binding = verified;
126
+ }
127
+ else {
128
+ ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
129
+ }
130
+ }
131
+ catch {
132
+ ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
133
+ }
138
134
  }
139
135
  }
140
- catch {
141
- ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
136
+ if (binding) {
137
+ await storeSignupHandover(redis, { token, binding, ttlSeconds });
142
138
  }
143
- }
144
- }
145
- if (binding) {
146
- await storeSignupHandover(ctx.redis, { token, binding, ttlSeconds });
147
- }
148
- // normalizeEmail from the store — one source of truth for
149
- // normalization; the delivery recipient + lookup path consistently
150
- // get the same format.
151
- const normalizedEmail = normalizeEmail(email);
152
- const locale = resolveHandlerMailLocale(ctx, opts.locale);
153
- await dispatchMagicLinkMail(ctx.notify, {
154
- handlerName: "signup-request",
155
- notificationType: SIGNUP_NOTIFICATION_TYPE,
156
- renderContent: renderActivationEmail,
157
- }, {
158
- email: normalizedEmail,
159
- appUrl: opts.appUrl,
160
- token,
161
- expiresAt: expiresAtIso,
162
- issuedAt: issuedAt.toString(),
163
- timeZone: ctx.tz.user,
164
- ...(opts.appName !== undefined && { appName: opts.appName }),
165
- locale,
139
+ },
166
140
  });
167
141
  return {
168
142
  isSuccess: true,
169
143
  data: {
170
144
  kind: "signup-requested",
171
- email: normalizedEmail,
172
- token,
173
- expiresAt: expiresAtIso,
145
+ email: issued.email,
146
+ token: issued.token,
147
+ expiresAt: issued.expiresAt,
174
148
  },
175
149
  };
176
150
  },
@@ -35,6 +35,7 @@ export function createTokenRequestHandler(spec, opts) {
35
35
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
36
36
  additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
37
37
  escapeHatch: {
38
+ grants: ["systemIdentity"],
38
39
  reason: "Anonymous requester has no session — looks up the target user by email via " +
39
40
  "ctx.queryAs(SYSTEM, user:findForAuth) to mint the out-of-band token.",
40
41
  },
@@ -21,6 +21,7 @@ export function createVerifyEmailHandler(opts) {
21
21
  access: { roles: ["anonymous"] },
22
22
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
23
23
  escapeHatch: {
24
+ grants: ["systemIdentity"],
24
25
  reason: "Anonymous token holder has no session — runConfirmTokenFlow loads and updates the " +
25
26
  "target user via ctx.writeAs(SYSTEM, user:update) to set emailVerified.",
26
27
  },
@@ -9,3 +9,5 @@ export type { AccountLockoutOptions, AccountUnlockOptions, AuthEmailPasswordOpti
9
9
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature.js";
10
10
  export { AUTH_SELF_REGISTRATION_FEATURE, createAuthSelfRegistrationToggleFeature, } from "./self-registration-toggle.js";
11
11
  export { signToken, TokenPurpose, type VerifyResult, verifyToken } from "./signed-token.js";
12
+ export { type IssuedSignupActivation, type IssueSignupActivationArgs, issueSignupActivation, SIGNUP_ACTIVATION_NOTIFICATION_TYPE, } from "./signup-activation.js";
13
+ export { invalidateExistingSignupToken, normalizeEmail, SIGNUP_TOKEN_KEY_PREFIXES, storeSignupToken, } from "./signup-token-store.js";
@@ -17,3 +17,5 @@ export { AUTH_SELF_REGISTRATION_FEATURE, createAuthSelfRegistrationToggleFeature
17
17
  // statt eigene HMAC-Logik zu duplizieren. Purpose-string diskriminiert
18
18
  // Cross-Replay zwischen Flows.
19
19
  export { signToken, TokenPurpose, verifyToken } from "./signed-token.js";
20
+ export { issueSignupActivation, SIGNUP_ACTIVATION_NOTIFICATION_TYPE, } from "./signup-activation.js";
21
+ export { invalidateExistingSignupToken, normalizeEmail, SIGNUP_TOKEN_KEY_PREFIXES, storeSignupToken, } from "./signup-token-store.js";
@@ -64,6 +64,8 @@ export type ProvisionSignupAccountOptions = {
64
64
  readonly tenantName: string;
65
65
  readonly tenantId: TenantId;
66
66
  readonly memberRoles?: readonly string[];
67
+ /** Stored on the new user row so the first session already carries the locale claim. */
68
+ readonly locale?: string;
67
69
  };
68
70
  export declare function provisionSignupAccount(db: DbConnection, options: ProvisionSignupAccountOptions, hooks?: SeedTenantHooks): Promise<{
69
71
  readonly userId: string;