@agent-native/creative-context 0.7.25 → 0.8.0-nightly-20260915020720

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 (76) hide show
  1. package/dist/client/actions.d.ts +3 -1
  2. package/dist/client/actions.d.ts.map +1 -1
  3. package/dist/client/actions.js +2 -2
  4. package/dist/client/actions.js.map +1 -1
  5. package/dist/client/agent-tab.d.ts.map +1 -1
  6. package/dist/client/agent-tab.js +5 -7
  7. package/dist/client/agent-tab.js.map +1 -1
  8. package/dist/client/application-state.d.ts +3 -1
  9. package/dist/client/application-state.d.ts.map +1 -1
  10. package/dist/client/application-state.js +9 -2
  11. package/dist/client/application-state.js.map +1 -1
  12. package/dist/client/index.d.ts +1 -0
  13. package/dist/client/index.d.ts.map +1 -1
  14. package/dist/client/index.js +1 -0
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/useCreativeContextLab.d.ts +8 -0
  17. package/dist/client/useCreativeContextLab.d.ts.map +1 -0
  18. package/dist/client/useCreativeContextLab.js +9 -0
  19. package/dist/client/useCreativeContextLab.js.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/labs.d.ts +2 -0
  25. package/dist/labs.d.ts.map +1 -0
  26. package/dist/labs.js +6 -0
  27. package/dist/labs.js.map +1 -0
  28. package/dist/server/context.d.ts +1 -0
  29. package/dist/server/context.d.ts.map +1 -1
  30. package/dist/server/context.js +2 -0
  31. package/dist/server/context.js.map +1 -1
  32. package/dist/server/generation-context.d.ts +1 -1
  33. package/dist/server/generation-context.d.ts.map +1 -1
  34. package/dist/server/generation-context.js +35 -8
  35. package/dist/server/generation-context.js.map +1 -1
  36. package/dist/server/index.d.ts +2 -1
  37. package/dist/server/index.d.ts.map +1 -1
  38. package/dist/server/index.js +5 -2
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/labs.d.ts +5 -0
  41. package/dist/server/labs.d.ts.map +1 -0
  42. package/dist/server/labs.js +33 -0
  43. package/dist/server/labs.js.map +1 -0
  44. package/dist/server/media.d.ts.map +1 -1
  45. package/dist/server/media.js +4 -0
  46. package/dist/server/media.js.map +1 -1
  47. package/dist/server/native-context-clone.d.ts +7 -0
  48. package/dist/server/native-context-clone.d.ts.map +1 -0
  49. package/dist/server/native-context-clone.js +7 -0
  50. package/dist/server/native-context-clone.js.map +1 -0
  51. package/dist/server/prompt-provider.d.ts.map +1 -1
  52. package/dist/server/prompt-provider.js +8 -2
  53. package/dist/server/prompt-provider.js.map +1 -1
  54. package/package.json +5 -5
  55. package/src/client/actions.ts +5 -1
  56. package/src/client/agent-tab.tsx +6 -7
  57. package/src/client/application-state.spec.ts +55 -2
  58. package/src/client/application-state.ts +10 -2
  59. package/src/client/index.ts +4 -0
  60. package/src/client/useCreativeContextLab.ts +11 -0
  61. package/src/index.ts +1 -0
  62. package/src/labs.ts +6 -0
  63. package/src/server/context.ts +3 -0
  64. package/src/server/generation-artifact-record-role.spec.ts +6 -0
  65. package/src/server/generation-context-a2a.spec.ts +37 -0
  66. package/src/server/generation-context-off.spec.ts +71 -0
  67. package/src/server/generation-context.ts +40 -14
  68. package/src/server/index.ts +9 -2
  69. package/src/server/labs.spec.ts +116 -0
  70. package/src/server/labs.ts +53 -0
  71. package/src/server/media.spec.ts +56 -1
  72. package/src/server/media.ts +9 -0
  73. package/src/server/native-context-clone.spec.ts +55 -0
  74. package/src/server/native-context-clone.ts +9 -0
  75. package/src/server/prompt-provider.spec.ts +55 -0
  76. package/src/server/prompt-provider.ts +13 -2
@@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({
15
15
  hasA2A: vi.fn(),
16
16
  callA2A: vi.fn(),
17
17
  getRequestOrgId: vi.fn(),
18
+ getRequestUserEmail: vi.fn(() => "user@example.test"),
18
19
  createArtifactCapability: vi.fn(),
20
+ isCreativeContextLabAvailable: vi.fn(),
19
21
  }));
20
22
 
21
23
  vi.mock("@agent-native/core/application-state", () => ({
@@ -24,6 +26,7 @@ vi.mock("@agent-native/core/application-state", () => ({
24
26
 
25
27
  vi.mock("@agent-native/core/server/request-context", () => ({
26
28
  getRequestOrgId: mocks.getRequestOrgId,
29
+ getRequestUserEmail: mocks.getRequestUserEmail,
27
30
  }));
28
31
 
29
32
  vi.mock("../store/index.js", () => ({
@@ -60,6 +63,14 @@ vi.mock("./isolated-a2a.js", () => ({
60
63
  })),
61
64
  }));
62
65
 
66
+ vi.mock("./labs.js", () => ({
67
+ isCreativeContextLabAvailable: mocks.isCreativeContextLabAvailable,
68
+ }));
69
+
70
+ vi.mock("./context.js", () => ({
71
+ getCreativeContext: () => ({ labKey: "creative-context.library" }),
72
+ }));
73
+
63
74
  vi.mock("./generation-artifact-access.js", () => ({
64
75
  assertGenerationArtifactAccess: vi.fn(),
65
76
  createGenerationArtifactAccessCapability: mocks.createArtifactCapability,
@@ -88,6 +99,7 @@ describe("generation context isolated A2A routing", () => {
88
99
  mocks.createArtifactCapability.mockImplementation(
89
100
  async (_identity, _target, operation) => `cap-${operation}`,
90
101
  );
102
+ mocks.isCreativeContextLabAvailable.mockResolvedValue(true);
91
103
  mocks.callA2A.mockResolvedValue(emptyRemoteContext);
92
104
  mocks.listCreativeContexts.mockResolvedValue({ contexts: [] });
93
105
  mocks.getCreativeContextById.mockResolvedValue(null);
@@ -147,6 +159,31 @@ describe("generation context isolated A2A routing", () => {
147
159
  expect(mocks.getLocal).not.toHaveBeenCalled();
148
160
  });
149
161
 
162
+ it.each([
163
+ { path: "isolated A2A", hasA2A: true },
164
+ { path: "local storage", hasA2A: false },
165
+ ])(
166
+ "does not record through $path when Labs is disabled",
167
+ async ({ hasA2A }) => {
168
+ mocks.isCreativeContextLabAvailable.mockResolvedValue(false);
169
+ mocks.hasA2A.mockReturnValue(hasA2A);
170
+
171
+ await expect(
172
+ recordGenerationCreativeContext({
173
+ appId: "slides",
174
+ artifactType: "deck",
175
+ artifactId: "deck-1",
176
+ contextMode: "auto",
177
+ contextPackId: null,
178
+ reuseLabels: [],
179
+ }),
180
+ ).resolves.toBeNull();
181
+
182
+ expect(mocks.callA2A).not.toHaveBeenCalled();
183
+ expect(mocks.recordLocal).not.toHaveBeenCalled();
184
+ },
185
+ );
186
+
150
187
  it("keeps the in-process path as the zero-configuration default", async () => {
151
188
  mocks.hasA2A.mockReturnValue(false);
152
189
  mocks.performCreativeContextSearch.mockResolvedValue({
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
7
7
  listAccessibleSearchDocuments: vi.fn(),
8
8
  performCreativeContextSearch: vi.fn(),
9
9
  callIsolatedCreativeContextA2A: vi.fn(),
10
+ isCreativeContextLabAvailable: vi.fn(),
11
+ getGenerationCreativeContextLocal: vi.fn(),
10
12
  }));
11
13
 
12
14
  vi.mock("@agent-native/core/application-state", () => ({
@@ -19,6 +21,11 @@ vi.mock("../store/index.js", () => ({
19
21
  listAccessibleSearchDocuments: mocks.listAccessibleSearchDocuments,
20
22
  }));
21
23
 
24
+ vi.mock("../store/generation.js", () => ({
25
+ getGenerationCreativeContext: mocks.getGenerationCreativeContextLocal,
26
+ recordGenerationCreativeContext: vi.fn(),
27
+ }));
28
+
22
29
  vi.mock("./retrieval.js", () => ({
23
30
  performCreativeContextSearch: mocks.performCreativeContextSearch,
24
31
  }));
@@ -29,7 +36,16 @@ vi.mock("./isolated-a2a.js", () => ({
29
36
  isolatedResolvePayload: vi.fn((input) => input),
30
37
  }));
31
38
 
39
+ vi.mock("./labs.js", () => ({
40
+ isCreativeContextLabAvailable: mocks.isCreativeContextLabAvailable,
41
+ }));
42
+
43
+ vi.mock("./context.js", () => ({
44
+ getCreativeContext: () => ({ labKey: "creative-context.library" }),
45
+ }));
46
+
32
47
  import {
48
+ getGenerationCreativeContext,
33
49
  resolveGenerationCreativeContext,
34
50
  validateGenerationCreativeContext,
35
51
  } from "./generation-context.js";
@@ -37,6 +53,7 @@ import {
37
53
  describe("creative context structural opt-out", () => {
38
54
  beforeEach(() => {
39
55
  vi.clearAllMocks();
56
+ mocks.isCreativeContextLabAvailable.mockResolvedValue(true);
40
57
  mocks.readAppState.mockResolvedValue({ contextMode: "off" });
41
58
  });
42
59
 
@@ -52,6 +69,60 @@ describe("creative context structural opt-out", () => {
52
69
  expect(mocks.callIsolatedCreativeContextA2A).not.toHaveBeenCalled();
53
70
  });
54
71
 
72
+ it("does not resolve automatic context while its Lab is disabled", async () => {
73
+ mocks.isCreativeContextLabAvailable.mockResolvedValue(false);
74
+
75
+ await expect(
76
+ resolveGenerationCreativeContext({ role: "design", query: "dashboard" }),
77
+ ).resolves.toMatchObject({ contextMode: "off", results: [] });
78
+ expect(mocks.readAppState).not.toHaveBeenCalled();
79
+ expect(mocks.callIsolatedCreativeContextA2A).not.toHaveBeenCalled();
80
+ expect(mocks.performCreativeContextSearch).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it("does not read inherited generation context while its Lab is disabled", async () => {
84
+ mocks.isCreativeContextLabAvailable.mockResolvedValue(false);
85
+
86
+ await expect(
87
+ getGenerationCreativeContext({
88
+ appId: "slides",
89
+ artifactType: "deck",
90
+ artifactId: "deck-1",
91
+ }),
92
+ ).resolves.toBeNull();
93
+ expect(mocks.readAppState).not.toHaveBeenCalled();
94
+ expect(mocks.callIsolatedCreativeContextA2A).not.toHaveBeenCalled();
95
+ expect(mocks.getGenerationCreativeContextLocal).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("does not treat an unreadable Lab as permission to read inherited context", async () => {
99
+ mocks.isCreativeContextLabAvailable.mockRejectedValue(
100
+ new Error("settings unavailable"),
101
+ );
102
+
103
+ await expect(
104
+ getGenerationCreativeContext({
105
+ appId: "slides",
106
+ artifactType: "deck",
107
+ artifactId: "deck-1",
108
+ }),
109
+ ).rejects.toThrow("settings unavailable");
110
+ expect(mocks.callIsolatedCreativeContextA2A).not.toHaveBeenCalled();
111
+ expect(mocks.getGenerationCreativeContextLocal).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("fails closed when Labs state cannot be read", async () => {
115
+ mocks.isCreativeContextLabAvailable.mockRejectedValue(
116
+ new Error("settings unavailable"),
117
+ );
118
+
119
+ await expect(
120
+ resolveGenerationCreativeContext({ role: "design", query: "dashboard" }),
121
+ ).rejects.toThrow("settings unavailable");
122
+ expect(mocks.callIsolatedCreativeContextA2A).not.toHaveBeenCalled();
123
+ expect(mocks.performCreativeContextSearch).not.toHaveBeenCalled();
124
+ });
125
+
55
126
  it("rejects an explicit pack before validating final-write provenance", async () => {
56
127
  await expect(
57
128
  validateGenerationCreativeContext({
@@ -1,5 +1,8 @@
1
1
  import { readAppState } from "@agent-native/core/application-state";
2
- import { getRequestOrgId } from "@agent-native/core/server/request-context";
2
+ import {
3
+ getRequestOrgId,
4
+ getRequestUserEmail,
5
+ } from "@agent-native/core/server/request-context";
3
6
 
4
7
  import {
5
8
  getGenerationCreativeContext as getGenerationCreativeContextLocal,
@@ -23,6 +26,7 @@ import {
23
26
  sanitizeUntrustedReference,
24
27
  UNTRUSTED_REFERENCE_ROLE,
25
28
  } from "../untrusted-reference.js";
29
+ import { getCreativeContext } from "./context.js";
26
30
  import {
27
31
  assertGenerationArtifactAccess,
28
32
  createGenerationArtifactAccessCapability,
@@ -35,6 +39,7 @@ import {
35
39
  isolatedResolvePayload,
36
40
  type IsolatedRecordPayload,
37
41
  } from "./isolated-a2a.js";
42
+ import { isCreativeContextLabAvailable } from "./labs.js";
38
43
  import { performCreativeContextSearch } from "./retrieval.js";
39
44
 
40
45
  export type CreativeGenerationRole =
@@ -56,6 +61,13 @@ const SPECIALTY_STOP_WORDS = new Set([
56
61
  "with",
57
62
  ]);
58
63
 
64
+ async function creativeContextLabEnabled() {
65
+ return isCreativeContextLabAvailable(
66
+ getRequestUserEmail(),
67
+ getCreativeContext().labKey,
68
+ );
69
+ }
70
+
59
71
  function specialtyTokens(value: string) {
60
72
  return new Set(
61
73
  value
@@ -249,16 +261,23 @@ export async function resolveGenerationCreativeContext(
249
261
  if (input.contextModeOverride === "off") {
250
262
  return resolveGenerationCreativeContextLocal(input);
251
263
  }
264
+ if (!(await creativeContextLabEnabled())) {
265
+ return resolveGenerationCreativeContextLocal({
266
+ ...input,
267
+ contextModeOverride: "off",
268
+ });
269
+ }
252
270
  if (hasIsolatedCreativeContextA2A()) {
253
- const state = (await readAppState("creative-context").catch(
254
- () => null,
255
- )) as {
271
+ const state = (await readAppState("creative-context")) as {
256
272
  contextMode?: "auto" | "off";
257
273
  pinnedPackId?: string | null;
258
274
  selectedContextId?: string | null;
259
275
  } | null;
260
276
  if (state?.contextMode === "off") {
261
- return resolveGenerationCreativeContextLocal(input);
277
+ return resolveGenerationCreativeContextLocal({
278
+ ...input,
279
+ contextModeOverride: "off",
280
+ });
262
281
  }
263
282
  return callIsolatedCreativeContextA2A("resolve", {
264
283
  ...isolatedResolvePayload(input),
@@ -295,7 +314,7 @@ export async function resolveGenerationCreativeContextLocal(
295
314
  results: [],
296
315
  };
297
316
  }
298
- const state = (await readAppState("creative-context").catch(() => null)) as {
317
+ const state = (await readAppState("creative-context")) as {
299
318
  contextMode?: "auto" | "off";
300
319
  pinnedPackId?: string | null;
301
320
  selectedContextId?: string | null;
@@ -449,14 +468,21 @@ export async function validateGenerationCreativeContext(
449
468
  if (input.contextModeOverride === "off") {
450
469
  return validateGenerationCreativeContextLocal(input);
451
470
  }
471
+ if (!(await creativeContextLabEnabled())) {
472
+ return validateGenerationCreativeContextLocal({
473
+ ...input,
474
+ contextModeOverride: "off",
475
+ });
476
+ }
452
477
  if (hasIsolatedCreativeContextA2A()) {
453
- const state = (await readAppState("creative-context").catch(
454
- () => null,
455
- )) as {
478
+ const state = (await readAppState("creative-context")) as {
456
479
  contextMode?: "auto" | "off";
457
480
  } | null;
458
481
  if (state?.contextMode === "off") {
459
- return validateGenerationCreativeContextLocal(input);
482
+ return validateGenerationCreativeContextLocal({
483
+ ...input,
484
+ contextModeOverride: "off",
485
+ });
460
486
  }
461
487
  return callIsolatedCreativeContextA2A("validate", {
462
488
  contextPackId: input.contextPackId,
@@ -500,7 +526,7 @@ export async function validateGenerationCreativeContextLocal(
500
526
  results: [],
501
527
  };
502
528
  }
503
- const state = (await readAppState("creative-context").catch(() => null)) as {
529
+ const state = (await readAppState("creative-context")) as {
504
530
  contextMode?: "auto" | "off";
505
531
  } | null;
506
532
  if (state?.contextMode === "off") {
@@ -557,6 +583,7 @@ export async function recordGenerationCreativeContext(
557
583
  input: IsolatedRecordPayload,
558
584
  options: { db?: any; artifactAccess?: GenerationArtifactAccessTarget } = {},
559
585
  ) {
586
+ if (!(await creativeContextLabEnabled())) return null;
560
587
  const artifactAccessTarget = collaborativeArtifactTarget(
561
588
  input,
562
589
  options.artifactAccess,
@@ -602,14 +629,13 @@ export async function getGenerationCreativeContext(
602
629
  db?: any;
603
630
  } = {},
604
631
  ) {
632
+ if (!(await creativeContextLabEnabled())) return null;
605
633
  const artifactAccessTarget = collaborativeArtifactTarget(
606
634
  input,
607
635
  options.artifactAccess,
608
636
  );
609
637
  if (!options.db && hasIsolatedCreativeContextA2A()) {
610
- const state = (await readAppState("creative-context").catch(
611
- () => null,
612
- )) as {
638
+ const state = (await readAppState("creative-context")) as {
613
639
  contextMode?: "auto" | "off";
614
640
  } | null;
615
641
  if (state?.contextMode !== "off") {
@@ -23,10 +23,16 @@ import {
23
23
  getCreativeContext,
24
24
  type CreativeContextServerContext,
25
25
  } from "./context.js";
26
+ import { gateCreativeContextActions } from "./labs.js";
26
27
  import { createCreativeContextMediaPlugin } from "./media.js";
27
28
  import { registerCreativeContextPromptProvider } from "./prompt-provider.js";
28
29
  import { getCreativeContextResourcePath } from "./resource-paths.js";
29
30
 
31
+ export {
32
+ assertCreativeContextLabEnabled,
33
+ isCreativeContextLabAvailable,
34
+ } from "./labs.js";
35
+
30
36
  export interface CreativeContextSetupOptions extends Partial<
31
37
  Omit<CreativeContextServerContext, "connectorContext">
32
38
  > {
@@ -35,7 +41,7 @@ export interface CreativeContextSetupOptions extends Partial<
35
41
  continuationDispatcher?: CreativeContextImportContinuationDispatcher;
36
42
  }
37
43
 
38
- registerPackageActions(creativeContextActions);
44
+ registerPackageActions(gateCreativeContextActions(creativeContextActions));
39
45
  registerWorkspaceConnectionLifecycleListener(async (event) => {
40
46
  await handleWorkspaceConnectionLifecycle(event);
41
47
  });
@@ -103,6 +109,7 @@ export function setupCreativeContext(
103
109
  configureCreativeContext({
104
110
  getDb: options.getDb,
105
111
  schema: options.schema,
112
+ labKey: options.labKey,
106
113
  vectorAdapter: options.vectorAdapter,
107
114
  connectors: options.connectors,
108
115
  projections: options.projections,
@@ -155,7 +162,7 @@ export {
155
162
  type RenderedDesignExtractionStatus,
156
163
  } from "../connectors/rendered-design.js";
157
164
  export { serializePrivateBlobHandle } from "../connectors/private-artifacts.js";
158
- export { resolveNativeContextCloneReference } from "../store/contexts.js";
165
+ export { resolveNativeContextCloneReference } from "./native-context-clone.js";
159
166
  export {
160
167
  CREATIVE_CONTEXT_BACKGROUND_PROCESSOR_ROUTE,
161
168
  CREATIVE_CONTEXT_IMPORT_PROCESSOR_ROUTE,
@@ -0,0 +1,116 @@
1
+ import type { ActionEntry } from "@agent-native/core/server";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ getUserLabs: vi.fn(),
6
+ getRequestUserEmail: vi.fn(),
7
+ getCreativeContext: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@agent-native/core/labs/server", () => mocks);
11
+ vi.mock("@agent-native/core/server/request-context", () => mocks);
12
+ vi.mock("./context.js", () => mocks);
13
+
14
+ import {
15
+ assertCreativeContextLabEnabled,
16
+ gateCreativeContextActions,
17
+ isCreativeContextLabAvailable,
18
+ } from "./labs.js";
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ mocks.getCreativeContext.mockReturnValue({
23
+ labKey: "content.creative-context",
24
+ });
25
+ });
26
+
27
+ describe("isCreativeContextLabAvailable", () => {
28
+ it("does not expose the library without an authenticated user", async () => {
29
+ await expect(isCreativeContextLabAvailable(undefined)).resolves.toBe(false);
30
+ expect(mocks.getUserLabs).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("uses the shared Creative Context Lab by default", async () => {
34
+ mocks.getUserLabs.mockResolvedValue({ "creative-context.library": true });
35
+
36
+ await expect(
37
+ isCreativeContextLabAvailable("user@example.com"),
38
+ ).resolves.toBe(true);
39
+ expect(mocks.getUserLabs).toHaveBeenCalledWith("user@example.com");
40
+ });
41
+
42
+ it("supports an app's existing Creative Context Lab key", async () => {
43
+ mocks.getUserLabs.mockResolvedValue({ "content.creative-context": true });
44
+
45
+ await expect(
46
+ isCreativeContextLabAvailable(
47
+ "user@example.com",
48
+ "content.creative-context",
49
+ ),
50
+ ).resolves.toBe(true);
51
+ });
52
+
53
+ it("preserves unreadable lab state as an error", async () => {
54
+ mocks.getUserLabs.mockRejectedValue(new Error("settings unavailable"));
55
+
56
+ await expect(
57
+ isCreativeContextLabAvailable("user@example.com"),
58
+ ).rejects.toThrow("settings unavailable");
59
+ });
60
+
61
+ it("requires the configured app Lab before Creative Context operations", async () => {
62
+ mocks.getUserLabs.mockResolvedValue({ "content.creative-context": true });
63
+ await expect(
64
+ assertCreativeContextLabEnabled("user@example.com"),
65
+ ).resolves.toBeUndefined();
66
+ expect(mocks.getUserLabs).toHaveBeenCalledWith("user@example.com");
67
+ expect(mocks.getCreativeContext).toHaveBeenCalled();
68
+
69
+ mocks.getUserLabs.mockResolvedValue({ "content.creative-context": false });
70
+ await expect(
71
+ assertCreativeContextLabEnabled("user@example.com"),
72
+ ).rejects.toThrow("Creative Context is disabled in Labs");
73
+ });
74
+
75
+ it("gates package actions with the configured app Lab", async () => {
76
+ const run = vi.fn().mockResolvedValue({ ok: true });
77
+ const actions = gateCreativeContextActions({
78
+ "manage-creative-context": {
79
+ tool: {} as ActionEntry["tool"],
80
+ run,
81
+ },
82
+ });
83
+ const action = actions["manage-creative-context"];
84
+
85
+ mocks.getUserLabs.mockResolvedValue({ "content.creative-context": true });
86
+ await expect(
87
+ action.run({}, { caller: "tool", userEmail: "user@example.test" }),
88
+ ).resolves.toEqual({ ok: true });
89
+ expect(mocks.getUserLabs).toHaveBeenCalledWith("user@example.test");
90
+ expect(mocks.getCreativeContext).toHaveBeenCalled();
91
+ expect(run).toHaveBeenCalledOnce();
92
+
93
+ mocks.getUserLabs.mockResolvedValue({ "content.creative-context": false });
94
+ await expect(
95
+ action.run({}, { caller: "tool", userEmail: "user@example.test" }),
96
+ ).rejects.toThrow("Creative Context is disabled in Labs");
97
+ expect(run).toHaveBeenCalledOnce();
98
+ });
99
+
100
+ it("keeps approved purge cleanup runnable after disabling the Lab", async () => {
101
+ const run = vi.fn().mockResolvedValue({ ok: true });
102
+ const actions = gateCreativeContextActions({
103
+ "process-context-purge": {
104
+ tool: {} as ActionEntry["tool"],
105
+ run,
106
+ },
107
+ });
108
+ mocks.getUserLabs.mockRejectedValue(new Error("settings unavailable"));
109
+
110
+ await expect(actions["process-context-purge"].run({})).resolves.toEqual({
111
+ ok: true,
112
+ });
113
+ expect(mocks.getUserLabs).not.toHaveBeenCalled();
114
+ expect(run).toHaveBeenCalledOnce();
115
+ });
116
+ });
@@ -0,0 +1,53 @@
1
+ import { getUserLabs } from "@agent-native/core/labs/server";
2
+ import type { ActionEntry } from "@agent-native/core/server";
3
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
4
+
5
+ import { CREATIVE_CONTEXT_LIBRARY_LAB } from "../labs.js";
6
+ import { getCreativeContext } from "./context.js";
7
+
8
+ export async function isCreativeContextLabAvailable(
9
+ userEmail: string | undefined,
10
+ labKey = CREATIVE_CONTEXT_LIBRARY_LAB.key,
11
+ ): Promise<boolean> {
12
+ if (!userEmail) return false;
13
+ const labs = await getUserLabs(userEmail);
14
+ return labs[labKey] === true;
15
+ }
16
+
17
+ export async function assertCreativeContextLabEnabled(
18
+ userEmail = getRequestUserEmail(),
19
+ ): Promise<void> {
20
+ if (
21
+ !(await isCreativeContextLabAvailable(
22
+ userEmail,
23
+ getCreativeContext().labKey,
24
+ ))
25
+ ) {
26
+ throw new Error("Creative Context is disabled in Labs");
27
+ }
28
+ }
29
+
30
+ function gateCreativeContextAction(action: ActionEntry): ActionEntry {
31
+ return {
32
+ ...action,
33
+ async run(args, context) {
34
+ await assertCreativeContextLabEnabled(
35
+ context?.userEmail ?? getRequestUserEmail(),
36
+ );
37
+ return action.run(args, context);
38
+ },
39
+ };
40
+ }
41
+
42
+ export function gateCreativeContextActions(
43
+ actions: Record<string, ActionEntry>,
44
+ ): Record<string, ActionEntry> {
45
+ return Object.fromEntries(
46
+ Object.entries(actions).map(([name, action]) => [
47
+ name,
48
+ name === "process-context-purge"
49
+ ? action
50
+ : gateCreativeContextAction(action),
51
+ ]),
52
+ );
53
+ }
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
7
7
  readPrivateArtifact: vi.fn(async () => new Uint8Array([1, 2, 3])),
8
8
  getCreativeContextItem: vi.fn(),
9
9
  readPendingCreativeContextMedia: vi.fn(),
10
+ isCreativeContextLabAvailable: vi.fn(),
11
+ labKey: "creative-context.library",
10
12
  }));
11
13
 
12
14
  vi.mock("@agent-native/core/server", () => ({
@@ -36,7 +38,14 @@ vi.mock("../store/index.js", () => ({
36
38
  }));
37
39
 
38
40
  vi.mock("./context.js", () => ({
39
- getCreativeContext: vi.fn(() => ({ connectorContext: {} })),
41
+ getCreativeContext: vi.fn(() => ({
42
+ connectorContext: {},
43
+ labKey: mocks.labKey,
44
+ })),
45
+ }));
46
+
47
+ vi.mock("./labs.js", () => ({
48
+ isCreativeContextLabAvailable: mocks.isCreativeContextLabAvailable,
40
49
  }));
41
50
 
42
51
  const { createCreativeContextMediaPlugin } = await import("./media.js");
@@ -56,6 +65,9 @@ describe("creative context media route", () => {
56
65
  mocks.handler = null;
57
66
  mocks.getSession.mockReset();
58
67
  mocks.runWithRequestContext.mockClear();
68
+ mocks.readPrivateArtifact.mockClear();
69
+ mocks.isCreativeContextLabAvailable.mockReset().mockResolvedValue(true);
70
+ mocks.labKey = "creative-context.library";
59
71
  mocks.getCreativeContextItem.mockReset().mockResolvedValue({
60
72
  item: {
61
73
  id: "item-1",
@@ -74,6 +86,45 @@ describe("creative context media route", () => {
74
86
  expect(response.status).toBe(401);
75
87
  expect(mocks.runWithRequestContext).not.toHaveBeenCalled();
76
88
  expect(mocks.getCreativeContextItem).not.toHaveBeenCalled();
89
+ expect(mocks.isCreativeContextLabAvailable).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("does not read private media when its app-specific Lab is disabled", async () => {
93
+ mocks.getSession.mockResolvedValue({
94
+ email: "Alice@Example.test ",
95
+ orgId: "org-1",
96
+ });
97
+ mocks.labKey = "content.creative-context";
98
+ mocks.isCreativeContextLabAvailable.mockResolvedValue(false);
99
+
100
+ const response = await mocks.handler!(event());
101
+
102
+ expect(response.status).toBe(404);
103
+ expect(mocks.isCreativeContextLabAvailable).toHaveBeenCalledWith(
104
+ "alice@example.test",
105
+ "content.creative-context",
106
+ );
107
+ expect(mocks.runWithRequestContext).not.toHaveBeenCalled();
108
+ expect(mocks.getCreativeContextItem).not.toHaveBeenCalled();
109
+ expect(mocks.readPendingCreativeContextMedia).not.toHaveBeenCalled();
110
+ expect(mocks.readPrivateArtifact).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it("fails closed when the app Labs setting cannot be read", async () => {
114
+ mocks.getSession.mockResolvedValue({
115
+ email: "alice@example.test",
116
+ orgId: "org-1",
117
+ });
118
+ mocks.isCreativeContextLabAvailable.mockRejectedValue(
119
+ new Error("Labs settings unavailable"),
120
+ );
121
+
122
+ await expect(mocks.handler!(event())).rejects.toThrow(
123
+ "Labs settings unavailable",
124
+ );
125
+ expect(mocks.runWithRequestContext).not.toHaveBeenCalled();
126
+ expect(mocks.getCreativeContextItem).not.toHaveBeenCalled();
127
+ expect(mocks.readPrivateArtifact).not.toHaveBeenCalled();
77
128
  });
78
129
 
79
130
  it("runs access-scoped reads under the authenticated request context", async () => {
@@ -91,6 +142,10 @@ describe("creative context media route", () => {
91
142
  "item-1",
92
143
  "version-1",
93
144
  );
145
+ expect(mocks.isCreativeContextLabAvailable).toHaveBeenCalledWith(
146
+ "alice@example.test",
147
+ "creative-context.library",
148
+ );
94
149
  });
95
150
 
96
151
  it("uses the narrow pending-submission path only after generic item access fails", async () => {
@@ -16,6 +16,7 @@ import {
16
16
  readPendingCreativeContextMedia,
17
17
  } from "../store/index.js";
18
18
  import { getCreativeContext } from "./context.js";
19
+ import { isCreativeContextLabAvailable } from "./labs.js";
19
20
 
20
21
  const SAFE_MIME_TYPES = new Set([
21
22
  "image/png",
@@ -38,6 +39,14 @@ export function createCreativeContextMediaPlugin(): NitroPluginDef {
38
39
  if (!userEmail) {
39
40
  return new Response("Authentication required", { status: 401 });
40
41
  }
42
+ if (
43
+ !(await isCreativeContextLabAvailable(
44
+ userEmail,
45
+ getCreativeContext().labKey,
46
+ ))
47
+ ) {
48
+ return new Response("Not found", { status: 404 });
49
+ }
41
50
  const orgId = session?.orgId;
42
51
  const url = new URL(
43
52
  event.req.url,
@@ -0,0 +1,55 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ assertCreativeContextLabEnabled: vi.fn(),
5
+ resolveFromStore: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("./labs.js", () => ({
9
+ assertCreativeContextLabEnabled: mocks.assertCreativeContextLabEnabled,
10
+ }));
11
+
12
+ vi.mock("../store/contexts.js", () => ({
13
+ resolveNativeContextCloneReference: mocks.resolveFromStore,
14
+ }));
15
+
16
+ import { resolveNativeContextCloneReference } from "./native-context-clone.js";
17
+
18
+ describe("resolveNativeContextCloneReference", () => {
19
+ beforeEach(() => vi.clearAllMocks());
20
+
21
+ it("checks Labs before reading a clone reference", async () => {
22
+ const input = {
23
+ appId: "design",
24
+ resourceType: "design",
25
+ resourceId: "design-1",
26
+ contextId: "context-1",
27
+ artifactKey: "design:design:design-1",
28
+ };
29
+ const reference = { publishedItemVersionId: "version-1" };
30
+ mocks.resolveFromStore.mockResolvedValue(reference);
31
+
32
+ await expect(resolveNativeContextCloneReference(input)).resolves.toBe(
33
+ reference,
34
+ );
35
+ expect(mocks.assertCreativeContextLabEnabled).toHaveBeenCalledOnce();
36
+ expect(mocks.resolveFromStore).toHaveBeenCalledWith(input);
37
+ });
38
+
39
+ it("does not read the reference when Labs is disabled or unreadable", async () => {
40
+ mocks.assertCreativeContextLabEnabled.mockRejectedValue(
41
+ new Error("Creative Context is disabled in Labs"),
42
+ );
43
+
44
+ await expect(
45
+ resolveNativeContextCloneReference({
46
+ appId: "design",
47
+ resourceType: "design",
48
+ resourceId: "design-1",
49
+ contextId: "context-1",
50
+ artifactKey: "design:design:design-1",
51
+ }),
52
+ ).rejects.toThrow("Creative Context is disabled in Labs");
53
+ expect(mocks.resolveFromStore).not.toHaveBeenCalled();
54
+ });
55
+ });