@stigmer/server 3.39.0 → 3.40.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 (150) hide show
  1. package/dist/artifactstorage/artifact-storage.d.ts +10 -2
  2. package/dist/artifactstorage/artifact-storage.d.ts.map +1 -1
  3. package/dist/artifactstorage/artifact-storage.js +6 -3
  4. package/dist/artifactstorage/artifact-storage.js.map +1 -1
  5. package/dist/boot/artifact-lane.d.ts +55 -0
  6. package/dist/boot/artifact-lane.d.ts.map +1 -0
  7. package/dist/boot/artifact-lane.js +74 -0
  8. package/dist/boot/artifact-lane.js.map +1 -0
  9. package/dist/boot/compose.d.ts.map +1 -1
  10. package/dist/boot/compose.js +42 -19
  11. package/dist/boot/compose.js.map +1 -1
  12. package/dist/boot/config.d.ts +11 -6
  13. package/dist/boot/config.d.ts.map +1 -1
  14. package/dist/boot/config.js +10 -5
  15. package/dist/boot/config.js.map +1 -1
  16. package/dist/domain/agent/controller.d.ts +5 -2
  17. package/dist/domain/agent/controller.d.ts.map +1 -1
  18. package/dist/domain/agent/controller.js.map +1 -1
  19. package/dist/domain/agentexecution/controller.d.ts +18 -4
  20. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  21. package/dist/domain/agentexecution/controller.js +2 -2
  22. package/dist/domain/agentexecution/controller.js.map +1 -1
  23. package/dist/domain/agentexecution/create-steps.d.ts +29 -15
  24. package/dist/domain/agentexecution/create-steps.d.ts.map +1 -1
  25. package/dist/domain/agentexecution/create-steps.js +118 -51
  26. package/dist/domain/agentexecution/create-steps.js.map +1 -1
  27. package/dist/domain/agentinstance/controller.d.ts +8 -2
  28. package/dist/domain/agentinstance/controller.d.ts.map +1 -1
  29. package/dist/domain/agentinstance/controller.js.map +1 -1
  30. package/dist/domain/agentinstance/steps.d.ts +3 -1
  31. package/dist/domain/agentinstance/steps.d.ts.map +1 -1
  32. package/dist/domain/agentinstance/steps.js +12 -6
  33. package/dist/domain/agentinstance/steps.js.map +1 -1
  34. package/dist/domain/agentshare/controller.d.ts +9 -4
  35. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  36. package/dist/domain/agentshare/controller.js +3 -3
  37. package/dist/domain/agentshare/controller.js.map +1 -1
  38. package/dist/domain/artifact/file-server.d.ts +2 -1
  39. package/dist/domain/artifact/file-server.d.ts.map +1 -1
  40. package/dist/domain/artifact/file-server.js +6 -3
  41. package/dist/domain/artifact/file-server.js.map +1 -1
  42. package/dist/domain/channelapp/controller.d.ts +8 -3
  43. package/dist/domain/channelapp/controller.d.ts.map +1 -1
  44. package/dist/domain/channelapp/controller.js.map +1 -1
  45. package/dist/domain/environment/controller.d.ts +8 -5
  46. package/dist/domain/environment/controller.d.ts.map +1 -1
  47. package/dist/domain/environment/controller.js +3 -3
  48. package/dist/domain/environment/controller.js.map +1 -1
  49. package/dist/domain/executioncontext/controller.d.ts +7 -4
  50. package/dist/domain/executioncontext/controller.d.ts.map +1 -1
  51. package/dist/domain/executioncontext/controller.js.map +1 -1
  52. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  53. package/dist/domain/mcpserver/controller.js +6 -2
  54. package/dist/domain/mcpserver/controller.js.map +1 -1
  55. package/dist/domain/memory/constants.d.ts +9 -0
  56. package/dist/domain/memory/constants.d.ts.map +1 -1
  57. package/dist/domain/memory/constants.js +9 -0
  58. package/dist/domain/memory/constants.js.map +1 -1
  59. package/dist/domain/memory/controller.d.ts +18 -6
  60. package/dist/domain/memory/controller.d.ts.map +1 -1
  61. package/dist/domain/memory/controller.js +18 -21
  62. package/dist/domain/memory/controller.js.map +1 -1
  63. package/dist/domain/memory/steps.d.ts +38 -10
  64. package/dist/domain/memory/steps.d.ts.map +1 -1
  65. package/dist/domain/memory/steps.js +87 -17
  66. package/dist/domain/memory/steps.js.map +1 -1
  67. package/dist/domain/organization/controller.d.ts +7 -4
  68. package/dist/domain/organization/controller.d.ts.map +1 -1
  69. package/dist/domain/organization/controller.js.map +1 -1
  70. package/dist/domain/plugin/controller.d.ts.map +1 -1
  71. package/dist/domain/plugin/controller.js +10 -5
  72. package/dist/domain/plugin/controller.js.map +1 -1
  73. package/dist/domain/session/controller.d.ts +7 -3
  74. package/dist/domain/session/controller.d.ts.map +1 -1
  75. package/dist/domain/session/controller.js.map +1 -1
  76. package/dist/domain/skill/controller.d.ts.map +1 -1
  77. package/dist/domain/skill/controller.js +15 -5
  78. package/dist/domain/skill/controller.js.map +1 -1
  79. package/dist/domain/workflow/controller.d.ts +6 -2
  80. package/dist/domain/workflow/controller.d.ts.map +1 -1
  81. package/dist/domain/workflow/controller.js +6 -3
  82. package/dist/domain/workflow/controller.js.map +1 -1
  83. package/dist/domain/workflowexecution/controller.d.ts +8 -4
  84. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  85. package/dist/domain/workflowexecution/controller.js.map +1 -1
  86. package/dist/domain/workflowexecution/update-status.d.ts.map +1 -1
  87. package/dist/domain/workflowexecution/update-status.js +3 -2
  88. package/dist/domain/workflowexecution/update-status.js.map +1 -1
  89. package/dist/domain/workflowinstance/controller.d.ts +2 -2
  90. package/dist/domain/workflowinstance/controller.d.ts.map +1 -1
  91. package/dist/domain/workflowinstance/controller.js +10 -5
  92. package/dist/domain/workflowinstance/controller.js.map +1 -1
  93. package/dist/domain/workflowinstance/steps.d.ts +5 -3
  94. package/dist/domain/workflowinstance/steps.d.ts.map +1 -1
  95. package/dist/domain/workflowinstance/steps.js +14 -9
  96. package/dist/domain/workflowinstance/steps.js.map +1 -1
  97. package/dist/extensions/identity.d.ts +28 -14
  98. package/dist/extensions/identity.d.ts.map +1 -1
  99. package/dist/extensions/identity.js +38 -0
  100. package/dist/extensions/identity.js.map +1 -1
  101. package/dist/pipeline/steps/guard-memory-capture.d.ts.map +1 -1
  102. package/dist/pipeline/steps/guard-memory-capture.js +1 -13
  103. package/dist/pipeline/steps/guard-memory-capture.js.map +1 -1
  104. package/package.json +6 -6
  105. package/src/artifactstorage/__tests__/artifact-storage.test.ts +15 -1
  106. package/src/artifactstorage/artifact-storage.ts +12 -4
  107. package/src/boot/__tests__/artifact-lane.test.ts +71 -0
  108. package/src/boot/__tests__/compose.test.ts +73 -3
  109. package/src/boot/__tests__/config.test.ts +19 -0
  110. package/src/boot/artifact-lane.ts +96 -0
  111. package/src/boot/compose.ts +57 -21
  112. package/src/boot/config.ts +25 -15
  113. package/src/domain/agent/controller.ts +5 -2
  114. package/src/domain/agentexecution/__tests__/create-steps.test.ts +230 -2
  115. package/src/domain/agentexecution/controller.ts +32 -6
  116. package/src/domain/agentexecution/create-steps.ts +162 -62
  117. package/src/domain/agentinstance/__tests__/store-faults.test.ts +69 -18
  118. package/src/domain/agentinstance/controller.ts +8 -2
  119. package/src/domain/agentinstance/steps.ts +12 -6
  120. package/src/domain/agentshare/controller.ts +12 -7
  121. package/src/domain/artifact/file-server.ts +8 -4
  122. package/src/domain/channelapp/controller.ts +8 -3
  123. package/src/domain/environment/controller.ts +11 -8
  124. package/src/domain/executioncontext/controller.ts +7 -4
  125. package/src/domain/mcpserver/controller.ts +6 -2
  126. package/src/domain/memory/__tests__/capture-credential.test.ts +6 -6
  127. package/src/domain/memory/__tests__/person-consent-composed.test.ts +187 -0
  128. package/src/domain/memory/__tests__/person-consent.test.ts +274 -0
  129. package/src/domain/memory/constants.ts +11 -0
  130. package/src/domain/memory/controller.ts +36 -27
  131. package/src/domain/memory/steps.ts +112 -17
  132. package/src/domain/organization/controller.ts +7 -4
  133. package/src/domain/plugin/__tests__/plugin.test.ts +8 -0
  134. package/src/domain/plugin/__tests__/store-faults.test.ts +104 -0
  135. package/src/domain/plugin/controller.ts +10 -5
  136. package/src/domain/session/controller.ts +7 -3
  137. package/src/domain/skill/__tests__/skill.test.ts +4 -0
  138. package/src/domain/skill/__tests__/store-faults.test.ts +103 -0
  139. package/src/domain/skill/controller.ts +15 -5
  140. package/src/domain/workflow/__tests__/store-faults.test.ts +107 -0
  141. package/src/domain/workflow/controller.ts +12 -5
  142. package/src/domain/workflowexecution/controller.ts +8 -4
  143. package/src/domain/workflowexecution/update-status.ts +3 -2
  144. package/src/domain/workflowinstance/__tests__/store-faults.test.ts +172 -0
  145. package/src/domain/workflowinstance/controller.ts +12 -7
  146. package/src/domain/workflowinstance/steps.ts +14 -10
  147. package/src/extensions/__tests__/identity.test.ts +82 -0
  148. package/src/extensions/identity.ts +47 -0
  149. package/src/pipeline/__tests__/blind-not-found.test.ts +4 -20
  150. package/src/pipeline/steps/guard-memory-capture.ts +4 -19
@@ -19,8 +19,12 @@
19
19
  * managed-env wiring is a disclosed, deliberately unpinned composition
20
20
  * artifact).
21
21
  *
22
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies,
23
- * and Publish steps (no multi-tenant auth, IAM/FGA, or event publishing).
22
+ * Every chain opens with Authorize; create, delete and updateVisibility run
23
+ * the shared tuple-lifecycle steps against the composed lifecycle;
24
+ * getByReference loads, then authorizes the loaded server exactly as `get`
25
+ * would (AuthorizeResolvedTarget). The connect/OAuth handlers evaluate
26
+ * their annotation through authorizeDirect, after the load where they load
27
+ * (#224). Per-RPC posture: docs/authorization-coverage.md §20.
24
28
  *
25
29
  * Proven by mcpserver.conformance.test.ts +
26
30
  * agent-mcpserver-references.conformance.test.ts +
@@ -42,7 +42,7 @@ function memoryCtx(spec: {
42
42
  }
43
43
 
44
44
  describe("ResolveMemoryDefaults with an admitted capture credential", () => {
45
- it("writes the proved subject and overrides provenance.session_id", () => {
45
+ it("writes the proved subject and overrides provenance.session_id", async () => {
46
46
  const ctx = memoryCtx({
47
47
  provenance: {
48
48
  sessionId: "ses_runner_reported",
@@ -54,7 +54,7 @@ describe("ResolveMemoryDefaults with an admitted capture credential", () => {
54
54
  subjectIdentityAccountId: "ida_human",
55
55
  provedSessionId: "ses_proved",
56
56
  });
57
- newResolveMemoryDefaultsStep().execute(ctx);
57
+ await newResolveMemoryDefaultsStep().execute(ctx);
58
58
  expect(ctx.newState.spec?.subjectIdentityAccountId).toBe("ida_human");
59
59
  expect(ctx.newState.spec?.provenance?.sessionId).toBe("ses_proved");
60
60
  // The threaded agent id survives; the forged tool_call_id never does.
@@ -62,21 +62,21 @@ describe("ResolveMemoryDefaults with an admitted capture credential", () => {
62
62
  expect(ctx.newState.spec?.provenance?.toolCallId).toBe("");
63
63
  });
64
64
 
65
- it("creates provenance when the request supplied none", () => {
65
+ it("creates provenance when the request supplied none", async () => {
66
66
  const ctx = memoryCtx({});
67
67
  ctx.set(MEMORY_CAPTURE_CREDENTIAL_KEY, {
68
68
  subjectIdentityAccountId: "ida_human",
69
69
  provedSessionId: "ses_proved",
70
70
  });
71
- newResolveMemoryDefaultsStep().execute(ctx);
71
+ await newResolveMemoryDefaultsStep().execute(ctx);
72
72
  expect(ctx.newState.spec?.provenance?.sessionId).toBe("ses_proved");
73
73
  });
74
74
 
75
- it("without the handoff the OSS arms stand", () => {
75
+ it("without the handoff the OSS arms stand", async () => {
76
76
  const ctx = memoryCtx({
77
77
  provenance: { sessionId: "ses_supplied", toolCallId: "forged" },
78
78
  });
79
- newResolveMemoryDefaultsStep().execute(ctx);
79
+ await newResolveMemoryDefaultsStep().execute(ctx);
80
80
  expect(ctx.newState.spec?.subjectIdentityAccountId).toBe("");
81
81
  expect(ctx.newState.spec?.provenance?.sessionId).toBe("ses_supplied");
82
82
  expect(ctx.newState.spec?.provenance?.toolCallId).toBe("");
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Pins memory's double opt-in and its subject at the composition root
3
+ * (stigmer#1387), over a real boot under the BUILT-IN posture (a unit that
4
+ * declares require-authentication and registers no Authorizer — the
5
+ * open-source self-host with sign-in), with two signed-in people on the
6
+ * wire:
7
+ *
8
+ * - Carol founds an organization with memory on. With her own switch
9
+ * still off (the default), her capture is refused with the account
10
+ * copy — before this fix it was stored, filed under nobody.
11
+ * - Once she turns her own switch on, her capture is filed under HER
12
+ * account id, she can read and confirm it (the model's subject tuple
13
+ * names her), and Dave, a member of the same organization, can
14
+ * neither read it nor see it listed: memory is subject-only.
15
+ * - Dave, whose own switch is off, is refused exactly as Carol was.
16
+ *
17
+ * Recall is not driven here: an execution create needs an engine, and the
18
+ * recall step runs after the engine check. Its gates are pinned
19
+ * step-for-step in domain/agentexecution/__tests__/create-steps.test.ts,
20
+ * over the same identity-account reads this boot composes.
21
+ */
22
+ import { mkdtempSync, rmSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import path from "node:path";
25
+
26
+ import { clone, create } from "@bufbuild/protobuf";
27
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
28
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
29
+
30
+ import { MemorySchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
31
+ import { MemoryCommandController } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/command_pb";
32
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
33
+ import { MemoryQueryController } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/query_pb";
34
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
35
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
36
+ import {
37
+ IdentityAccountPreferencesSchema,
38
+ IdentityAccountSpecSchema,
39
+ } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
40
+ import { IdentityAccountCommandController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/command_pb";
41
+ import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
42
+
43
+ import { loadConfig } from "../../../boot/config.js";
44
+ import { composeServer } from "../../../boot/compose.js";
45
+ import type { ComposedServer } from "../../../boot/compose.js";
46
+ import type { ServerExtension } from "../../../extensions/registry.js";
47
+ import {
48
+ baseConfig,
49
+ fakeJwt,
50
+ fakeVerifier,
51
+ silentLogger,
52
+ transportFor,
53
+ } from "../../../extensions/__tests__/composed-support.js";
54
+ import { MEMORY_ACCOUNT_DISABLED_MESSAGE } from "../constants.js";
55
+
56
+ const CAROL = "fake|carol";
57
+ const DAVE = "fake|dave";
58
+ const ORG = "memory-person-org";
59
+
60
+ async function failureOf(promise: Promise<unknown>): Promise<ConnectError> {
61
+ try {
62
+ await promise;
63
+ } catch (error) {
64
+ expect(error).toBeInstanceOf(ConnectError);
65
+ return error as ConnectError;
66
+ }
67
+ throw new Error("expected the call to fail");
68
+ }
69
+
70
+ function memoryInput(content: string) {
71
+ return create(MemorySchema, {
72
+ apiVersion: "agentic.stigmer.ai/v1",
73
+ kind: "Memory",
74
+ metadata: { org: ORG },
75
+ spec: { content },
76
+ });
77
+ }
78
+
79
+ describe("memory where callers are persons (built-in posture, composed server)", () => {
80
+ let dir: string;
81
+ let server: ComposedServer;
82
+ let port: number;
83
+ let carolAccount: IdentityAccount;
84
+
85
+ const asCarol = () => transportFor(port, fakeJwt(CAROL, "carol@example.com"));
86
+ const asDave = () => transportFor(port, fakeJwt(DAVE, "dave@example.com"));
87
+
88
+ beforeAll(async () => {
89
+ dir = mkdtempSync(path.join(tmpdir(), "memory-person-consent-composed-"));
90
+ const unit: ServerExtension = {
91
+ name: "fake-oidc-only",
92
+ requireAuthentication: true,
93
+ identityVerifiers: [fakeVerifier],
94
+ };
95
+ server = await composeServer({
96
+ config: loadConfig(baseConfig(dir)),
97
+ logger: silentLogger,
98
+ extensions: [unit],
99
+ portOverride: 0,
100
+ host: "127.0.0.1",
101
+ });
102
+ port = await server.start();
103
+
104
+ // Carol provisions and founds the organization with memory on; Dave
105
+ // provisions afterwards and is a member of it (the membership rules
106
+ // run at a person's first provisioning).
107
+ carolAccount = await createClient(
108
+ IdentityAccountCommandController,
109
+ asCarol(),
110
+ ).provisionMyAccount({});
111
+ await createClient(OrganizationCommandController, asCarol()).create({
112
+ apiVersion: "tenancy.stigmer.ai/v1",
113
+ kind: "Organization",
114
+ metadata: { name: ORG, slug: ORG, org: "" },
115
+ spec: { description: ORG, preferences: { memoryEnabled: true } },
116
+ });
117
+ await createClient(
118
+ IdentityAccountCommandController,
119
+ asDave(),
120
+ ).provisionMyAccount({});
121
+ });
122
+
123
+ afterAll(async () => {
124
+ await server.shutdown();
125
+ rmSync(dir, { recursive: true, force: true });
126
+ });
127
+
128
+ it("refuses a person whose own switch is off, with the account's copy", async () => {
129
+ const failure = await failureOf(
130
+ createClient(MemoryCommandController, asCarol()).create(
131
+ memoryInput("Carol prefers OpenTofu."),
132
+ ),
133
+ );
134
+ expect(failure.code).toBe(Code.FailedPrecondition);
135
+ expect(failure.rawMessage).toBe(MEMORY_ACCOUNT_DISABLED_MESSAGE);
136
+ });
137
+
138
+ it("files the fact under the person once they opt in, and it stays theirs alone", async () => {
139
+ const carolId = carolAccount.metadata?.id ?? "";
140
+ expect(carolId).not.toBe("");
141
+ const optedIn = clone(IdentityAccountSchema, carolAccount);
142
+ optedIn.spec ??= create(IdentityAccountSpecSchema);
143
+ optedIn.spec.preferences ??= create(IdentityAccountPreferencesSchema);
144
+ optedIn.spec.preferences.memoryEnabled = true;
145
+ await createClient(IdentityAccountCommandController, asCarol()).update(
146
+ optedIn,
147
+ );
148
+
149
+ const carolMemories = createClient(MemoryCommandController, asCarol());
150
+ const created = await carolMemories.create(
151
+ memoryInput("Carol prefers OpenTofu."),
152
+ );
153
+ const memoryId = created.metadata?.id ?? "";
154
+ expect(created.spec?.subjectIdentityAccountId).toBe(carolId);
155
+
156
+ // The subject tuple names Carol: she decides on her own memory.
157
+ const confirmed = await carolMemories.confirm({ value: memoryId });
158
+ expect(confirmed.status?.lifecycleState).toBe(
159
+ MemoryLifecycleState.lifecycle_state_confirmed,
160
+ );
161
+ const carolReads = createClient(MemoryQueryController, asCarol());
162
+ expect((await carolReads.get({ value: memoryId })).metadata?.id).toBe(
163
+ memoryId,
164
+ );
165
+ expect(
166
+ (await carolReads.list({ org: ORG })).items.map((m) => m.metadata?.id),
167
+ ).toContain(memoryId);
168
+
169
+ // Dave shares the organization, not the memory.
170
+ const daveReads = createClient(MemoryQueryController, asDave());
171
+ const failure = await failureOf(daveReads.get({ value: memoryId }));
172
+ expect(failure.code).toBe(Code.PermissionDenied);
173
+ expect(
174
+ (await daveReads.list({ org: ORG })).items.map((m) => m.metadata?.id),
175
+ ).not.toContain(memoryId);
176
+ });
177
+
178
+ it("refuses the second person exactly as the first, while their switch is off", async () => {
179
+ const failure = await failureOf(
180
+ createClient(MemoryCommandController, asDave()).create(
181
+ memoryInput("Dave deploys on Fridays."),
182
+ ),
183
+ );
184
+ expect(failure.code).toBe(Code.FailedPrecondition);
185
+ expect(failure.rawMessage).toBe(MEMORY_ACCOUNT_DISABLED_MESSAGE);
186
+ });
187
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Pins memory capture where callers are persons (stigmer#1387): with the
3
+ * identity-account directory composed, ResolveMemoryDefaults files the
4
+ * memory under the id of the account it is about — the admitted capture
5
+ * credential's subject (an account id or a raw issuer subject, read the
6
+ * way accountForStamp reads a creator stamp), else the calling person —
7
+ * and hands that account to CheckMemoryEnablement, which requires the
8
+ * organization's switch and then the person's own (the Java
9
+ * MemoryCreateHandler's order and copy). A caller no account stands for is
10
+ * stamped "" and refused by the enablement step, behind the authorization
11
+ * bar. Without the directory — the single-operator posture — the org flag
12
+ * alone decides and the subject stays the "" sentinel.
13
+ */
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+
18
+ import { create } from "@bufbuild/protobuf";
19
+ import { Code, ConnectError } from "@connectrpc/connect";
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+
22
+ import { MemorySchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
23
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
24
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
25
+ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
26
+
27
+ import type { CallerIdentity } from "../../../extensions/identity.js";
28
+ import { RequestContext } from "../../../pipeline/request-context.js";
29
+ import { MEMORY_CAPTURE_CREDENTIAL_KEY } from "../../../pipeline/steps/guard-memory-capture.js";
30
+ import type { Store } from "../../../store/interface.js";
31
+ import { SqliteStore } from "../../../store/sqlite/store.js";
32
+ import type { AccountsByCaller } from "../../identityaccount/resolve.js";
33
+ import { fakeIdentityAccountStore } from "../../identityaccount/__tests__/support.js";
34
+ import type { FakeIdentityAccountStore } from "../../identityaccount/__tests__/support.js";
35
+ import {
36
+ MEMORY_ACCOUNT_DISABLED_MESSAGE,
37
+ memoryDisabledMessage,
38
+ } from "../constants.js";
39
+ import {
40
+ memorySubjectAccountOf,
41
+ newCheckMemoryEnablementStep,
42
+ newResolveMemoryDefaultsStep,
43
+ } from "../steps.js";
44
+
45
+ const ORG = "org_acme";
46
+ const CAROL = "ida_carol";
47
+ const CAROL_SUBJECT = "auth0|carol";
48
+
49
+ /** Carol at the console: a wire user whose identity is her account id. */
50
+ const CAROL_AT_CONSOLE: CallerIdentity = {
51
+ identityId: CAROL,
52
+ callerClass: "user",
53
+ issuer: "https://issuer.example",
54
+ rawToken: "opaque",
55
+ };
56
+
57
+ /** A sandbox-lane caller; its capture credential names the subject. */
58
+ const SANDBOX: CallerIdentity = {
59
+ identityId: CAROL,
60
+ callerClass: "sandbox",
61
+ issuer: "stigmer",
62
+ rawToken: "opaque",
63
+ };
64
+
65
+ /** A signed-in caller no account stands for yet (idp-shaped). */
66
+ const STRANGER: CallerIdentity = {
67
+ identityId: "auth0|stranger",
68
+ callerClass: "user",
69
+ issuer: "https://issuer.example",
70
+ rawToken: "opaque",
71
+ };
72
+
73
+ let dir: string;
74
+ let store: Store;
75
+ let accounts: FakeIdentityAccountStore;
76
+
77
+ beforeEach(async () => {
78
+ dir = mkdtempSync(path.join(tmpdir(), "memory-person-consent-"));
79
+ store = SqliteStore.open(path.join(dir, "stigmer.db"));
80
+ accounts = fakeIdentityAccountStore();
81
+ seedCarol({ memoryEnabled: true });
82
+ await seedOrg(true);
83
+ });
84
+
85
+ afterEach(() => {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ });
88
+
89
+ function seedCarol(preferences: { memoryEnabled: boolean }): void {
90
+ accounts.rows.set(
91
+ CAROL,
92
+ create(IdentityAccountSchema, {
93
+ metadata: { id: CAROL, name: "Carol" },
94
+ spec: { idpId: CAROL_SUBJECT, preferences },
95
+ }),
96
+ );
97
+ }
98
+
99
+ async function seedOrg(memoryEnabled: boolean): Promise<void> {
100
+ await store.saveResource(
101
+ ApiResourceKind.organization,
102
+ ORG,
103
+ OrganizationSchema,
104
+ create(OrganizationSchema, {
105
+ metadata: { id: ORG, name: ORG, org: ORG },
106
+ spec: { preferences: { memoryEnabled } },
107
+ }),
108
+ );
109
+ }
110
+
111
+ function memoryCtx(
112
+ caller: CallerIdentity,
113
+ ): RequestContext<typeof MemorySchema> {
114
+ return new RequestContext(
115
+ MemorySchema,
116
+ create(MemorySchema, {
117
+ metadata: { name: "m", org: ORG },
118
+ // A forged subject: the server always overwrites it.
119
+ spec: { content: "fact", subjectIdentityAccountId: "ida_forged" },
120
+ }),
121
+ caller,
122
+ ApiResourceKind.memory,
123
+ );
124
+ }
125
+
126
+ function withCredential(
127
+ ctx: RequestContext<typeof MemorySchema>,
128
+ subject: string,
129
+ ): RequestContext<typeof MemorySchema> {
130
+ ctx.set(MEMORY_CAPTURE_CREDENTIAL_KEY, {
131
+ subjectIdentityAccountId: subject,
132
+ provedSessionId: "ses_proved",
133
+ });
134
+ return ctx;
135
+ }
136
+
137
+ /** Runs the create chain's two consent steps in their chain order. */
138
+ async function capture(
139
+ ctx: RequestContext<typeof MemorySchema>,
140
+ personAccounts: AccountsByCaller | undefined,
141
+ ): Promise<void> {
142
+ await newResolveMemoryDefaultsStep(personAccounts).execute(ctx);
143
+ await newCheckMemoryEnablementStep(store, personAccounts).execute(ctx);
144
+ }
145
+
146
+ async function refusalOf(run: Promise<void>): Promise<ConnectError> {
147
+ try {
148
+ await run;
149
+ } catch (error) {
150
+ return ConnectError.from(error);
151
+ }
152
+ throw new Error("expected the capture to be refused");
153
+ }
154
+
155
+ describe("capture where callers are persons", () => {
156
+ it("files a direct capture under the calling person's account", async () => {
157
+ const ctx = memoryCtx(CAROL_AT_CONSOLE);
158
+ await capture(ctx, accounts);
159
+ expect(ctx.newState.spec?.subjectIdentityAccountId).toBe(CAROL);
160
+ expect(memorySubjectAccountOf(ctx)?.metadata?.id).toBe(CAROL);
161
+ });
162
+
163
+ it("files a credentialed capture under the credential's subject", async () => {
164
+ const ctx = withCredential(memoryCtx(SANDBOX), CAROL);
165
+ await capture(ctx, accounts);
166
+ expect(ctx.newState.spec?.subjectIdentityAccountId).toBe(CAROL);
167
+ expect(ctx.newState.spec?.provenance?.sessionId).toBe("ses_proved");
168
+ });
169
+
170
+ it("normalises a credential subject that arrived as a raw issuer subject to the account id", async () => {
171
+ const ctx = withCredential(memoryCtx(SANDBOX), CAROL_SUBJECT);
172
+ await capture(ctx, accounts);
173
+ expect(ctx.newState.spec?.subjectIdentityAccountId).toBe(CAROL);
174
+ });
175
+
176
+ it("refuses with the person's copy when their own switch is off", async () => {
177
+ seedCarol({ memoryEnabled: false });
178
+ for (const ctx of [
179
+ memoryCtx(CAROL_AT_CONSOLE),
180
+ withCredential(memoryCtx(SANDBOX), CAROL),
181
+ ]) {
182
+ const refusal = await refusalOf(capture(ctx, accounts));
183
+ expect(refusal.code).toBe(Code.FailedPrecondition);
184
+ expect(refusal.rawMessage).toBe(MEMORY_ACCOUNT_DISABLED_MESSAGE);
185
+ }
186
+ });
187
+
188
+ it("asks the organization first: its switch off refuses with the org's copy", async () => {
189
+ seedCarol({ memoryEnabled: false });
190
+ await seedOrg(false);
191
+ const refusal = await refusalOf(
192
+ capture(memoryCtx(CAROL_AT_CONSOLE), accounts),
193
+ );
194
+ expect(refusal.code).toBe(Code.FailedPrecondition);
195
+ expect(refusal.rawMessage).toBe(memoryDisabledMessage(ORG));
196
+ });
197
+
198
+ it("stamps nobody and refuses in the enablement step when no account stands for the caller", async () => {
199
+ const ctx = memoryCtx(STRANGER);
200
+ await newResolveMemoryDefaultsStep(accounts).execute(ctx);
201
+ // Not refused here: the refusal belongs behind the authorization bar.
202
+ expect(ctx.newState.spec?.subjectIdentityAccountId).toBe("");
203
+ expect(memorySubjectAccountOf(ctx)).toBeUndefined();
204
+ const refusal = await refusalOf(
205
+ Promise.resolve(
206
+ newCheckMemoryEnablementStep(store, accounts).execute(ctx),
207
+ ),
208
+ );
209
+ expect(refusal.code).toBe(Code.FailedPrecondition);
210
+ expect(refusal.rawMessage).toBe(MEMORY_ACCOUNT_DISABLED_MESSAGE);
211
+ });
212
+
213
+ it("refuses a credential whose subject names no account", async () => {
214
+ const ctx = withCredential(memoryCtx(SANDBOX), "chn_org_acme");
215
+ const refusal = await refusalOf(capture(ctx, accounts));
216
+ expect(refusal.code).toBe(Code.FailedPrecondition);
217
+ expect(ctx.newState.spec?.subjectIdentityAccountId).toBe("");
218
+ });
219
+
220
+ it("answers a directory fault as an infrastructure fault, never a refusal", async () => {
221
+ const faulting: AccountsByCaller = {
222
+ findById: async () => {
223
+ throw new Error("simulated store fault");
224
+ },
225
+ findDirectByIdpId: async () => {
226
+ throw new Error("simulated store fault");
227
+ },
228
+ };
229
+ const refusal = await refusalOf(
230
+ Promise.resolve(
231
+ newResolveMemoryDefaultsStep(faulting).execute(
232
+ memoryCtx(CAROL_AT_CONSOLE),
233
+ ),
234
+ ),
235
+ );
236
+ expect(refusal.code).toBe(Code.Internal);
237
+ });
238
+
239
+ it("refuses to file under an account row that carries no id", async () => {
240
+ const nameless: AccountsByCaller = {
241
+ findById: async () =>
242
+ create(IdentityAccountSchema, {
243
+ spec: { preferences: { memoryEnabled: true } },
244
+ }),
245
+ findDirectByIdpId: async () => undefined,
246
+ };
247
+ const refusal = await refusalOf(
248
+ Promise.resolve(
249
+ newResolveMemoryDefaultsStep(nameless).execute(
250
+ memoryCtx(CAROL_AT_CONSOLE),
251
+ ),
252
+ ),
253
+ );
254
+ expect(refusal.code).toBe(Code.Internal);
255
+ });
256
+ });
257
+
258
+ describe("capture under the single-operator posture", () => {
259
+ it("keeps the sentinel subject and the org flag alone, whatever the account says", async () => {
260
+ seedCarol({ memoryEnabled: false });
261
+ const ctx = memoryCtx(CAROL_AT_CONSOLE);
262
+ await capture(ctx, undefined);
263
+ expect(ctx.newState.spec?.subjectIdentityAccountId).toBe("");
264
+ expect(memorySubjectAccountOf(ctx)).toBeUndefined();
265
+ });
266
+
267
+ it("still refuses with the org's copy when the organization is off", async () => {
268
+ await seedOrg(false);
269
+ const refusal = await refusalOf(
270
+ capture(memoryCtx(CAROL_AT_CONSOLE), undefined),
271
+ );
272
+ expect(refusal.rawMessage).toBe(memoryDisabledMessage(ORG));
273
+ });
274
+ });
@@ -30,6 +30,17 @@ export function memoryDisabledMessage(org: string): string {
30
30
  return `memory is not enabled for organization ${org} — an organization admin can enable it in organization preferences`;
31
31
  }
32
32
 
33
+ /**
34
+ * Refuses a create while the PERSON the memory would be about has not
35
+ * enabled memory on their own account — the member half of the double
36
+ * opt-in (DD-006 D1), and the answer for a caller no account stands for,
37
+ * since only an unprovisioned person reaches that arm. The Java
38
+ * MemoryPolicy.MEMORY_ACCOUNT_DISABLED_MESSAGE, byte-pinned: the hosted
39
+ * edition's clients have rendered it since the Java handler shipped.
40
+ */
41
+ export const MEMORY_ACCOUNT_DISABLED_MESSAGE =
42
+ "memory is not enabled for your account — enable it in account preferences";
43
+
33
44
  /**
34
45
  * Refuses confirming a rejected memory: the decision is auditable and
35
46
  * stands; a fresh proposal is the way back. Go
@@ -35,12 +35,15 @@
35
35
  *
36
36
  * Authorization posture (OSS): this edition is single-user and local, so
37
37
  * handlers perform no authorization — a documented no-op, not a silent
38
- * divergence. The subject is the empty-string sentinel (the OAuth grant
39
- * store convention); enablement is the org flag alone (the user scope
40
- * collapses — DD-002 D1). The cloud edition derives the subject from the
41
- * calling credential, enforces the strict first-party-human gate, checks
42
- * both memory_enabled flags, and gates every RPC through FGA (subject-only
43
- * can_view/can_edit/can_delete).
38
+ * divergence. The subject and the double opt-in follow the POSTURE, not
39
+ * the edition (stigmer#1387): where callers are persons (the
40
+ * require-authentication posture composes `personAccounts` — the hosted
41
+ * edition, a self-host with sign-in) the subject is the identity account
42
+ * the memory is about and both memory_enabled flags must be on; under the
43
+ * single-operator posture (trusted-local) the subject is the empty-string
44
+ * sentinel (the OAuth grant store convention) and enablement is the org
45
+ * flag alone (the user scope collapses — DD-002 D1). Every RPC's FGA
46
+ * question is subject-only (can_view/can_edit/can_delete).
44
47
  *
45
48
  * Memory is deliberately NOT search-indexed (privacy — subject-only
46
49
  * content must not surface in org-visible search): no search-extractor,
@@ -70,6 +73,7 @@ import type { Logger } from "../../boot/logger.js";
70
73
  import type { Authorizer } from "../../extensions/authorizer.js";
71
74
  import type { RunnerCredentialProvider } from "../../runnerauth/runner-credential-provider.js";
72
75
  import type { ListReadScope } from "../../extensions/list-read-scope.js";
76
+ import type { AccountsByCaller } from "../identityaccount/resolve.js";
73
77
  import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
74
78
  import { apiResourceKindKey } from "../../pipeline/interceptors/apiresource.js";
75
79
  import { internalError } from "../../pipeline/errors.js";
@@ -131,6 +135,14 @@ export interface MemoryControllerDeps {
131
135
  readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
132
136
  /** The composed list read scope — list narrows through it; undefined = the OSS full scan (20260830.01). */
133
137
  readonly listReadScope: ListReadScope | undefined;
138
+ /**
139
+ * The directory of the persons callers stand for (stigmer#1387) — the
140
+ * composed identity-account port, bound under the require-authentication
141
+ * posture; undefined = the single-operator posture (trusted-local),
142
+ * where memory is the organization's switch alone and the subject the
143
+ * empty-string sentinel.
144
+ */
145
+ readonly personAccounts: AccountsByCaller | undefined;
134
146
  /**
135
147
  * The composed runner-credential provider — GuardMemoryCapture consults
136
148
  * its authorizeMemoryCapture capability for the runner-credential
@@ -172,10 +184,13 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
172
184
  * No search-index step: memory is not_search_indexed by design (privacy —
173
185
  * content is subject-only and must not surface in org-visible search).
174
186
  *
175
- * Note: Unlike Stigmer Cloud, OSS excludes the strict
176
- * first-party-human-operator gate and the caller's own memory_enabled
177
- * check (no per-request user identity — the user scope collapses, DD-006
178
- * D1) and creates no FGA tuples.
187
+ * Authorization: GuardMemoryCapture decides WHO may capture (the composed
188
+ * RunnerCredentialProvider), AuthorizeResolvedTarget decides WHERE
189
+ * (can_create_session on the organization), and CreateAuthorizationTuples
190
+ * runs against the composed lifecycle. ResolveMemoryDefaults names the
191
+ * person the memory is about and CheckMemoryEnablement requires both
192
+ * halves of the double opt-in where callers are persons (both step docs;
193
+ * stigmer#1387).
179
194
  */
180
195
  async function createMemory(
181
196
  deps: MemoryControllerDeps,
@@ -195,7 +210,7 @@ async function createMemory(
195
210
  .addStep(newValidateProtoStep())
196
211
  .addStep(newGuardMemoryCaptureStep(deps.runnerCredentialProvider))
197
212
  .addStep(newValidateVisibilityStep())
198
- .addStep(newResolveMemoryDefaultsStep())
213
+ .addStep(newResolveMemoryDefaultsStep(deps.personAccounts))
199
214
  // Before the enablement and cap checks, so a refused caller learns
200
215
  // nothing about the organization's settings (steps.ts,
201
216
  // resolveMemoryCreateTargets).
@@ -205,7 +220,7 @@ async function createMemory(
205
220
  resolveMemoryCreateTargets,
206
221
  ),
207
222
  )
208
- .addStep(newCheckMemoryEnablementStep(deps.store))
223
+ .addStep(newCheckMemoryEnablementStep(deps.store, deps.personAccounts))
209
224
  .addStep(newCheckMemoryCapStep(deps.store))
210
225
  .addStep(newResolveSlugStep())
211
226
  .addStep(newCheckDuplicateStep(deps.store))
@@ -236,8 +251,7 @@ async function createMemory(
236
251
  * owners (create, confirm, reject) last wrote it, even against a
237
252
  * concurrent confirm landing between this pipeline's load and persist.
238
253
  *
239
- * Note: Unlike Stigmer Cloud, OSS excludes the authorization step (cloud
240
- * requires can_edit on the memory — FGA subject-only).
254
+ * Authorize evaluates can_edit on the memory (subject-only in the model).
241
255
  */
242
256
  async function update(
243
257
  deps: MemoryControllerDeps,
@@ -332,9 +346,8 @@ async function reject(
332
346
  * runTransition: one contract with opposite verdicts (DD-005 D3),
333
347
  * answering with the post-image row.
334
348
  *
335
- * Note: Unlike Stigmer Cloud, OSS excludes the authorization step (cloud
336
- * requires can_edit on the memory — FGA subject-only, loading before
337
- * authorizing so a missing memory answers NOT_FOUND, #224).
349
+ * Authorize evaluates can_edit on the memory (subject-only in the model)
350
+ * against the request's id, before the load.
338
351
  */
339
352
  async function runTransition(
340
353
  deps: MemoryControllerDeps,
@@ -381,8 +394,7 @@ async function runTransition(
381
394
  *
382
395
  * No search-index cleanup: memory is not_search_indexed.
383
396
  *
384
- * Note: Unlike Stigmer Cloud, OSS excludes the authorization step (cloud
385
- * requires can_delete on the memory — FGA subject-only).
397
+ * Authorize evaluates can_delete on the memory (subject-only in the model).
386
398
  *
387
399
  * The deleted memory is returned for audit trail purposes (gRPC
388
400
  * convention).
@@ -425,9 +437,8 @@ async function deleteMemory(
425
437
  /**
426
438
  * Get — chain per Go buildGetPipeline (ExtractResourceId → LoadTarget).
427
439
  *
428
- * Note: Unlike Stigmer Cloud, OSS excludes the authorization step (cloud
429
- * requires can_view — FGA subject-only: only the person a memory is about
430
- * can read it).
440
+ * Authorize evaluates can_view on the memory: in the model only the person
441
+ * a memory is about can read it.
431
442
  */
432
443
  async function get(
433
444
  deps: MemoryControllerDeps,
@@ -466,11 +477,9 @@ async function get(
466
477
  * console's presentation concern (DD-005 D4), deliberately not an RPC
467
478
  * parameter at the kind's dozens-of-records scale.
468
479
  *
469
- * Note: Unlike Stigmer Cloud, OSS excludes:
470
- * - Authorization filtering (single user — every record is the
471
- * caller's; cloud filters to can_view via FGA, which resolves to the
472
- * subject)
473
- * - Pagination (returns all matching results)
480
+ * Narrowed to the caller's authorized rows (can_view, which resolves to
481
+ * the subject) by the composed list read scope. Unpaginated (returns all
482
+ * matching results).
474
483
  */
475
484
  async function list(
476
485
  deps: MemoryControllerDeps,