@agent-native/dispatch 0.40.30-nightly-20261021093329 → 0.40.31
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.
- package/CHANGELOG.md +15 -60
- package/dist/actions/connect-external-agent.d.ts.map +1 -1
- package/dist/actions/connect-external-agent.js +12 -13
- package/dist/actions/connect-external-agent.js.map +1 -1
- package/dist/actions/create-workspace-resource.d.ts +13 -13
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/delete-workspace-resource.d.ts +13 -13
- package/dist/actions/import-agent.d.ts +13 -13
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +8 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/inspect-connect-agent-card.d.ts +5 -0
- package/dist/actions/inspect-connect-agent-card.d.ts.map +1 -0
- package/dist/actions/inspect-connect-agent-card.js +15 -0
- package/dist/actions/inspect-connect-agent-card.js.map +1 -0
- package/dist/actions/install-curated-community-app.d.ts +36 -0
- package/dist/actions/install-curated-community-app.d.ts.map +1 -0
- package/dist/actions/install-curated-community-app.js +26 -0
- package/dist/actions/install-curated-community-app.js.map +1 -0
- package/dist/actions/list-connect-marketplace-apps.d.ts +3 -0
- package/dist/actions/list-connect-marketplace-apps.d.ts.map +1 -0
- package/dist/actions/list-connect-marketplace-apps.js +15 -0
- package/dist/actions/list-connect-marketplace-apps.js.map +1 -0
- package/dist/actions/list-connected-agents.d.ts +2 -0
- package/dist/actions/list-connected-agents.js +7 -3
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list-curated-community-apps.d.ts +3 -0
- package/dist/actions/list-curated-community-apps.d.ts.map +1 -0
- package/dist/actions/list-curated-community-apps.js +10 -0
- package/dist/actions/list-curated-community-apps.js.map +1 -0
- package/dist/actions/list-workspace-connections.d.ts.map +1 -1
- package/dist/actions/list-workspace-connections.js +13 -18
- package/dist/actions/list-workspace-connections.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +14 -14
- package/dist/actions/remix-workspace-template.d.ts +6 -0
- package/dist/actions/update-workspace-app-metadata.d.ts +1 -0
- package/dist/actions/update-workspace-app-metadata.js +26 -5
- package/dist/actions/update-workspace-app-metadata.js.map +1 -1
- package/dist/components/action-query-error.d.ts +2 -1
- package/dist/components/action-query-error.d.ts.map +1 -1
- package/dist/components/action-query-error.js +25 -2
- package/dist/components/action-query-error.js.map +1 -1
- package/dist/components/chat-home-apps.d.ts.map +1 -1
- package/dist/components/chat-home-apps.js +2 -2
- package/dist/components/chat-home-apps.js.map +1 -1
- package/dist/components/community-apps-section.d.ts +2 -0
- package/dist/components/community-apps-section.d.ts.map +1 -0
- package/dist/components/community-apps-section.js +90 -0
- package/dist/components/community-apps-section.js.map +1 -0
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +1 -1
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +7 -0
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +63 -9
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/lib/connect-apps.d.ts +1 -2
- package/dist/lib/connect-apps.d.ts.map +1 -1
- package/dist/lib/connect-apps.js +41 -65
- package/dist/lib/connect-apps.js.map +1 -1
- package/dist/lib/workspace-apps.d.ts +1 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +1 -1
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +3 -2
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/connect.d.ts.map +1 -1
- package/dist/routes/pages/connect.js +5 -7
- package/dist/routes/pages/connect.js.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts +11 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +379 -92
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/community-apps.d.ts +45 -0
- package/dist/server/lib/community-apps.d.ts.map +1 -0
- package/dist/server/lib/community-apps.js +267 -0
- package/dist/server/lib/community-apps.js.map +1 -0
- package/dist/server/lib/community-source.d.ts +19 -0
- package/dist/server/lib/community-source.d.ts.map +1 -0
- package/dist/server/lib/community-source.js +118 -0
- package/dist/server/lib/community-source.js.map +1 -0
- package/dist/server/lib/connect-apps.d.ts +4 -0
- package/dist/server/lib/connect-apps.d.ts.map +1 -0
- package/dist/server/lib/connect-apps.js +99 -0
- package/dist/server/lib/connect-apps.js.map +1 -0
- package/dist/server/lib/org-apps-directory-cache.d.ts +20 -0
- package/dist/server/lib/org-apps-directory-cache.d.ts.map +1 -0
- package/dist/server/lib/org-apps-directory-cache.js +44 -0
- package/dist/server/lib/org-apps-directory-cache.js.map +1 -0
- package/dist/server/plugins/integrations.js +3 -3
- package/dist/server/plugins/integrations.js.map +1 -1
- package/dist/shared/labs.d.ts +2 -0
- package/dist/shared/labs.d.ts.map +1 -0
- package/dist/shared/labs.js +8 -0
- package/dist/shared/labs.js.map +1 -0
- package/package.json +5 -4
- package/src/actions/connect-external-agent.spec.ts +101 -3
- package/src/actions/connect-external-agent.ts +12 -11
- package/src/actions/index.spec.ts +13 -0
- package/src/actions/index.ts +8 -0
- package/src/actions/inspect-connect-agent-card.ts +17 -0
- package/src/actions/install-curated-community-app.ts +32 -0
- package/src/actions/list-connect-marketplace-apps.ts +17 -0
- package/src/actions/list-connected-agents.spec.ts +17 -1
- package/src/actions/list-connected-agents.ts +7 -2
- package/src/actions/list-curated-community-apps.ts +12 -0
- package/src/actions/list-workspace-connections.spec.ts +14 -3
- package/src/actions/list-workspace-connections.ts +13 -17
- package/src/actions/update-workspace-app-metadata.spec.ts +25 -0
- package/src/actions/update-workspace-app-metadata.ts +48 -24
- package/src/components/action-query-error.spec.tsx +20 -0
- package/src/components/action-query-error.tsx +40 -0
- package/src/components/chat-home-apps.spec.tsx +32 -1
- package/src/components/chat-home-apps.tsx +6 -1
- package/src/components/community-apps-section.tsx +224 -0
- package/src/components/dispatch-control-plane.tsx +1 -0
- package/src/components/layout/Layout.tsx +9 -0
- package/src/components/workspace-app-card.spec.tsx +226 -1
- package/src/components/workspace-app-card.tsx +115 -12
- package/src/lib/connect-apps.spec.ts +118 -66
- package/src/lib/connect-apps.ts +47 -82
- package/src/lib/workspace-apps.ts +1 -0
- package/src/routes/pages/$appId.tsx +1 -0
- package/src/routes/pages/apps.spec.tsx +227 -3
- package/src/routes/pages/apps.tsx +5 -0
- package/src/routes/pages/connect.spec.tsx +53 -6
- package/src/routes/pages/connect.tsx +14 -11
- package/src/server/index.ts +9 -0
- package/src/server/lib/app-creation-store.spec.ts +820 -16
- package/src/server/lib/app-creation-store.ts +504 -101
- package/src/server/lib/community-apps.spec.ts +367 -0
- package/src/server/lib/community-apps.ts +372 -0
- package/src/server/lib/community-source.spec.ts +151 -0
- package/src/server/lib/community-source.ts +186 -0
- package/src/server/lib/connect-apps.ts +149 -0
- package/src/server/lib/org-apps-directory-cache.spec.ts +71 -0
- package/src/server/lib/org-apps-directory-cache.ts +66 -0
- package/src/server/plugins/integrations.spec.ts +60 -0
- package/src/server/plugins/integrations.ts +3 -3
- package/src/shared/labs.ts +9 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -10,23 +11,44 @@ import {
|
|
|
10
11
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
12
|
|
|
12
13
|
import {
|
|
14
|
+
archiveWorkspaceApp,
|
|
13
15
|
generateWorkspaceAppDescription,
|
|
14
16
|
getAppCreationSettings,
|
|
15
17
|
listAvailableWorkspaceTemplates,
|
|
18
|
+
listOrganizationVisibleWorkspaceApps,
|
|
16
19
|
listWorkspaceApps,
|
|
17
20
|
scaffoldWorkspaceAppFromTemplate,
|
|
18
21
|
setAppCreationSettings,
|
|
19
22
|
startWorkspaceAppCreation,
|
|
20
23
|
updateWorkspaceAppMetadata,
|
|
21
24
|
} from "./app-creation-store.js";
|
|
25
|
+
import {
|
|
26
|
+
orgAppsDirectoryAccessCache,
|
|
27
|
+
orgAppsDirectoryCache,
|
|
28
|
+
} from "./org-apps-directory-cache.js";
|
|
22
29
|
|
|
23
30
|
const originalFetch = globalThis.fetch;
|
|
24
31
|
const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
|
|
25
32
|
|
|
33
|
+
function verifyJwtWithSecret(
|
|
34
|
+
token: string,
|
|
35
|
+
secret: string,
|
|
36
|
+
): Record<string, unknown> {
|
|
37
|
+
const [header, payload, signature] = token.split(".");
|
|
38
|
+
const expected = createHmac("sha256", secret)
|
|
39
|
+
.update(`${header}.${payload}`)
|
|
40
|
+
.digest("base64url");
|
|
41
|
+
if (signature !== expected || !payload) {
|
|
42
|
+
throw new Error("JWT signature did not match the provided secret.");
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
const mocks = vi.hoisted(() => {
|
|
27
48
|
const settings = new Map<string, unknown>();
|
|
28
49
|
const state = {
|
|
29
50
|
orgRole: "admin" as string | null,
|
|
51
|
+
workspaceApps: [] as Record<string, unknown>[],
|
|
30
52
|
};
|
|
31
53
|
const executedSql: string[] = [];
|
|
32
54
|
return {
|
|
@@ -48,6 +70,8 @@ const mocks = vi.hoisted(() => {
|
|
|
48
70
|
getOrgDomain: vi.fn(async (orgId: string) =>
|
|
49
71
|
orgId ? "example.test" : null,
|
|
50
72
|
),
|
|
73
|
+
getOrgA2ASecret: vi.fn(async () => null as string | null),
|
|
74
|
+
signA2AToken: vi.fn(),
|
|
51
75
|
isWorkspaceAppAccessAllowed: vi.fn(
|
|
52
76
|
async (): Promise<boolean | "unavailable"> => true,
|
|
53
77
|
),
|
|
@@ -62,6 +86,20 @@ const mocks = vi.hoisted(() => {
|
|
|
62
86
|
? statement
|
|
63
87
|
: String((statement as { sql?: unknown })?.sql ?? "");
|
|
64
88
|
executedSql.push(sql);
|
|
89
|
+
if (sql.includes("SELECT id, org_id, visibility, org_enabled")) {
|
|
90
|
+
const args = (statement as { args?: unknown[] }).args ?? [];
|
|
91
|
+
return {
|
|
92
|
+
rows: state.workspaceApps.filter((app) => args.includes(app.id)),
|
|
93
|
+
rowsAffected: 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
|
|
97
|
+
const args = (statement as { args?: unknown[] }).args ?? [];
|
|
98
|
+
return {
|
|
99
|
+
rows: state.workspaceApps.filter((app) => args.includes(app.id)),
|
|
100
|
+
rowsAffected: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
65
103
|
if (sql.includes("SELECT id FROM workspace_apps")) {
|
|
66
104
|
return { rows: [], rowsAffected: 0 };
|
|
67
105
|
}
|
|
@@ -71,6 +109,22 @@ const mocks = vi.hoisted(() => {
|
|
|
71
109
|
};
|
|
72
110
|
}),
|
|
73
111
|
})),
|
|
112
|
+
getDb: vi.fn(() => ({
|
|
113
|
+
select: vi.fn(() => ({
|
|
114
|
+
from: vi.fn(() => ({
|
|
115
|
+
where: vi.fn(async () =>
|
|
116
|
+
state.workspaceApps
|
|
117
|
+
.filter(
|
|
118
|
+
(app) =>
|
|
119
|
+
app.org_id === "org-123" &&
|
|
120
|
+
app.visibility === "org" &&
|
|
121
|
+
app.org_enabled === 1,
|
|
122
|
+
)
|
|
123
|
+
.map((app) => ({ id: String(app.id) })),
|
|
124
|
+
),
|
|
125
|
+
})),
|
|
126
|
+
})),
|
|
127
|
+
})),
|
|
74
128
|
resolveBuilderCredentialsDetailed: vi.fn(async () => ({
|
|
75
129
|
privateKey: null as string | null,
|
|
76
130
|
publicKey: null as string | null,
|
|
@@ -94,6 +148,23 @@ const mocks = vi.hoisted(() => {
|
|
|
94
148
|
};
|
|
95
149
|
});
|
|
96
150
|
|
|
151
|
+
vi.mock("@agent-native/core/a2a", async (importOriginal) => {
|
|
152
|
+
const actual =
|
|
153
|
+
await importOriginal<typeof import("@agent-native/core/a2a")>();
|
|
154
|
+
mocks.signA2AToken.mockImplementation((...args: any[]) =>
|
|
155
|
+
actual.signA2AToken(...args),
|
|
156
|
+
);
|
|
157
|
+
return {
|
|
158
|
+
...actual,
|
|
159
|
+
signA2AToken: (...args: any[]) => mocks.signA2AToken(...args),
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
vi.mock("../../db/index.js", async (importOriginal) => {
|
|
164
|
+
const actual = await importOriginal<typeof import("../../db/index.js")>();
|
|
165
|
+
return { ...actual, getDb: mocks.getDb };
|
|
166
|
+
});
|
|
167
|
+
|
|
97
168
|
vi.mock("@agent-native/core/secrets", async (importOriginal) => {
|
|
98
169
|
const actual =
|
|
99
170
|
await importOriginal<typeof import("@agent-native/core/secrets")>();
|
|
@@ -124,6 +195,7 @@ vi.mock("@agent-native/core/org", async (importOriginal) => {
|
|
|
124
195
|
await importOriginal<typeof import("@agent-native/core/org")>();
|
|
125
196
|
return {
|
|
126
197
|
...actual,
|
|
198
|
+
getOrgA2ASecret: (...args: any[]) => mocks.getOrgA2ASecret(...args),
|
|
127
199
|
getOrgDomain: (...args: any[]) => mocks.getOrgDomain(...args),
|
|
128
200
|
isWorkspaceAppAccessAllowed: (...args: any[]) =>
|
|
129
201
|
mocks.isWorkspaceAppAccessAllowed(...args),
|
|
@@ -176,6 +248,8 @@ afterEach(() => {
|
|
|
176
248
|
mocks.settings.clear();
|
|
177
249
|
mocks.getOrgSetting.mockReset();
|
|
178
250
|
mocks.getOrgSetting.mockResolvedValue(null);
|
|
251
|
+
mocks.getOrgA2ASecret.mockReset();
|
|
252
|
+
mocks.getOrgA2ASecret.mockResolvedValue(null);
|
|
179
253
|
mocks.isWorkspaceAppAccessAllowed.mockReset();
|
|
180
254
|
mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true);
|
|
181
255
|
mocks.mutateSetting.mockReset();
|
|
@@ -187,6 +261,9 @@ afterEach(() => {
|
|
|
187
261
|
},
|
|
188
262
|
);
|
|
189
263
|
mocks.state.orgRole = "admin";
|
|
264
|
+
mocks.state.workspaceApps = [];
|
|
265
|
+
orgAppsDirectoryCache.clear();
|
|
266
|
+
orgAppsDirectoryAccessCache.clear();
|
|
190
267
|
mocks.getDbExec.mockReset();
|
|
191
268
|
mocks.getDbExec.mockImplementation(() => ({
|
|
192
269
|
execute: vi.fn(async (statement: unknown) => {
|
|
@@ -195,6 +272,24 @@ afterEach(() => {
|
|
|
195
272
|
? statement
|
|
196
273
|
: String((statement as { sql?: unknown })?.sql ?? "");
|
|
197
274
|
mocks.executedSql.push(sql);
|
|
275
|
+
if (sql.includes("SELECT id, org_id, visibility, org_enabled")) {
|
|
276
|
+
const args = (statement as { args?: unknown[] }).args ?? [];
|
|
277
|
+
return {
|
|
278
|
+
rows: mocks.state.workspaceApps.filter((app) =>
|
|
279
|
+
args.includes(app.id),
|
|
280
|
+
),
|
|
281
|
+
rowsAffected: 0,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
|
|
285
|
+
const args = (statement as { args?: unknown[] }).args ?? [];
|
|
286
|
+
return {
|
|
287
|
+
rows: mocks.state.workspaceApps.filter((app) =>
|
|
288
|
+
args.includes(app.id),
|
|
289
|
+
),
|
|
290
|
+
rowsAffected: 0,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
198
293
|
if (sql.includes("SELECT id FROM workspace_apps")) {
|
|
199
294
|
return { rows: [], rowsAffected: 0 };
|
|
200
295
|
}
|
|
@@ -373,6 +468,71 @@ describe("listWorkspaceApps", () => {
|
|
|
373
468
|
]);
|
|
374
469
|
});
|
|
375
470
|
|
|
471
|
+
it("lists only ready org-visible apps for an organization principal", async () => {
|
|
472
|
+
stubNoPendingContext();
|
|
473
|
+
stubManifest([
|
|
474
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
475
|
+
{ id: "qr", name: "QR", path: "/qr" },
|
|
476
|
+
{ id: "private-app", name: "Private", path: "/private-app" },
|
|
477
|
+
{ id: "disabled-app", name: "Disabled", path: "/disabled-app" },
|
|
478
|
+
{ id: "foreign-app", name: "Foreign", path: "/foreign-app" },
|
|
479
|
+
]);
|
|
480
|
+
mocks.settings.set("dispatch-app-creation-settings:org:org-123", {
|
|
481
|
+
pendingApps: [pendingApp("pending-app")],
|
|
482
|
+
});
|
|
483
|
+
mocks.state.workspaceApps = [
|
|
484
|
+
{
|
|
485
|
+
id: "dispatch",
|
|
486
|
+
owner_email: "owner@example.test",
|
|
487
|
+
org_id: "org-123",
|
|
488
|
+
visibility: "org",
|
|
489
|
+
org_enabled: 1,
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
id: "qr",
|
|
493
|
+
owner_email: "owner@example.test",
|
|
494
|
+
org_id: "org-123",
|
|
495
|
+
visibility: "org",
|
|
496
|
+
org_enabled: 1,
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
id: "private-app",
|
|
500
|
+
owner_email: "dev@example.test",
|
|
501
|
+
org_id: "org-123",
|
|
502
|
+
visibility: "private",
|
|
503
|
+
org_enabled: 1,
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: "disabled-app",
|
|
507
|
+
owner_email: "owner@example.test",
|
|
508
|
+
org_id: "org-123",
|
|
509
|
+
visibility: "org",
|
|
510
|
+
org_enabled: 0,
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: "foreign-app",
|
|
514
|
+
owner_email: "owner@example.test",
|
|
515
|
+
org_id: "another-org",
|
|
516
|
+
visibility: "org",
|
|
517
|
+
org_enabled: 1,
|
|
518
|
+
},
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
const apps = await runWithRequestContext({ orgId: "org-123" }, () =>
|
|
522
|
+
listOrganizationVisibleWorkspaceApps(),
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "qr"]);
|
|
526
|
+
expect(mocks.executedSql.some((sql) => sql.includes("INSERT INTO"))).toBe(
|
|
527
|
+
false,
|
|
528
|
+
);
|
|
529
|
+
expect(
|
|
530
|
+
mocks.executedSql.some((sql) =>
|
|
531
|
+
sql.includes("INSERT INTO workspace_apps"),
|
|
532
|
+
),
|
|
533
|
+
).toBe(false);
|
|
534
|
+
});
|
|
535
|
+
|
|
376
536
|
it.each([401, 403])(
|
|
377
537
|
"keeps a local gateway denial on the unverified fallback path (%i)",
|
|
378
538
|
async (status) => {
|
|
@@ -472,6 +632,130 @@ describe("listWorkspaceApps", () => {
|
|
|
472
632
|
expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
|
|
473
633
|
});
|
|
474
634
|
|
|
635
|
+
it("signs the hosted registry request with the caller's organization secret", async () => {
|
|
636
|
+
const fetchMock = vi.fn(
|
|
637
|
+
async () =>
|
|
638
|
+
new Response(JSON.stringify([]), {
|
|
639
|
+
headers: { "content-type": "application/json" },
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
643
|
+
vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
|
|
644
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
645
|
+
mocks.getOrgA2ASecret.mockResolvedValueOnce("workspace-org-secret");
|
|
646
|
+
|
|
647
|
+
await runWithRequestContext(
|
|
648
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
649
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const authorization = fetchMock.mock.calls[0]?.[1]?.headers
|
|
653
|
+
?.Authorization as string;
|
|
654
|
+
const token = authorization.slice("Bearer ".length);
|
|
655
|
+
const payload = verifyJwtWithSecret(token, "workspace-org-secret");
|
|
656
|
+
expect(payload).toMatchObject({
|
|
657
|
+
sub: "dev@example.test",
|
|
658
|
+
org_domain: "example.test",
|
|
659
|
+
aud: "https://agent-workspace.builder.io",
|
|
660
|
+
});
|
|
661
|
+
expect(() =>
|
|
662
|
+
verifyJwtWithSecret(token, "dispatch-deployment-secret"),
|
|
663
|
+
).toThrow();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("reports missing hosted gateway signing credentials instead of sending anonymously", async () => {
|
|
667
|
+
const fetchMock = vi.fn();
|
|
668
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
669
|
+
vi.stubEnv("A2A_SECRET", "");
|
|
670
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
671
|
+
mocks.getOrgA2ASecret.mockResolvedValueOnce(null);
|
|
672
|
+
stubManifest([
|
|
673
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
674
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
675
|
+
]);
|
|
676
|
+
|
|
677
|
+
await expect(
|
|
678
|
+
runWithRequestContext(
|
|
679
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
680
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
681
|
+
),
|
|
682
|
+
).rejects.toMatchObject({
|
|
683
|
+
statusCode: 503,
|
|
684
|
+
errorCode: "workspace_gateway_a2a_secret_missing",
|
|
685
|
+
});
|
|
686
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("reports organization A2A secret lookup failures before using manifest apps", async () => {
|
|
690
|
+
const fetchMock = vi.fn();
|
|
691
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
692
|
+
vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
|
|
693
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
694
|
+
stubManifest([
|
|
695
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
696
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
697
|
+
]);
|
|
698
|
+
mocks.getOrgA2ASecret.mockRejectedValueOnce(
|
|
699
|
+
new Error("database unavailable"),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
await expect(
|
|
703
|
+
runWithRequestContext(
|
|
704
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
705
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
706
|
+
),
|
|
707
|
+
).rejects.toMatchObject({
|
|
708
|
+
statusCode: 503,
|
|
709
|
+
errorCode: "workspace_gateway_org_a2a_secret_unavailable",
|
|
710
|
+
});
|
|
711
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("reports token signing failures before using manifest apps", async () => {
|
|
715
|
+
const fetchMock = vi.fn();
|
|
716
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
717
|
+
vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
|
|
718
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
719
|
+
stubManifest([
|
|
720
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
721
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
722
|
+
]);
|
|
723
|
+
mocks.signA2AToken.mockRejectedValueOnce(new Error("signing unavailable"));
|
|
724
|
+
|
|
725
|
+
await expect(
|
|
726
|
+
runWithRequestContext(
|
|
727
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
728
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
729
|
+
),
|
|
730
|
+
).rejects.toMatchObject({
|
|
731
|
+
statusCode: 503,
|
|
732
|
+
errorCode: "workspace_gateway_token_signing_failed",
|
|
733
|
+
});
|
|
734
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("does not hide an organization lookup failure behind manifest apps", async () => {
|
|
738
|
+
const fetchMock = vi.fn();
|
|
739
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
740
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
741
|
+
stubManifest([
|
|
742
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
743
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
744
|
+
]);
|
|
745
|
+
mocks.getOrgDomain.mockRejectedValueOnce(new Error("database unavailable"));
|
|
746
|
+
|
|
747
|
+
await expect(
|
|
748
|
+
runWithRequestContext(
|
|
749
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
750
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
751
|
+
),
|
|
752
|
+
).rejects.toMatchObject({
|
|
753
|
+
statusCode: 503,
|
|
754
|
+
errorCode: "workspace_gateway_org_domain_unavailable",
|
|
755
|
+
});
|
|
756
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
757
|
+
});
|
|
758
|
+
|
|
475
759
|
it("binds hosted registry tokens to the mounted Dispatch path", async () => {
|
|
476
760
|
const fetchMock = vi.fn(async () => {
|
|
477
761
|
return new Response(
|
|
@@ -665,6 +949,122 @@ describe("listWorkspaceApps", () => {
|
|
|
665
949
|
expect(apps.map((app) => app.id)).toEqual(["dispatch", "clips"]);
|
|
666
950
|
});
|
|
667
951
|
|
|
952
|
+
it("treats an empty hosted registry response as a successful empty list", async () => {
|
|
953
|
+
const fetchMock = vi.fn(
|
|
954
|
+
async () =>
|
|
955
|
+
new Response(JSON.stringify([]), {
|
|
956
|
+
headers: { "content-type": "application/json" },
|
|
957
|
+
}),
|
|
958
|
+
);
|
|
959
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
960
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
961
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
962
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
963
|
+
|
|
964
|
+
const apps = await runWithRequestContext(
|
|
965
|
+
{ userEmail: "dev@example.test" },
|
|
966
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
expect(apps).toEqual([]);
|
|
970
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it.each([206, 500, 502, 503])(
|
|
974
|
+
"surfaces hosted registry HTTP %i failures instead of using manifest apps",
|
|
975
|
+
async (status) => {
|
|
976
|
+
const fetchMock = vi.fn(
|
|
977
|
+
async () => new Response("gateway unavailable", { status }),
|
|
978
|
+
);
|
|
979
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
980
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
981
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
982
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
983
|
+
|
|
984
|
+
await expect(
|
|
985
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
986
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
987
|
+
),
|
|
988
|
+
).rejects.toMatchObject({
|
|
989
|
+
statusCode: 503,
|
|
990
|
+
errorCode: `workspace_gateway_http_${status}`,
|
|
991
|
+
});
|
|
992
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
993
|
+
},
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
it("surfaces hosted registry transport failures instead of using manifest apps", async () => {
|
|
997
|
+
const fetchMock = vi.fn(async () => {
|
|
998
|
+
throw new TypeError("Network connection failed");
|
|
999
|
+
});
|
|
1000
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1001
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
1002
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
1003
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
1004
|
+
|
|
1005
|
+
await expect(
|
|
1006
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
1007
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
1008
|
+
),
|
|
1009
|
+
).rejects.toMatchObject({
|
|
1010
|
+
statusCode: 503,
|
|
1011
|
+
errorCode: "workspace_gateway_unavailable",
|
|
1012
|
+
});
|
|
1013
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("surfaces malformed hosted registry responses instead of using manifest apps", async () => {
|
|
1017
|
+
const fetchMock = vi.fn(
|
|
1018
|
+
async () =>
|
|
1019
|
+
new Response("not json", {
|
|
1020
|
+
headers: { "content-type": "application/json" },
|
|
1021
|
+
}),
|
|
1022
|
+
);
|
|
1023
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1024
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
1025
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
1026
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
1027
|
+
|
|
1028
|
+
await expect(
|
|
1029
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
1030
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
1031
|
+
),
|
|
1032
|
+
).rejects.toMatchObject({
|
|
1033
|
+
statusCode: 503,
|
|
1034
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
1035
|
+
});
|
|
1036
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it.each([
|
|
1040
|
+
[{ id: "dispatch", name: "Dispatch", path: "/dispatch" }, null],
|
|
1041
|
+
[
|
|
1042
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
1043
|
+
{ id: "DISPATCH", name: "Duplicate Dispatch", path: "/other" },
|
|
1044
|
+
],
|
|
1045
|
+
])("rejects incomplete hosted registry lists: %j", async (apps) => {
|
|
1046
|
+
const fetchMock = vi.fn(
|
|
1047
|
+
async () =>
|
|
1048
|
+
new Response(JSON.stringify(apps), {
|
|
1049
|
+
headers: { "content-type": "application/json" },
|
|
1050
|
+
}),
|
|
1051
|
+
);
|
|
1052
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1053
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
1054
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
1055
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
1056
|
+
|
|
1057
|
+
await expect(
|
|
1058
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
1059
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
1060
|
+
),
|
|
1061
|
+
).rejects.toMatchObject({
|
|
1062
|
+
statusCode: 503,
|
|
1063
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
1064
|
+
});
|
|
1065
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
668
1068
|
it("passes the Vercel protection bypass to the hosted workspace registry", async () => {
|
|
669
1069
|
const fetchMock = vi.fn(async () => {
|
|
670
1070
|
return new Response(
|
|
@@ -731,10 +1131,10 @@ describe("listWorkspaceApps", () => {
|
|
|
731
1131
|
);
|
|
732
1132
|
});
|
|
733
1133
|
|
|
734
|
-
it("
|
|
1134
|
+
it("skips a same-origin hosted registry without credentials", async () => {
|
|
735
1135
|
const fetchMock = vi.fn();
|
|
736
1136
|
vi.stubGlobal("fetch", fetchMock);
|
|
737
|
-
vi.stubEnv("A2A_SECRET", "
|
|
1137
|
+
vi.stubEnv("A2A_SECRET", "");
|
|
738
1138
|
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
739
1139
|
|
|
740
1140
|
const apps = await runWithRequestContext(
|
|
@@ -763,9 +1163,13 @@ describe("listWorkspaceApps", () => {
|
|
|
763
1163
|
{ userEmail: "dev@example.test", orgId: "org-without-domain" },
|
|
764
1164
|
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
765
1165
|
),
|
|
766
|
-
).rejects.
|
|
767
|
-
|
|
768
|
-
|
|
1166
|
+
).rejects.toMatchObject({
|
|
1167
|
+
name: "WorkspaceAppsGatewayAuthorizationError",
|
|
1168
|
+
statusCode: 401,
|
|
1169
|
+
errorCode: "workspace_gateway_org_domain_missing",
|
|
1170
|
+
message:
|
|
1171
|
+
"Workspace apps gateway cannot authenticate without a resolved organization domain.",
|
|
1172
|
+
});
|
|
769
1173
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
770
1174
|
});
|
|
771
1175
|
|
|
@@ -1508,6 +1912,51 @@ describe("listWorkspaceApps", () => {
|
|
|
1508
1912
|
);
|
|
1509
1913
|
});
|
|
1510
1914
|
|
|
1915
|
+
it("keeps the curated source id on a pending app summary", async () => {
|
|
1916
|
+
stubNoPendingContext();
|
|
1917
|
+
stubManifest();
|
|
1918
|
+
mocks.settings.set(settingsKey, {
|
|
1919
|
+
pendingApps: [
|
|
1920
|
+
pendingApp("community-qr", {
|
|
1921
|
+
communitySourceId: "community-qr",
|
|
1922
|
+
}),
|
|
1923
|
+
],
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
const apps = await runWithRequestContext(
|
|
1927
|
+
{ userEmail: "dev@example.test" },
|
|
1928
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
expect(apps.find((app) => app.id === "community-qr")).toMatchObject({
|
|
1932
|
+
status: "pending",
|
|
1933
|
+
communitySourceId: "community-qr",
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it("keeps the curated source id on ready app metadata", async () => {
|
|
1938
|
+
stubNoPendingContext();
|
|
1939
|
+
stubManifest([
|
|
1940
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
1941
|
+
{ id: "community-qr", name: "QR", path: "/community-qr" },
|
|
1942
|
+
]);
|
|
1943
|
+
mocks.settings.set("workspace-app-metadata:user:dev@example.test", {
|
|
1944
|
+
apps: {
|
|
1945
|
+
"community-qr": { communitySourceId: "community-qr" },
|
|
1946
|
+
},
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
const apps = await runWithRequestContext(
|
|
1950
|
+
{ userEmail: "dev@example.test" },
|
|
1951
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
expect(apps.find((app) => app.id === "community-qr")).toMatchObject({
|
|
1955
|
+
status: "ready",
|
|
1956
|
+
communitySourceId: "community-qr",
|
|
1957
|
+
});
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1511
1960
|
it("keeps unscoped legacy pending rows visible when there is no deploy context", async () => {
|
|
1512
1961
|
stubNoPendingContext();
|
|
1513
1962
|
stubManifest();
|
|
@@ -1643,7 +2092,7 @@ describe("listWorkspaceApps", () => {
|
|
|
1643
2092
|
expect(apps.find((app) => app.id === "mail")?.status).toBe("ready");
|
|
1644
2093
|
});
|
|
1645
2094
|
|
|
1646
|
-
it("lets workspace admins update app
|
|
2095
|
+
it("lets workspace admins update app profile metadata", async () => {
|
|
1647
2096
|
stubNoPendingContext();
|
|
1648
2097
|
stubManifest([
|
|
1649
2098
|
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
@@ -1662,24 +2111,26 @@ describe("listWorkspaceApps", () => {
|
|
|
1662
2111
|
appId: "todo",
|
|
1663
2112
|
name: "Todo Board",
|
|
1664
2113
|
description: "Tracks team work.",
|
|
2114
|
+
whenToUse: ["Create a task from a meeting note"],
|
|
1665
2115
|
}),
|
|
1666
2116
|
);
|
|
1667
2117
|
|
|
1668
2118
|
expect(updated.name).toBe("Todo Board");
|
|
1669
2119
|
expect(updated.description).toBe("Tracks team work.");
|
|
2120
|
+
expect(updated.whenToUse).toEqual(["Create a task from a meeting note"]);
|
|
1670
2121
|
expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
|
|
1671
2122
|
apps: {
|
|
1672
2123
|
todo: expect.objectContaining({
|
|
1673
2124
|
name: "Todo Board",
|
|
1674
2125
|
description: "Tracks team work.",
|
|
2126
|
+
whenToUse: ["Create a task from a meeting note"],
|
|
1675
2127
|
updatedBy: "dev@example.test",
|
|
1676
2128
|
}),
|
|
1677
2129
|
},
|
|
1678
2130
|
});
|
|
1679
2131
|
});
|
|
1680
2132
|
|
|
1681
|
-
it("
|
|
1682
|
-
mocks.state.orgRole = "member";
|
|
2133
|
+
it("does not overwrite app profile metadata when its settings are unreadable", async () => {
|
|
1683
2134
|
stubNoPendingContext();
|
|
1684
2135
|
stubManifest([
|
|
1685
2136
|
{
|
|
@@ -1689,30 +2140,262 @@ describe("listWorkspaceApps", () => {
|
|
|
1689
2140
|
path: "/todo",
|
|
1690
2141
|
},
|
|
1691
2142
|
]);
|
|
2143
|
+
const key = "workspace-app-metadata:org:org-123";
|
|
2144
|
+
const existing = { apps: { other: { name: "Preserve me" } } };
|
|
2145
|
+
mocks.settings.set(key, existing);
|
|
2146
|
+
let metadataReads = 0;
|
|
2147
|
+
mocks.getSetting.mockImplementation(async (settingKey: string) => {
|
|
2148
|
+
if (settingKey === key && ++metadataReads === 2) {
|
|
2149
|
+
throw new Error("metadata settings unavailable");
|
|
2150
|
+
}
|
|
2151
|
+
return mocks.settings.get(settingKey) ?? null;
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
await expect(
|
|
2155
|
+
runWithRequestContext(
|
|
2156
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2157
|
+
() => updateWorkspaceAppMetadata({ appId: "todo", name: "Updated" }),
|
|
2158
|
+
),
|
|
2159
|
+
).rejects.toThrow("metadata settings unavailable");
|
|
2160
|
+
|
|
2161
|
+
expect(metadataReads).toBe(2);
|
|
2162
|
+
expect(mocks.putSetting).not.toHaveBeenCalled();
|
|
2163
|
+
expect(mocks.settings.get(key)).toEqual(existing);
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
it("does not overwrite workspace settings when archiving after a read failure", async () => {
|
|
2167
|
+
const key = "dispatch-app-creation-settings:org:org-123";
|
|
2168
|
+
const existing = {
|
|
2169
|
+
builderProjectId: "project-123",
|
|
2170
|
+
pendingCommunityReservations: ["community-qr"],
|
|
2171
|
+
archivedAppIds: [],
|
|
2172
|
+
};
|
|
2173
|
+
mocks.settings.set(key, existing);
|
|
2174
|
+
mocks.getSetting.mockRejectedValueOnce(
|
|
2175
|
+
new Error("workspace settings unavailable"),
|
|
2176
|
+
);
|
|
2177
|
+
|
|
2178
|
+
await expect(
|
|
2179
|
+
runWithRequestContext(
|
|
2180
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2181
|
+
() => archiveWorkspaceApp({ appId: "todo" }),
|
|
2182
|
+
),
|
|
2183
|
+
).rejects.toThrow("workspace settings unavailable");
|
|
2184
|
+
|
|
2185
|
+
expect(mocks.putSetting).not.toHaveBeenCalled();
|
|
2186
|
+
expect(mocks.settings.get(key)).toEqual(existing);
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
it("preserves generated name and description when only examples change", async () => {
|
|
2190
|
+
stubNoPendingContext();
|
|
2191
|
+
stubManifest([
|
|
2192
|
+
{
|
|
2193
|
+
id: "todo",
|
|
2194
|
+
name: "Todo",
|
|
2195
|
+
description: "Manifest description",
|
|
2196
|
+
path: "/todo",
|
|
2197
|
+
},
|
|
2198
|
+
]);
|
|
2199
|
+
mocks.settings.set("workspace-app-metadata:org:org-123", {
|
|
2200
|
+
apps: {
|
|
2201
|
+
todo: {
|
|
2202
|
+
description: "Generated description",
|
|
2203
|
+
generated: true,
|
|
2204
|
+
},
|
|
2205
|
+
},
|
|
2206
|
+
});
|
|
1692
2207
|
|
|
1693
2208
|
const updated = await runWithRequestContext(
|
|
1694
2209
|
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
1695
2210
|
() =>
|
|
1696
2211
|
updateWorkspaceAppMetadata({
|
|
1697
2212
|
appId: "todo",
|
|
1698
|
-
|
|
1699
|
-
description: "Tracks team work.",
|
|
2213
|
+
whenToUse: ["Create a task from a meeting note"],
|
|
1700
2214
|
}),
|
|
1701
2215
|
);
|
|
1702
2216
|
|
|
1703
|
-
expect(updated).
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
});
|
|
2217
|
+
expect(updated.name).toBe("Todo");
|
|
2218
|
+
expect(updated.description).toBe("Manifest description");
|
|
2219
|
+
expect(updated.whenToUse).toEqual(["Create a task from a meeting note"]);
|
|
1707
2220
|
expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
|
|
1708
2221
|
apps: {
|
|
1709
2222
|
todo: expect.objectContaining({
|
|
1710
|
-
|
|
1711
|
-
|
|
2223
|
+
description: "Generated description",
|
|
2224
|
+
generated: true,
|
|
2225
|
+
whenToUse: ["Create a task from a meeting note"],
|
|
1712
2226
|
updatedBy: "dev@example.test",
|
|
1713
2227
|
}),
|
|
1714
2228
|
},
|
|
1715
2229
|
});
|
|
2230
|
+
expect(
|
|
2231
|
+
(mocks.settings.get("workspace-app-metadata:org:org-123") as any).apps
|
|
2232
|
+
.todo,
|
|
2233
|
+
).not.toHaveProperty("name");
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
it("invalidates org directory discovery after a profile edit", async () => {
|
|
2237
|
+
stubNoPendingContext();
|
|
2238
|
+
stubManifest([
|
|
2239
|
+
{ id: "todo", name: "Todo", description: "Tasks", path: "/todo" },
|
|
2240
|
+
]);
|
|
2241
|
+
const load = vi.fn(async () => [
|
|
2242
|
+
{
|
|
2243
|
+
id: "todo",
|
|
2244
|
+
name: "Old profile",
|
|
2245
|
+
url: "https://todo.example.test",
|
|
2246
|
+
},
|
|
2247
|
+
]);
|
|
2248
|
+
await orgAppsDirectoryCache.get("org-123|organization", load);
|
|
2249
|
+
const loadAccess = vi.fn(async () => new Set(["todo"]));
|
|
2250
|
+
await orgAppsDirectoryAccessCache.get("org-123|organization", loadAccess);
|
|
2251
|
+
|
|
2252
|
+
await runWithRequestContext(
|
|
2253
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2254
|
+
() =>
|
|
2255
|
+
updateWorkspaceAppMetadata({
|
|
2256
|
+
appId: "todo",
|
|
2257
|
+
description: "Updated profile",
|
|
2258
|
+
}),
|
|
2259
|
+
);
|
|
2260
|
+
|
|
2261
|
+
const refreshedLoad = vi.fn(async () => [
|
|
2262
|
+
{
|
|
2263
|
+
id: "todo",
|
|
2264
|
+
name: "Updated profile",
|
|
2265
|
+
url: "https://todo.example.test",
|
|
2266
|
+
},
|
|
2267
|
+
]);
|
|
2268
|
+
await expect(
|
|
2269
|
+
orgAppsDirectoryCache.get("org-123|organization", refreshedLoad),
|
|
2270
|
+
).resolves.toEqual([
|
|
2271
|
+
{
|
|
2272
|
+
id: "todo",
|
|
2273
|
+
name: "Updated profile",
|
|
2274
|
+
url: "https://todo.example.test",
|
|
2275
|
+
},
|
|
2276
|
+
]);
|
|
2277
|
+
expect(refreshedLoad).toHaveBeenCalledOnce();
|
|
2278
|
+
|
|
2279
|
+
const refreshedAccess = vi.fn(async () => new Set(["todo"]));
|
|
2280
|
+
await expect(
|
|
2281
|
+
orgAppsDirectoryAccessCache.get("org-123|organization", refreshedAccess),
|
|
2282
|
+
).resolves.toEqual(new Set(["todo"]));
|
|
2283
|
+
expect(refreshedAccess).toHaveBeenCalledOnce();
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
it("clears explicit null metadata and preserves an empty when-to-use list", async () => {
|
|
2287
|
+
stubNoPendingContext();
|
|
2288
|
+
stubManifest([
|
|
2289
|
+
{
|
|
2290
|
+
id: "todo",
|
|
2291
|
+
name: "Manifest name",
|
|
2292
|
+
description: "Manifest description",
|
|
2293
|
+
path: "/todo",
|
|
2294
|
+
},
|
|
2295
|
+
]);
|
|
2296
|
+
mocks.settings.set("workspace-app-metadata:org:org-123", {
|
|
2297
|
+
apps: {
|
|
2298
|
+
todo: {
|
|
2299
|
+
name: "Override name",
|
|
2300
|
+
description: "Override description",
|
|
2301
|
+
whenToUse: ["Old example"],
|
|
2302
|
+
},
|
|
2303
|
+
},
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
await runWithRequestContext(
|
|
2307
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2308
|
+
() =>
|
|
2309
|
+
updateWorkspaceAppMetadata({
|
|
2310
|
+
appId: "todo",
|
|
2311
|
+
name: null,
|
|
2312
|
+
description: null,
|
|
2313
|
+
whenToUse: [],
|
|
2314
|
+
}),
|
|
2315
|
+
);
|
|
2316
|
+
|
|
2317
|
+
expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
|
|
2318
|
+
apps: {
|
|
2319
|
+
todo: expect.objectContaining({ whenToUse: [] }),
|
|
2320
|
+
},
|
|
2321
|
+
});
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
it("reports owner-role lookup failures as unavailable instead of unauthorized", async () => {
|
|
2325
|
+
stubNoPendingContext();
|
|
2326
|
+
stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
|
|
2327
|
+
mocks.getDbExec.mockReturnValue({
|
|
2328
|
+
execute: vi.fn(async () => {
|
|
2329
|
+
throw new Error("database unavailable");
|
|
2330
|
+
}),
|
|
2331
|
+
} as any);
|
|
2332
|
+
|
|
2333
|
+
await expect(
|
|
2334
|
+
runWithRequestContext(
|
|
2335
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2336
|
+
() => updateWorkspaceAppMetadata({ appId: "todo", name: "Updated" }),
|
|
2337
|
+
),
|
|
2338
|
+
).rejects.toMatchObject({
|
|
2339
|
+
name: "AppCreationOwnerRoleUnavailableError",
|
|
2340
|
+
statusCode: 503,
|
|
2341
|
+
});
|
|
2342
|
+
expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
|
|
2343
|
+
false,
|
|
2344
|
+
);
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
it("rejects malformed profile examples before writing", async () => {
|
|
2348
|
+
stubNoPendingContext();
|
|
2349
|
+
stubManifest([
|
|
2350
|
+
{ id: "todo", name: "Todo", description: "Tasks", path: "/todo" },
|
|
2351
|
+
]);
|
|
2352
|
+
const tooManyExamples = Array.from(
|
|
2353
|
+
{ length: 7 },
|
|
2354
|
+
(_, index) => `Example ${index}`,
|
|
2355
|
+
);
|
|
2356
|
+
|
|
2357
|
+
await expect(
|
|
2358
|
+
runWithRequestContext(
|
|
2359
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2360
|
+
() =>
|
|
2361
|
+
updateWorkspaceAppMetadata({
|
|
2362
|
+
appId: "todo",
|
|
2363
|
+
whenToUse: tooManyExamples,
|
|
2364
|
+
}),
|
|
2365
|
+
),
|
|
2366
|
+
).rejects.toThrow(/up to 6 non-empty examples/);
|
|
2367
|
+
expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
|
|
2368
|
+
false,
|
|
2369
|
+
);
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
it("rejects workspace member updates to app profile metadata", async () => {
|
|
2373
|
+
mocks.state.orgRole = "member";
|
|
2374
|
+
stubNoPendingContext();
|
|
2375
|
+
stubManifest([
|
|
2376
|
+
{
|
|
2377
|
+
id: "todo",
|
|
2378
|
+
name: "Todo",
|
|
2379
|
+
description: "Original description",
|
|
2380
|
+
path: "/todo",
|
|
2381
|
+
},
|
|
2382
|
+
]);
|
|
2383
|
+
|
|
2384
|
+
await expect(
|
|
2385
|
+
runWithRequestContext(
|
|
2386
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2387
|
+
() =>
|
|
2388
|
+
updateWorkspaceAppMetadata({
|
|
2389
|
+
appId: "todo",
|
|
2390
|
+
name: "Todo Board",
|
|
2391
|
+
description: "Tracks team work.",
|
|
2392
|
+
whenToUse: ["Create a task from a meeting note"],
|
|
2393
|
+
}),
|
|
2394
|
+
),
|
|
2395
|
+
).rejects.toMatchObject({ statusCode: 403 });
|
|
2396
|
+
expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
|
|
2397
|
+
false,
|
|
2398
|
+
);
|
|
1716
2399
|
});
|
|
1717
2400
|
|
|
1718
2401
|
it("generates a concise seed description from an app prompt", () => {
|
|
@@ -1812,6 +2495,82 @@ describe("startWorkspaceAppCreation", () => {
|
|
|
1812
2495
|
});
|
|
1813
2496
|
});
|
|
1814
2497
|
|
|
2498
|
+
it("persists the curated source id for local-agent app creation", async () => {
|
|
2499
|
+
const result = await runWithRequestContext(
|
|
2500
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2501
|
+
() =>
|
|
2502
|
+
startWorkspaceAppCreation({
|
|
2503
|
+
prompt: "Install the curated QR template",
|
|
2504
|
+
appId: "community-qr",
|
|
2505
|
+
communitySourceId: "community-qr",
|
|
2506
|
+
attachments: [
|
|
2507
|
+
{
|
|
2508
|
+
type: "upload",
|
|
2509
|
+
contentType: "text/plain",
|
|
2510
|
+
name: "community-qr/README.md",
|
|
2511
|
+
text: "Pinned template source",
|
|
2512
|
+
dataUrl: "",
|
|
2513
|
+
id: "community-qr:README.md",
|
|
2514
|
+
},
|
|
2515
|
+
],
|
|
2516
|
+
}),
|
|
2517
|
+
);
|
|
2518
|
+
|
|
2519
|
+
expect(result).toMatchObject({
|
|
2520
|
+
mode: "local-agent",
|
|
2521
|
+
attachments: [
|
|
2522
|
+
{
|
|
2523
|
+
type: "file",
|
|
2524
|
+
contentType: "text/plain",
|
|
2525
|
+
name: "community-qr/README.md",
|
|
2526
|
+
text: "Pinned template source",
|
|
2527
|
+
},
|
|
2528
|
+
],
|
|
2529
|
+
});
|
|
2530
|
+
expect(
|
|
2531
|
+
mocks.settings.get("workspace-app-metadata:org:org-123"),
|
|
2532
|
+
).toMatchObject({
|
|
2533
|
+
apps: {
|
|
2534
|
+
"community-qr": { communitySourceId: "community-qr" },
|
|
2535
|
+
},
|
|
2536
|
+
});
|
|
2537
|
+
expect(
|
|
2538
|
+
mocks.settings.get("dispatch-app-creation-settings:org:org-123"),
|
|
2539
|
+
).toMatchObject({
|
|
2540
|
+
pendingApps: [
|
|
2541
|
+
expect.objectContaining({
|
|
2542
|
+
id: "community-qr",
|
|
2543
|
+
communitySourceId: "community-qr",
|
|
2544
|
+
createdBy: "dev@example.test",
|
|
2545
|
+
}),
|
|
2546
|
+
],
|
|
2547
|
+
});
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
it("atomically reserves local community installs against duplicate requests", async () => {
|
|
2551
|
+
const input = {
|
|
2552
|
+
prompt: "Install the curated QR template",
|
|
2553
|
+
appId: "community-qr",
|
|
2554
|
+
communitySourceId: "community-qr",
|
|
2555
|
+
};
|
|
2556
|
+
const context = { userEmail: "dev@example.test", orgId: "org-123" };
|
|
2557
|
+
|
|
2558
|
+
const first = await runWithRequestContext(context, () =>
|
|
2559
|
+
startWorkspaceAppCreation(input),
|
|
2560
|
+
);
|
|
2561
|
+
const duplicate = await runWithRequestContext(context, () =>
|
|
2562
|
+
startWorkspaceAppCreation(input),
|
|
2563
|
+
);
|
|
2564
|
+
|
|
2565
|
+
expect(first.mode).toBe("local-agent");
|
|
2566
|
+
expect(duplicate).toMatchObject({
|
|
2567
|
+
mode: "app-id-taken",
|
|
2568
|
+
appId: "community-qr",
|
|
2569
|
+
conflict: "pending",
|
|
2570
|
+
owner: "dev@example.test",
|
|
2571
|
+
});
|
|
2572
|
+
});
|
|
2573
|
+
|
|
1815
2574
|
it("rejects a cross-member collision with an active pending app id", async () => {
|
|
1816
2575
|
stubHostedRuntime();
|
|
1817
2576
|
stubBuilderProjectConfigured();
|
|
@@ -1961,6 +2720,51 @@ describe("startWorkspaceAppCreation", () => {
|
|
|
1961
2720
|
expect(mocks.mutateSetting).toHaveBeenCalled();
|
|
1962
2721
|
});
|
|
1963
2722
|
|
|
2723
|
+
it("persists the curated source id with a hosted pending reservation", async () => {
|
|
2724
|
+
stubHostedRuntime();
|
|
2725
|
+
stubBuilderProjectConfigured();
|
|
2726
|
+
mocks.resolveBuilderCredentialsDetailed.mockResolvedValue(
|
|
2727
|
+
credentials({
|
|
2728
|
+
privateKey: "priv",
|
|
2729
|
+
publicKey: "pub",
|
|
2730
|
+
userId: "builder-user-42",
|
|
2731
|
+
}),
|
|
2732
|
+
);
|
|
2733
|
+
mocks.runBuilderAgent.mockResolvedValue({
|
|
2734
|
+
branchName: "community-qr1",
|
|
2735
|
+
url: "https://builder.io/app/projects/project-1/community-qr1",
|
|
2736
|
+
status: "processing",
|
|
2737
|
+
});
|
|
2738
|
+
|
|
2739
|
+
await runWithRequestContext(
|
|
2740
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
2741
|
+
() =>
|
|
2742
|
+
startWorkspaceAppCreation({
|
|
2743
|
+
prompt: "Install the curated QR template",
|
|
2744
|
+
appId: "community-qr",
|
|
2745
|
+
communitySourceId: "community-qr",
|
|
2746
|
+
}),
|
|
2747
|
+
);
|
|
2748
|
+
|
|
2749
|
+
expect(
|
|
2750
|
+
mocks.settings.get("dispatch-app-creation-settings:org:org-123"),
|
|
2751
|
+
).toMatchObject({
|
|
2752
|
+
pendingApps: [
|
|
2753
|
+
expect.objectContaining({
|
|
2754
|
+
id: "community-qr",
|
|
2755
|
+
communitySourceId: "community-qr",
|
|
2756
|
+
}),
|
|
2757
|
+
],
|
|
2758
|
+
});
|
|
2759
|
+
expect(
|
|
2760
|
+
mocks.settings.get("workspace-app-metadata:org:org-123"),
|
|
2761
|
+
).toMatchObject({
|
|
2762
|
+
apps: {
|
|
2763
|
+
"community-qr": { communitySourceId: "community-qr" },
|
|
2764
|
+
},
|
|
2765
|
+
});
|
|
2766
|
+
});
|
|
2767
|
+
|
|
1964
2768
|
it("releases the reservation when Builder handoff fails", async () => {
|
|
1965
2769
|
stubHostedRuntime();
|
|
1966
2770
|
stubBuilderProjectConfigured();
|