@stigmer/server 3.39.1-dev.20260928194002 → 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 (124) 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/agentshare/controller.d.ts +9 -4
  31. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  32. package/dist/domain/agentshare/controller.js +3 -3
  33. package/dist/domain/agentshare/controller.js.map +1 -1
  34. package/dist/domain/artifact/file-server.d.ts +2 -1
  35. package/dist/domain/artifact/file-server.d.ts.map +1 -1
  36. package/dist/domain/artifact/file-server.js +6 -3
  37. package/dist/domain/artifact/file-server.js.map +1 -1
  38. package/dist/domain/channelapp/controller.d.ts +8 -3
  39. package/dist/domain/channelapp/controller.d.ts.map +1 -1
  40. package/dist/domain/channelapp/controller.js.map +1 -1
  41. package/dist/domain/environment/controller.d.ts +8 -5
  42. package/dist/domain/environment/controller.d.ts.map +1 -1
  43. package/dist/domain/environment/controller.js +3 -3
  44. package/dist/domain/environment/controller.js.map +1 -1
  45. package/dist/domain/executioncontext/controller.d.ts +7 -4
  46. package/dist/domain/executioncontext/controller.d.ts.map +1 -1
  47. package/dist/domain/executioncontext/controller.js.map +1 -1
  48. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  49. package/dist/domain/mcpserver/controller.js +6 -2
  50. package/dist/domain/mcpserver/controller.js.map +1 -1
  51. package/dist/domain/memory/constants.d.ts +9 -0
  52. package/dist/domain/memory/constants.d.ts.map +1 -1
  53. package/dist/domain/memory/constants.js +9 -0
  54. package/dist/domain/memory/constants.js.map +1 -1
  55. package/dist/domain/memory/controller.d.ts +18 -6
  56. package/dist/domain/memory/controller.d.ts.map +1 -1
  57. package/dist/domain/memory/controller.js +18 -21
  58. package/dist/domain/memory/controller.js.map +1 -1
  59. package/dist/domain/memory/steps.d.ts +38 -10
  60. package/dist/domain/memory/steps.d.ts.map +1 -1
  61. package/dist/domain/memory/steps.js +87 -17
  62. package/dist/domain/memory/steps.js.map +1 -1
  63. package/dist/domain/organization/controller.d.ts +7 -4
  64. package/dist/domain/organization/controller.d.ts.map +1 -1
  65. package/dist/domain/organization/controller.js.map +1 -1
  66. package/dist/domain/session/controller.d.ts +7 -3
  67. package/dist/domain/session/controller.d.ts.map +1 -1
  68. package/dist/domain/session/controller.js.map +1 -1
  69. package/dist/domain/skill/controller.d.ts.map +1 -1
  70. package/dist/domain/skill/controller.js +8 -2
  71. package/dist/domain/skill/controller.js.map +1 -1
  72. package/dist/domain/workflow/controller.d.ts +6 -2
  73. package/dist/domain/workflow/controller.d.ts.map +1 -1
  74. package/dist/domain/workflow/controller.js.map +1 -1
  75. package/dist/domain/workflowexecution/controller.d.ts +8 -4
  76. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  77. package/dist/domain/workflowexecution/controller.js.map +1 -1
  78. package/dist/domain/workflowexecution/update-status.d.ts.map +1 -1
  79. package/dist/domain/workflowexecution/update-status.js +3 -2
  80. package/dist/domain/workflowexecution/update-status.js.map +1 -1
  81. package/dist/extensions/identity.d.ts +28 -14
  82. package/dist/extensions/identity.d.ts.map +1 -1
  83. package/dist/extensions/identity.js +38 -0
  84. package/dist/extensions/identity.js.map +1 -1
  85. package/dist/pipeline/steps/guard-memory-capture.d.ts.map +1 -1
  86. package/dist/pipeline/steps/guard-memory-capture.js +1 -13
  87. package/dist/pipeline/steps/guard-memory-capture.js.map +1 -1
  88. package/package.json +6 -6
  89. package/src/artifactstorage/__tests__/artifact-storage.test.ts +15 -1
  90. package/src/artifactstorage/artifact-storage.ts +12 -4
  91. package/src/boot/__tests__/artifact-lane.test.ts +71 -0
  92. package/src/boot/__tests__/compose.test.ts +73 -3
  93. package/src/boot/__tests__/config.test.ts +19 -0
  94. package/src/boot/artifact-lane.ts +96 -0
  95. package/src/boot/compose.ts +57 -21
  96. package/src/boot/config.ts +25 -15
  97. package/src/domain/agent/controller.ts +5 -2
  98. package/src/domain/agentexecution/__tests__/create-steps.test.ts +230 -2
  99. package/src/domain/agentexecution/controller.ts +32 -6
  100. package/src/domain/agentexecution/create-steps.ts +162 -62
  101. package/src/domain/agentinstance/controller.ts +8 -2
  102. package/src/domain/agentshare/controller.ts +12 -7
  103. package/src/domain/artifact/file-server.ts +8 -4
  104. package/src/domain/channelapp/controller.ts +8 -3
  105. package/src/domain/environment/controller.ts +11 -8
  106. package/src/domain/executioncontext/controller.ts +7 -4
  107. package/src/domain/mcpserver/controller.ts +6 -2
  108. package/src/domain/memory/__tests__/capture-credential.test.ts +6 -6
  109. package/src/domain/memory/__tests__/person-consent-composed.test.ts +187 -0
  110. package/src/domain/memory/__tests__/person-consent.test.ts +274 -0
  111. package/src/domain/memory/constants.ts +11 -0
  112. package/src/domain/memory/controller.ts +36 -27
  113. package/src/domain/memory/steps.ts +112 -17
  114. package/src/domain/organization/controller.ts +7 -4
  115. package/src/domain/plugin/__tests__/plugin.test.ts +8 -0
  116. package/src/domain/session/controller.ts +7 -3
  117. package/src/domain/skill/__tests__/skill.test.ts +4 -0
  118. package/src/domain/skill/controller.ts +8 -2
  119. package/src/domain/workflow/controller.ts +6 -2
  120. package/src/domain/workflowexecution/controller.ts +8 -4
  121. package/src/domain/workflowexecution/update-status.ts +3 -2
  122. package/src/extensions/__tests__/identity.test.ts +82 -0
  123. package/src/extensions/identity.ts +47 -0
  124. package/src/pipeline/steps/guard-memory-capture.ts +4 -19
@@ -13,10 +13,15 @@
13
13
  * cascade of same-org shares lives in the AGENT domain (ported with #6);
14
14
  * share delete itself cascades nothing.
15
15
  *
16
- * Authorization posture (OSS): single-user and local, so handlers perform
17
- * no authorization — a documented no-op, not a silent divergence. The
18
- * cloud edition enforces the same contracts via FGA plus app-level gates
19
- * for the anonymous resolution paths.
16
+ * Every chain opens with Authorize. create asks can_edit on the referenced
17
+ * agent, then can_create_agent_share (AuthorizeResolvedTarget); create and
18
+ * delete run the shared tuple-lifecycle steps against the composed
19
+ * lifecycle; getByReference authorizes the loaded share as `get` would;
20
+ * getByAgent and list narrow through the composed list read scope. The
21
+ * public share-link read skips the Authorizer through is_public, and the
22
+ * member read asks can_view on the organization before the share loads
23
+ * (AuthorizeMemberAudience). Per-RPC posture:
24
+ * docs/authorization-coverage.md §9.
20
25
  *
21
26
  * Proven by agentshare.conformance.test.ts (CONFORMANCE_TARGET=local),
22
27
  * __tests__/agentshare.test.ts and __tests__/store-faults.test.ts.
@@ -657,9 +662,9 @@ function newLoadSharesByAgentStep(
657
662
  const LIST_RESULT_KEY = "listResult";
658
663
 
659
664
  /**
660
- * List — org + labels filter (AND semantics), newest first. Versus Cloud,
661
- * OSS excludes authorization filtering AND pagination, exactly as Go's
662
- * list.go records.
665
+ * List — org + labels filter (AND semantics), newest first, narrowed to
666
+ * the caller's authorized rows by the composed list read scope.
667
+ * Unpaginated, exactly as Go's list.go records.
663
668
  */
664
669
  async function list(
665
670
  deps: AgentShareControllerDeps,
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * The artifact HTTP file server — ports the local-download lane from Go
3
3
  * pkg/server/server.go (artifactDownloadHandler + the boot block, lines
4
- * 849–926): a second listener on 127.0.0.1:ARTIFACT_HTTP_PORT (default
5
- * grpcPort+1), started only when artifact storage is LOCAL, serving
6
- * GET /<key> as the exact bytes LocalArtifactStorage wrote. The lane
4
+ * 849–926): a second listener on 127.0.0.1:ARTIFACT_HTTP_PORT (unset: the
5
+ * unified port + 1, ephemeral beside an ephemeral unified port — the rule is
6
+ * boot/artifact-lane.ts), started only when artifact storage is LOCAL,
7
+ * serving GET /<key> as the exact bytes LocalArtifactStorage wrote. Unlike
8
+ * Go, a bind failure fails the boot: the composition binds the lane before
9
+ * SERVING and lets listen()'s rejection stand (stigmer#1089). The lane
7
10
  * dissolved into its owning domain per the ratified D4 decision — it is
8
11
  * NOT a unified-port lane (Go runs it as a separate listener, and so does
9
12
  * this port).
@@ -39,7 +42,8 @@ export interface ArtifactFileServerOptions {
39
42
 
40
43
  export interface ArtifactFileServer {
41
44
  /**
42
- * Binds <host>:<port> (an explicit port 0 picks ephemeral for tests).
45
+ * Binds <host>:<port> (port 0 picks ephemeral) and resolves the port it
46
+ * bound; a bind failure rejects, and the composition fails the boot on it.
43
47
  * The host is the composition root's call: 127.0.0.1 everywhere by
44
48
  * default (ARTIFACT_HTTP_HOST, DD-013) — download URLs are minted for
45
49
  * the local machine — and 0.0.0.0 only inside the official container
@@ -14,9 +14,14 @@
14
14
  * not_search_indexed — configuration reached through its parent surface,
15
15
  * not a library artifact), so no index steps appear in any chain.
16
16
  *
17
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies, and
18
- * Publish steps (single-user local posture); validation, secret handling,
19
- * and the referential delete block are byte-compatible.
17
+ * Every chain opens with Authorize; create and delete run the shared
18
+ * tuple-lifecycle steps against the composed lifecycle; getByReference
19
+ * loads, then authorizes the loaded app exactly as `get` would
20
+ * (AuthorizeResolvedTarget). listByOrg does not yet narrow through the
21
+ * list read scope, although the model restricts viewing to owners,
22
+ * explicit grants and organization admins
23
+ * (fga/model/agentic/channel_app.fga, stigmer/stigmer#1384). Per-RPC
24
+ * posture: docs/authorization-coverage.md §13.
20
25
  *
21
26
  * Proven by channelapp.conformance.test.ts (CONFORMANCE_TARGET=local)
22
27
  * and __tests__/channelapp.test.ts.
@@ -11,11 +11,14 @@
11
11
  * Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local),
12
12
  * __tests__/environment.test.ts and __tests__/store-faults.test.ts.
13
13
  *
14
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies,
15
- * FGA-tuple, and Publish steps (no multi-tenant auth, IAM/FGA, or event
16
- * publishing here); the visibility ceiling (org — secrets never resolve
17
- * across the org boundary) and share restrictions run in BOTH editions
18
- * from the same proto config, keeping the error contract identical.
14
+ * Every chain opens with Authorize; create, delete and updateVisibility run
15
+ * the shared tuple-lifecycle steps against the composed lifecycle;
16
+ * getByReference loads, then authorizes the loaded environment exactly as
17
+ * `get` would (AuthorizeResolvedTarget); list narrows through the composed
18
+ * list read scope. The visibility ceiling (org — secrets never resolve
19
+ * across the org boundary) and share restrictions come from the same proto
20
+ * config in every edition, keeping the error contract identical. Per-RPC
21
+ * posture: docs/authorization-coverage.md §3.
19
22
  */
20
23
  import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
21
24
  import { create, fromBinary } from "@bufbuild/protobuf";
@@ -734,9 +737,9 @@ async function getSecretValue(
734
737
  const LIST_RESULT_KEY = "listResult";
735
738
 
736
739
  /**
737
- * List — org + labels filter with per-item redaction. Versus Cloud, OSS
738
- * excludes authorization filtering AND pagination (returns all matches),
739
- * exactly as Go's list.go records.
740
+ * List — org + labels filter with per-item redaction, narrowed to the
741
+ * caller's authorized rows by the composed list read scope. Unpaginated
742
+ * (returns all matches), exactly as Go's list.go records.
740
743
  */
741
744
  async function list(
742
745
  deps: EnvironmentControllerDeps,
@@ -25,10 +25,13 @@
25
25
  * (the decrypt-lane matrix conformance deliberately never exercises —
26
26
  * its harness authenticates as a user).
27
27
  *
28
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies,
29
- * and Publish steps (no multi-tenant auth, IAM/FGA, or event publishing
30
- * here); the redaction and decrypt-lane contracts run in BOTH editions,
31
- * keeping the error contract identical.
28
+ * Every chain opens with Authorize; create asks can_create_execution_in on
29
+ * the organization (AuthorizeCreate), and create and delete run the shared
30
+ * tuple-lifecycle steps against the composed lifecycle; getByReference
31
+ * loads, then authorizes the loaded context exactly as `get` would
32
+ * (AuthorizeResolvedTarget). The redaction and decrypt-lane contracts are
33
+ * the same in every edition, keeping the error contract identical. Per-RPC
34
+ * posture: docs/authorization-coverage.md §5.
32
35
  */
33
36
  import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
34
37
 
@@ -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
+ });