@cosmicdrift/kumiko-bundled-features 0.321.0 → 0.323.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 (28) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
  3. package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
  4. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
  5. package/src/agent-tools/agent-manifest.ts +13 -3
  6. package/src/agent-tools/tool-dispatch.ts +17 -12
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +124 -5
  8. package/src/auth-email-password/changes.json +7 -0
  9. package/src/auth-email-password/constants.ts +3 -0
  10. package/src/auth-email-password/feature.ts +4 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -6
  12. package/src/auth-email-password/handlers/invite-info.query.ts +89 -0
  13. package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
  14. package/src/delivery/__tests__/delivery.integration.test.ts +232 -0
  15. package/src/delivery/address-opt-out.ts +113 -24
  16. package/src/delivery/changes.json +7 -0
  17. package/src/delivery/constants.ts +1 -0
  18. package/src/delivery/feature.ts +5 -1
  19. package/src/delivery/handlers/resubscribe-address.write.ts +42 -0
  20. package/src/delivery/handlers/resubscribe-user.write.ts +41 -0
  21. package/src/delivery/public-names.ts +13 -0
  22. package/src/delivery/tables.ts +5 -2
  23. package/src/delivery/unsubscribe.ts +104 -7
  24. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +72 -0
  25. package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
  26. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
  27. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
  28. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.321.0",
3
+ "version": "0.323.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -134,12 +134,12 @@
134
134
  "./workflow-runner": "./src/workflow-runner/index.ts"
135
135
  },
136
136
  "dependencies": {
137
- "@cosmicdrift/kumiko-dispatcher-live": "0.321.0",
138
- "@cosmicdrift/kumiko-framework": "0.321.0",
139
- "@cosmicdrift/kumiko-headless": "0.321.0",
140
- "@cosmicdrift/kumiko-renderer": "0.321.0",
141
- "@cosmicdrift/kumiko-renderer-web": "0.321.0",
142
- "@cosmicdrift/kumiko-types": "0.321.0",
137
+ "@cosmicdrift/kumiko-dispatcher-live": "0.323.0",
138
+ "@cosmicdrift/kumiko-framework": "0.323.0",
139
+ "@cosmicdrift/kumiko-headless": "0.323.0",
140
+ "@cosmicdrift/kumiko-renderer": "0.323.0",
141
+ "@cosmicdrift/kumiko-renderer-web": "0.323.0",
142
+ "@cosmicdrift/kumiko-types": "0.323.0",
143
143
  "@mollie/api-client": "^4.5.0",
144
144
  "@node-rs/argon2": "^2.0.2",
145
145
  "@types/mailparser": "^3.4.6",
@@ -168,8 +168,8 @@
168
168
  ],
169
169
  "devDependencies": {
170
170
  "@testing-library/user-event": "^14.6.1",
171
- "@cosmicdrift/kumiko-locale-de": "0.321.0",
172
- "@cosmicdrift/kumiko-locale-es": "0.321.0",
171
+ "@cosmicdrift/kumiko-locale-de": "0.323.0",
172
+ "@cosmicdrift/kumiko-locale-es": "0.323.0",
173
173
  "jsqr": "^1.4.0"
174
174
  }
175
175
  }
@@ -0,0 +1,127 @@
1
+ // kms.eraseKey reached through configuredPiiSubjectKms() passes the same
2
+ // agent-risk floor executor.forget()/delete() already enforce.
3
+
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import {
6
+ configuredPiiSubjectKms,
7
+ configurePiiSubjectKms,
8
+ InMemoryKmsAdapter,
9
+ type SubjectId,
10
+ } from "@cosmicdrift/kumiko-framework/crypto";
11
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
13
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
14
+ import * as z from "zod";
15
+ import { buildAgentManifest } from "../agent-manifest";
16
+ import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
17
+ import { dispatchToolCall } from "../tool-dispatch";
18
+
19
+ const admin = TestUsers.admin;
20
+
21
+ const SUBJECT: SubjectId = {
22
+ kind: "record",
23
+ entity: "kms-erase-gate-widget",
24
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000001",
25
+ };
26
+
27
+ const ERASE_MID_QN = "kms-erase-gate-test:write:erase-mid";
28
+ const ERASE_HIGH_QN = "kms-erase-gate-test:write:erase-high";
29
+
30
+ const kmsGateFeature = defineFeature("kms-erase-gate-test", (r) => {
31
+ r.writeHandler(
32
+ "erase-mid",
33
+ z.object({}),
34
+ async () => {
35
+ await configuredPiiSubjectKms()!.eraseKey(SUBJECT, {
36
+ requestId: "kms-erase-gate-test:erase-mid",
37
+ eraseReason: "test",
38
+ });
39
+ return { isSuccess: true as const, data: {} };
40
+ },
41
+ {
42
+ access: { roles: ["Admin"] },
43
+ description: "Erase the widget subject key (mid risk, default).",
44
+ },
45
+ );
46
+
47
+ r.writeHandler(
48
+ "erase-high",
49
+ z.object({}),
50
+ async () => {
51
+ await configuredPiiSubjectKms()!.eraseKey(SUBJECT, {
52
+ requestId: "kms-erase-gate-test:erase-high",
53
+ eraseReason: "test",
54
+ });
55
+ return { isSuccess: true as const, data: {} };
56
+ },
57
+ {
58
+ access: { roles: ["Admin"] },
59
+ description: "Erase the widget subject key (high risk).",
60
+ agent: { risk: "high" },
61
+ },
62
+ );
63
+ });
64
+
65
+ let stack: TestStack;
66
+ let kms: InMemoryKmsAdapter;
67
+
68
+ beforeAll(async () => {
69
+ stack = await setupTestStack({ features: [kmsGateFeature] });
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await stack.cleanup();
74
+ });
75
+
76
+ beforeEach(async () => {
77
+ kms = new InMemoryKmsAdapter();
78
+ await kms.createKey(SUBJECT);
79
+ configurePiiSubjectKms(kms);
80
+ });
81
+
82
+ afterEach(() => {
83
+ resetPiiSubjectKmsForTests();
84
+ });
85
+
86
+ function catalogFor(roles: readonly string[]) {
87
+ const manifest = buildAgentManifest(stack.registry, { locale: "en", roles });
88
+ return buildToolCatalog(stack.registry, manifest, { mode: "edit" });
89
+ }
90
+
91
+ describe("kms.eraseKey via configuredPiiSubjectKms() — agent-risk gate", () => {
92
+ test("a mid-risk (default) tool is denied, the key stays readable", async () => {
93
+ const catalog = catalogFor(admin.roles);
94
+ const result = await dispatchToolCall({
95
+ dispatcher: stack.dispatcher,
96
+ user: admin,
97
+ toolName: toolNameForQn(ERASE_MID_QN),
98
+ input: {},
99
+ dispatchTable: catalog.dispatchTable,
100
+ runId: "run-mid",
101
+ toolCallId: "call-mid",
102
+ });
103
+
104
+ expect(result.ok).toBe(false);
105
+ if (result.ok) throw new Error("unreachable");
106
+ expect(result.error).toContain(ERASE_MID_QN);
107
+ expect(result.error).toContain("irreversible");
108
+ expect(result.error).toContain("kms.eraseKey");
109
+ await expect(kms.getKey(SUBJECT)).resolves.toBeTruthy();
110
+ });
111
+
112
+ test("a risk:high tool succeeds and the key is erased", async () => {
113
+ const catalog = catalogFor(admin.roles);
114
+ const result = await dispatchToolCall({
115
+ dispatcher: stack.dispatcher,
116
+ user: admin,
117
+ toolName: toolNameForQn(ERASE_HIGH_QN),
118
+ input: {},
119
+ dispatchTable: catalog.dispatchTable,
120
+ runId: "run-high",
121
+ toolCallId: "call-high",
122
+ });
123
+
124
+ expect(result.ok).toBe(true);
125
+ await expect(kms.getKey(SUBJECT)).rejects.toThrow("Subject key erased");
126
+ });
127
+ });
@@ -8,7 +8,7 @@ import {
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import * as z from "zod";
10
10
  import { buildAgentManifest } from "../agent-manifest";
11
- import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
11
+ import { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "../tool-catalog";
12
12
  import type { AgentManifest, AgentToolMode, RegistrySearchView } from "../types";
13
13
 
14
14
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -440,6 +440,21 @@ describe("buildToolCatalog — built-in client tool names vs. a colliding handle
440
440
  });
441
441
  });
442
442
 
443
+ describe("buildToolCatalog — tools/dispatchTable bijection", () => {
444
+ test("every tool name has exactly one dispatchTable entry, and vice versa, with no duplicates", () => {
445
+ const catalog = buildCatalog(ADMIN);
446
+ const names = catalog.tools.map((t) => t.name);
447
+ expect(names.length).toBeGreaterThan(0);
448
+ expect(new Set(names).size).toBe(names.length);
449
+ // open_form is the one deliberate exception: dispatch-only, claimed via
450
+ // claimDispatchName so the approval layer can trigger it, but never
451
+ // listed for the model to call directly (see addClientTools).
452
+ const dispatchNamesForModel = new Set(catalog.dispatchTable.keys());
453
+ dispatchNamesForModel.delete(OPEN_FORM_TOOL_NAME);
454
+ expect(dispatchNamesForModel).toEqual(new Set(names));
455
+ });
456
+ });
457
+
443
458
  describe("toolNameForQn", () => {
444
459
  test("drops the verb segment, joins with underscores, sanitizes non-identifier characters", () => {
445
460
  expect(toolNameForQn("agent-tools-test-vendor:write:vendor:approve")).toBe(
@@ -8,15 +8,21 @@
8
8
 
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
- import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
+ import {
12
+ createEntity,
13
+ createEntityExecutor,
14
+ createTextField,
15
+ defineFeature,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
12
17
  import {
13
18
  createTestUser,
14
19
  setupTestStack,
15
20
  type TestStack,
16
21
  unsafeCreateEntityTable,
17
22
  } from "@cosmicdrift/kumiko-framework/stack";
23
+ import * as z from "zod";
18
24
  import { buildAgentManifest } from "../agent-manifest";
19
- import { buildToolCatalog } from "../tool-catalog";
25
+ import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
20
26
  import { dispatchToolCall } from "../tool-dispatch";
21
27
 
22
28
  const vendorEntity = createEntity({
@@ -47,11 +53,92 @@ const vendorFeature = defineFeature("agent-tools-test-vendor", (r) => {
47
53
 
48
54
  const VENDOR_CREATE_QN = "agent-tools-test-vendor:write:vendor:create";
49
55
 
56
+ // No softDelete — forget-gizmo-high performs a hard, irreversible purge and
57
+ // therefore must resolve agent.risk "high" to pass the executor gate.
58
+ const gizmoEntity = createEntity({
59
+ table: "agent_tools_test_gizmos",
60
+ fields: {
61
+ label: createTextField({ personal: false, reason: "test_fixture", required: true }),
62
+ },
63
+ });
64
+ const { executor: gizmoExecutor } = createEntityExecutor("gizmo", gizmoEntity);
65
+
66
+ const GIZMO_CREATE_QN = "agent-tools-test-gizmo:write:gizmo:create";
67
+ const GIZMO_FORGET_HIGH_QN = "agent-tools-test-gizmo:write:forget-gizmo-high";
68
+ const GIZMO_DELEGATE_FORGET_MID_QN = "agent-tools-test-gizmo:write:delegate-forget-gizmo-mid";
69
+
70
+ const gizmoFeature = defineFeature("agent-tools-test-gizmo", (r) => {
71
+ r.entity("gizmo", gizmoEntity);
72
+
73
+ r.writeHandler(
74
+ "gizmo:create",
75
+ z.object({ label: z.string() }),
76
+ async (event, ctx) => gizmoExecutor.create(event.payload, event.user, ctx.db),
77
+ { access: { roles: ["Admin"] } },
78
+ );
79
+
80
+ r.writeHandler(
81
+ "forget-gizmo-high",
82
+ z.object({ id: z.uuid() }),
83
+ async (event, ctx) => gizmoExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
84
+ {
85
+ access: { roles: ["Admin"] },
86
+ description: "Permanently forget a gizmo (GDPR erasure).",
87
+ agent: { risk: "high" },
88
+ },
89
+ );
90
+
91
+ r.writeHandler(
92
+ "delegate-forget-gizmo-mid",
93
+ z.object({ id: z.uuid() }),
94
+ async (event, ctx) =>
95
+ ctx.write("agent-tools-test-gizmo:write:forget-gizmo-high", { id: event.payload.id }),
96
+ {
97
+ access: { roles: ["Admin"] },
98
+ description: "Delegates to forget-gizmo-high via ctx.write.",
99
+ },
100
+ );
101
+
102
+ // Simulates the enterprise "approve" write handler: an outer, mid-risk
103
+ // handler that itself runs the real dispatchToolCall path against a
104
+ // tool name given as input, the way an AI-agent turn approves a queued
105
+ // tool call.
106
+ r.writeHandler(
107
+ "approve",
108
+ z.object({ toolName: z.string(), id: z.string() }),
109
+ async (event) => {
110
+ const manifest = buildAgentManifest(stack.registry, {
111
+ locale: "en",
112
+ roles: event.user.roles,
113
+ });
114
+ const catalog = buildToolCatalog(stack.registry, manifest, { mode: "edit" });
115
+ // runId/toolCallId feed the write's idempotency key — must be unique
116
+ // per call, or a second approve() reuses the first call's cached
117
+ // WriteResult instead of actually re-dispatching the tool.
118
+ const result = await dispatchToolCall({
119
+ dispatcher: stack.dispatcher,
120
+ user: event.user,
121
+ toolName: event.payload.toolName,
122
+ input: { id: event.payload.id },
123
+ dispatchTable: catalog.dispatchTable,
124
+ runId: `run-approve:${event.payload.toolName}`,
125
+ toolCallId: `call-approve:${event.payload.id}`,
126
+ });
127
+ return {
128
+ isSuccess: true as const,
129
+ data: result.ok ? { ok: true } : { ok: false, error: result.error },
130
+ };
131
+ },
132
+ { access: { roles: ["Admin"] } },
133
+ );
134
+ });
135
+
50
136
  let stack: TestStack;
51
137
 
52
138
  beforeAll(async () => {
53
- stack = await setupTestStack({ features: [vendorFeature] });
139
+ stack = await setupTestStack({ features: [vendorFeature, gizmoFeature] });
54
140
  await unsafeCreateEntityTable(stack.db, vendorEntity);
141
+ await unsafeCreateEntityTable(stack.db, gizmoEntity, "gizmo");
55
142
  }, 20000);
56
143
 
57
144
  afterAll(async () => {
@@ -61,6 +148,7 @@ afterAll(async () => {
61
148
  beforeEach(async () => {
62
149
  await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
63
150
  await asRawClient(stack.db).unsafe("DELETE FROM agent_tools_test_vendors");
151
+ await asRawClient(stack.db).unsafe("DELETE FROM agent_tools_test_gizmos");
64
152
  });
65
153
 
66
154
  const TENANT_B = "00000000-0000-4000-8000-0000000000bb";
@@ -119,3 +207,42 @@ describe("dispatchToolCall — real <entity>:list pipeline", () => {
119
207
  expect(result.error).toContain("access denied");
120
208
  });
121
209
  });
210
+
211
+ describe("dispatchToolCall — the invoked tool is the entry handler, not the surrounding write handler", () => {
212
+ async function createGizmo(label: string): Promise<string> {
213
+ const { id } = await stack.http.writeOk<{ id: string }>(GIZMO_CREATE_QN, { label }, adminA);
214
+ return id;
215
+ }
216
+
217
+ async function gizmoRowExists(id: string): Promise<boolean> {
218
+ const rows = (await asRawClient(stack.db).unsafe(
219
+ "SELECT id FROM agent_tools_test_gizmos WHERE id = $1",
220
+ [id],
221
+ )) as readonly Record<string, unknown>[];
222
+ return rows.length > 0;
223
+ }
224
+
225
+ test("approve (mid) invoking a high-risk forget tool succeeds — the tool is the entry, not approve", async () => {
226
+ const id = await createGizmo("gone-after-tool-forget");
227
+ const { ok } = await stack.http.writeOk<{ ok: boolean }>(
228
+ "agent-tools-test-gizmo:write:approve",
229
+ { toolName: toolNameForQn(GIZMO_FORGET_HIGH_QN), id },
230
+ adminA,
231
+ );
232
+ expect(ok).toBe(true);
233
+ expect(await gizmoRowExists(id)).toBe(false);
234
+ });
235
+
236
+ test("approve (mid) invoking a mid-risk tool that delegates via ctx.write to the high forget handler is denied", async () => {
237
+ const id = await createGizmo("survives-delegated-forget");
238
+ const { ok, error } = await stack.http.writeOk<{ ok: boolean; error?: string }>(
239
+ "agent-tools-test-gizmo:write:approve",
240
+ { toolName: toolNameForQn(GIZMO_DELEGATE_FORGET_MID_QN), id },
241
+ adminA,
242
+ );
243
+ expect(ok).toBe(false);
244
+ expect(error).toContain(GIZMO_DELEGATE_FORGET_MID_QN);
245
+ expect(error).toContain("irreversible");
246
+ expect(await gizmoRowExists(id)).toBe(true);
247
+ });
248
+ });
@@ -6,6 +6,7 @@ import type {
6
6
  NavDefinition,
7
7
  QueryHandlerDef,
8
8
  ScreenDefinition,
9
+ TranslationEntry,
9
10
  TranslationKeys,
10
11
  WorkspaceDefinition,
11
12
  WriteHandlerDef,
@@ -15,6 +16,7 @@ import {
15
16
  isAgentVisibleScreen,
16
17
  resolveAgentExposure,
17
18
  } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
18
20
  import * as z from "zod";
19
21
  import type {
20
22
  AgentManifest,
@@ -29,6 +31,14 @@ import type {
29
31
  RegistryManifestView,
30
32
  } from "./types";
31
33
 
34
+ /** The manifest surfaces one string per locale — a plural entry reads as its
35
+ * CLDR `other` form since the manifest has no `count` to select a category. */
36
+ function entryAsLabels(entry: TranslationEntry): Readonly<Record<string, string>> {
37
+ return Object.fromEntries(
38
+ Object.entries(entry).map(([locale, value]) => [locale, translationValueOtherText(value)]),
39
+ );
40
+ }
41
+
32
42
  /** Translation keys carry a feature prefix the manifest can't reconstruct
33
43
  * (`showcase:entity:item:field:title`), so match on the suffix instead. */
34
44
  function labelsForSuffix(
@@ -36,7 +46,7 @@ function labelsForSuffix(
36
46
  suffix: string,
37
47
  ): Readonly<Record<string, string>> {
38
48
  for (const [key, labels] of Object.entries(translations)) {
39
- if (key === suffix || key.endsWith(suffix)) return labels;
49
+ if (key === suffix || key.endsWith(suffix)) return entryAsLabels(labels);
40
50
  }
41
51
  return {};
42
52
  }
@@ -226,7 +236,7 @@ function buildNavs(
226
236
 
227
237
  result.push({
228
238
  id: nav.id,
229
- labels: translations[nav.label] ?? {},
239
+ labels: entryAsLabels(translations[nav.label] ?? {}),
230
240
  ...(nav.screen !== undefined && { screen: nav.screen }),
231
241
  ...(nav.parent !== undefined && { parent: nav.parent }),
232
242
  workspaces: visibleWorkspaces,
@@ -304,7 +314,7 @@ function buildWorkspaces(
304
314
  if (!uiVisible(workspace.access, roles)) continue;
305
315
  result.push({
306
316
  id: workspace.id,
307
- labels: translations[workspace.label] ?? {},
317
+ labels: entryAsLabels(translations[workspace.label] ?? {}),
308
318
  ...(workspace.default === true && { default: true as const }),
309
319
  });
310
320
  }
@@ -1,3 +1,4 @@
1
+ import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/api";
1
2
  import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import type { ToolDispatchDescriptor } from "./types";
3
4
 
@@ -355,18 +356,22 @@ export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCa
355
356
  }
356
357
 
357
358
  try {
358
- switch (descriptor.kind) {
359
- case "search":
360
- return await dispatchSearch(descriptor, dispatcher, user, toolName, input);
361
- case "findBy":
362
- return await dispatchFindBy(descriptor, dispatcher, user, toolName, input);
363
- case "client":
364
- return dispatchClient(descriptor, input);
365
- case "server":
366
- return descriptor.op === "query"
367
- ? await dispatchServerQuery(descriptor, dispatcher, user, input)
368
- : await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
369
- }
359
+ // The invoked tool, not the surrounding turn/approve write handler, must
360
+ // be what the irreversible-operation gate sees as the entry handler.
361
+ return await runAsDirectCallEntry(async () => {
362
+ switch (descriptor.kind) {
363
+ case "search":
364
+ return await dispatchSearch(descriptor, dispatcher, user, toolName, input);
365
+ case "findBy":
366
+ return await dispatchFindBy(descriptor, dispatcher, user, toolName, input);
367
+ case "client":
368
+ return dispatchClient(descriptor, input);
369
+ case "server":
370
+ return descriptor.op === "query"
371
+ ? await dispatchServerQuery(descriptor, dispatcher, user, input)
372
+ : await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
373
+ }
374
+ });
370
375
  } catch (err) {
371
376
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
372
377
  }
@@ -50,7 +50,7 @@ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
50
50
  import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
51
51
  import { createUserFeature } from "../../user/feature";
52
52
  import { userEntity, userTable } from "../../user/schema/user";
53
- import { AuthErrors, AuthHandlers } from "../constants";
53
+ import { AuthErrors, AuthHandlers, AuthQueries } from "../constants";
54
54
  import { createAuthEmailPasswordFeature } from "../feature";
55
55
  import { storeInviteToken } from "../invite-token-store";
56
56
  import { seedUser } from "../seeding";
@@ -125,6 +125,7 @@ beforeAll(async () => {
125
125
  acceptHandler: AuthHandlers.inviteAccept,
126
126
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
127
127
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
128
+ infoHandler: AuthQueries.inviteInfo,
128
129
  },
129
130
  },
130
131
  });
@@ -149,8 +150,11 @@ beforeEach(async () => {
149
150
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
150
151
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
151
152
  emailTransport.sent.length = 0;
152
- const allKeys = await stack.redis.redis.keys("invite:*");
153
- if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
153
+ // Also clears rate-limit buckets: trustedProxyHops defaults to 0, so
154
+ // every call in this file shares one unknown-IP bucket per handler —
155
+ // without this, earlier invite-info calls would count toward the
156
+ // rate-limit test's own budget.
157
+ await stack.redis.flushNamespace();
154
158
 
155
159
  // Pro Test frische Tenant-IDs + tenant.key (sonst unique-violation
156
160
  // auf read_tenants_key_unique beim 2. Run).
@@ -438,7 +442,7 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
438
442
  }
439
443
  });
440
444
 
441
- test("Wrong password → 422 invalid_invite_token (anti-enum)", async () => {
445
+ test("Wrong password → 422 invalid_credentials, invite stays acceptable", async () => {
442
446
  const token = await inviteEmail(BOB_EMAIL, "Editor");
443
447
  const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
444
448
  token,
@@ -447,7 +451,16 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
447
451
  });
448
452
  expect(res.status).toBe(422);
449
453
  const body = (await res.json()) as { error?: { details?: { reason?: string } } };
450
- expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
454
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
455
+
456
+ // Token was released on failure (existing finally/unburn) — the same
457
+ // invite must still be acceptable with the correct password.
458
+ const retryRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
459
+ token,
460
+ email: BOB_EMAIL,
461
+ password: BOB_PASSWORD,
462
+ });
463
+ expect(retryRes.status).toBe(200);
451
464
  });
452
465
 
453
466
  test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
@@ -536,6 +549,112 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
536
549
  });
537
550
  });
538
551
 
552
+ describe("invite-info (anonymous, read-only lookup)", () => {
553
+ test("open invite for an existing account → {email, hasAccount:true}, token stays acceptable afterwards", async () => {
554
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
555
+
556
+ const res = await stack.http.raw("POST", "/api/auth/invite-info", { token });
557
+ expect(res.status).toBe(200);
558
+ const body = (await res.json()) as { email?: string; hasAccount?: boolean };
559
+ expect(body.email).toBe(BOB_EMAIL);
560
+ expect(body.hasAccount).toBe(true);
561
+
562
+ // Token was never consumed — the same invite can still be accepted.
563
+ const acceptRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
564
+ token,
565
+ email: BOB_EMAIL,
566
+ password: BOB_PASSWORD,
567
+ });
568
+ expect(acceptRes.status).toBe(200);
569
+ });
570
+
571
+ test("open invite for a new email → hasAccount:false", async () => {
572
+ const token = await inviteEmail(CAROL_EMAIL, "Admin");
573
+
574
+ const res = await stack.http.raw("POST", "/api/auth/invite-info", { token });
575
+ expect(res.status).toBe(200);
576
+ const body = (await res.json()) as { email?: string; hasAccount?: boolean };
577
+ expect(body.email).toBe(CAROL_EMAIL);
578
+ expect(body.hasAccount).toBe(false);
579
+ });
580
+
581
+ test("unknown token → 422 invalid_invite_token", async () => {
582
+ const res = await stack.http.raw("POST", "/api/auth/invite-info", {
583
+ token: "not-a-real-token",
584
+ });
585
+ expect(res.status).toBe(422);
586
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
587
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
588
+ });
589
+
590
+ test("error body has the same shape as the write routes' — invalid_invite_token sits at error.details.reason on both", async () => {
591
+ const infoRes = await stack.http.raw("POST", "/api/auth/invite-info", {
592
+ token: "not-a-real-token",
593
+ });
594
+ const writeRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
595
+ token: "not-a-real-token",
596
+ email: BOB_EMAIL,
597
+ password: BOB_PASSWORD,
598
+ });
599
+ expect(infoRes.status).toBe(422);
600
+ expect(writeRes.status).toBe(422);
601
+ type ErrorBody = {
602
+ isSuccess?: boolean;
603
+ error?: {
604
+ code?: string;
605
+ httpStatus?: number;
606
+ i18nKey?: string;
607
+ details?: { reason?: string };
608
+ };
609
+ };
610
+ const infoBody = (await infoRes.json()) as ErrorBody;
611
+ const writeBody = (await writeRes.json()) as ErrorBody;
612
+ expect(Object.keys(infoBody).sort()).toEqual(Object.keys(writeBody).sort());
613
+ expect(Object.keys(infoBody.error ?? {}).sort()).toEqual(
614
+ Object.keys(writeBody.error ?? {}).sort(),
615
+ );
616
+ expect(infoBody.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
617
+ expect(writeBody.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
618
+ });
619
+
620
+ test("already-accepted invitation → 422 invalid_invite_token", async () => {
621
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
622
+ const acceptRes = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
623
+ token,
624
+ email: BOB_EMAIL,
625
+ password: BOB_PASSWORD,
626
+ });
627
+ expect(acceptRes.status).toBe(200);
628
+
629
+ const res = await stack.http.raw("POST", "/api/auth/invite-info", { token });
630
+ expect(res.status).toBe(422);
631
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
632
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
633
+ });
634
+
635
+ test("token in the query string instead of the body → invalid_body", async () => {
636
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
637
+ const res = await stack.http.raw(
638
+ "POST",
639
+ `/api/auth/invite-info?token=${encodeURIComponent(token)}`,
640
+ {},
641
+ );
642
+ expect(res.status).toBe(400);
643
+ const body = (await res.json()) as { error?: string };
644
+ expect(body.error).toBe("invalid_body");
645
+ });
646
+
647
+ test("rate limit: the 21st call in a window is rejected with 429", async () => {
648
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
649
+ for (let i = 0; i < 20; i++) {
650
+ const res = await stack.http.raw("POST", "/api/auth/invite-info", { token });
651
+ expect(res.status).toBe(200);
652
+ }
653
+ const limited = await stack.http.raw("POST", "/api/auth/invite-info", { token });
654
+ expect(limited.status).toBe(429);
655
+ });
656
+ });
657
+
539
658
  describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", () => {
540
659
  test("a forbidden role planted directly on the invitation row (bug/migration) is rejected, not silently granted", async () => {
541
660
  // Simulates the ONE scenario this depth-layer exists for: a forbidden role
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.323.0",
4
+ "type": "improvement",
5
+ "title": "New anonymous invite-info lookup route; invite-accept-with-login now returns invalid_credentials for a wrong or missing password",
6
+ "detail": "POST /auth/invite-info ({token} -> {email, hasAccount}) lets an invite-acceptance page pre-fill the email and show a login-vs-signup form without consuming the token; it 422s invalid_invite_token for any unknown/non-pending token, same as the accept routes. invite-accept-with-login's wrong-password and no-password-set branches now return 422 invalid_credentials instead of collapsing into invalid_invite_token — safe to reveal because reaching that branch already required a valid, open invite token plus the matching invitation email.",
7
+ "migration": "No action needed for apps using createAuthEmailPasswordFeature's default wiring via runProdApp/runDevApp — the new route only mounts when the host app's auth-routes config sets invite.infoHandler (runProdApp/runDevApp now do this automatically when the invite feature is enabled). A client that special-cased invite-accept-with-login's invalid_invite_token response to also mean \"wrong password\" should switch to matching invalid_credentials for that case."
8
+ },
2
9
  {
3
10
  "version": "0.320.0",
4
11
  "type": "fix",
@@ -50,6 +50,9 @@ export const AuthHandlers = {
50
50
  // signup page can decide whether to show its own link/form.
51
51
  export const AuthQueries = {
52
52
  signupRegistrationStatus: "auth-email-password:query:signup-registration-status",
53
+ // Anonymous, read-only invite lookup for the invite-acceptance page —
54
+ // see invite-info.query.ts. Does not consume the token.
55
+ inviteInfo: "auth-email-password:query:invite-info",
53
56
  } as const;
54
57
 
55
58
  // Error codes — kept intentionally generic so clients can't distinguish
@@ -10,6 +10,7 @@ import {
10
10
  createInviteCreateHandler,
11
11
  type InviteCreateOptions,
12
12
  } from "./handlers/invite-create.write";
13
+ import { inviteInfoQuery } from "./handlers/invite-info.query";
13
14
  import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write";
14
15
  import { createLoginHandler, type LoginHandlerOptions } from "./handlers/login.write";
15
16
  import { logoutWrite } from "./handlers/logout.write";
@@ -233,6 +234,9 @@ export function createAuthEmailPasswordFeature(
233
234
  ...(opts.signup && {
234
235
  signupRegistrationStatus: r.queryHandler(selfRegistrationStatusQuery),
235
236
  }),
237
+ ...(opts.invite && {
238
+ inviteInfo: r.queryHandler(inviteInfoQuery),
239
+ }),
236
240
  };
237
241
 
238
242
  if (opts.signup) {