@agent-native/dispatch 0.29.4 → 0.30.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.
@@ -14,9 +14,13 @@ import {
14
14
  resolveBuilderCredentialsDetailed,
15
15
  runBuilderAgent,
16
16
  } from "@agent-native/core/server";
17
+ import { getOrgSetting } from "@agent-native/core/settings";
17
18
  import { getSetting, putSetting } from "@agent-native/core/settings";
18
19
  import { assertValidWorkspaceAppId } from "@agent-native/core/shared";
20
+ import { resolveAccess } from "@agent-native/core/sharing";
19
21
 
22
+ // Register the workspace-app shareable resource before any access lookup.
23
+ import "../../db/index.js";
20
24
  import { isWorkspaceSsoAppUrl } from "../../shared/workspace-sso.js";
21
25
  import { identityKeyForIncoming } from "./dispatch-integrations.js";
22
26
  import {
@@ -43,10 +47,12 @@ const APP_CREATION_SETTINGS_AUTHORIZATION_MESSAGE =
43
47
  const APP_CREATION_SETTINGS_REQUIRED_MESSAGE =
44
48
  "An organization owner or admin must configure the Builder workspace project before members can create apps.";
45
49
  const WORKSPACE_APP_METADATA_SETTINGS_KEY = "workspace-app-metadata";
50
+ const WORKSPACE_APP_DEFAULT_VISIBILITY_KEY = "workspace-app-default-visibility";
46
51
  const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
47
52
  const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
48
53
  const WORKSPACE_APPS_GATEWAY_PATH = "/_workspace/apps";
49
54
  const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS = 2_500;
55
+ const WORKSPACE_APP_ACCESS_CONCURRENCY = 8;
50
56
  const MAX_PENDING_APPS = 50;
51
57
  const PENDING_WORKSPACE_APP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
52
58
  const AGENT_CARD_PATH = "/.well-known/agent-card.json";
@@ -79,6 +85,7 @@ class AppCreationSettingsAuthorizationError extends Error {
79
85
  }
80
86
 
81
87
  type WorkspaceAppAudience = "internal" | "public";
88
+ type WorkspaceAppVisibility = "private" | "org";
82
89
 
83
90
  export interface WorkspaceAppSummary {
84
91
  id: string;
@@ -96,6 +103,7 @@ export interface WorkspaceAppSummary {
96
103
  branchName?: string | null;
97
104
  createdAt?: string | null;
98
105
  createdBy?: string | null;
106
+ visibility?: WorkspaceAppVisibility;
99
107
  owner?: string | null;
100
108
  teams?: string[];
101
109
  agentCardUrl?: string | null;
@@ -174,6 +182,8 @@ interface WorkspaceAppMetadataOverride {
174
182
  sourcePrompt?: string;
175
183
  updatedAt?: string;
176
184
  updatedBy?: string;
185
+ createdBy?: string;
186
+ visibility?: WorkspaceAppVisibility;
177
187
  }
178
188
 
179
189
  interface WorkspaceAppMetadataSettings {
@@ -337,6 +347,7 @@ function parseWorkspaceAppMetadataSettings(
337
347
  const sourcePrompt = cleanOptionalText(item.sourcePrompt);
338
348
  const updatedAt = cleanOptionalText(item.updatedAt);
339
349
  const updatedBy = cleanOptionalText(item.updatedBy);
350
+ const createdBy = cleanOptionalText(item.createdBy);
340
351
 
341
352
  if (name) override.name = name;
342
353
  if (description) override.description = description;
@@ -344,6 +355,10 @@ function parseWorkspaceAppMetadataSettings(
344
355
  if (sourcePrompt) override.sourcePrompt = sourcePrompt;
345
356
  if (updatedAt) override.updatedAt = updatedAt;
346
357
  if (updatedBy) override.updatedBy = updatedBy;
358
+ if (createdBy) override.createdBy = createdBy;
359
+ if (item.visibility === "private" || item.visibility === "org") {
360
+ override.visibility = item.visibility;
361
+ }
347
362
 
348
363
  if (Object.keys(override).length > 0) apps[id.trim()] = override;
349
364
  }
@@ -365,6 +380,8 @@ async function writeWorkspaceAppMetadataOverride(input: {
365
380
  generated?: boolean;
366
381
  sourcePrompt?: string | null;
367
382
  updatedBy?: string | null;
383
+ createdBy?: string | null;
384
+ visibility?: WorkspaceAppVisibility | null;
368
385
  }): Promise<WorkspaceAppMetadataSettings> {
369
386
  const key = workspaceAppMetadataSettingsKey();
370
387
  const current = parseWorkspaceAppMetadataSettings(
@@ -380,6 +397,7 @@ async function writeWorkspaceAppMetadataOverride(input: {
380
397
  const description = cleanOptionalText(input.description);
381
398
  const sourcePrompt = cleanOptionalText(input.sourcePrompt);
382
399
  const updatedBy = cleanOptionalText(input.updatedBy);
400
+ const createdBy = cleanOptionalText(input.createdBy);
383
401
 
384
402
  if (name) next.name = name;
385
403
  else delete next.name;
@@ -389,6 +407,8 @@ async function writeWorkspaceAppMetadataOverride(input: {
389
407
  else if (input.generated === false) delete next.generated;
390
408
  if (sourcePrompt) next.sourcePrompt = sourcePrompt;
391
409
  if (updatedBy) next.updatedBy = updatedBy;
410
+ if (createdBy) next.createdBy = createdBy;
411
+ if (input.visibility) next.visibility = input.visibility;
392
412
 
393
413
  current.apps[appId] = next;
394
414
  await putSetting(key, { apps: current.apps });
@@ -408,17 +428,23 @@ function applyWorkspaceAppMetadataOverride(
408
428
  const shouldApplyName = !!name && !generated;
409
429
  const shouldApplyDescription =
410
430
  !!description && (!generated || !cleanOptionalText(app.description));
411
- if (!shouldApplyName && !shouldApplyDescription) return app;
431
+ const visibility = override.visibility;
432
+ if (!shouldApplyName && !shouldApplyDescription && !visibility) return app;
412
433
 
413
434
  return {
414
435
  ...app,
415
436
  ...(shouldApplyName ? { name } : {}),
416
437
  ...(shouldApplyDescription ? { description } : {}),
417
- ...(app.status === "pending" && !app.createdBy && override.updatedBy
418
- ? { createdBy: override.updatedBy }
438
+ ...(visibility ? { visibility } : {}),
439
+ ...(app.status === "pending" &&
440
+ !app.createdBy &&
441
+ (override.createdBy || override.updatedBy)
442
+ ? { createdBy: override.createdBy ?? override.updatedBy }
419
443
  : {}),
420
- ...(app.status === "pending" && !app.owner && override.updatedBy
421
- ? { owner: override.updatedBy }
444
+ ...(app.status === "pending" &&
445
+ !app.owner &&
446
+ (override.createdBy || override.updatedBy)
447
+ ? { owner: override.createdBy ?? override.updatedBy }
422
448
  : {}),
423
449
  };
424
450
  }
@@ -1002,6 +1028,198 @@ async function maybeIncludeAgentCards(
1002
1028
  );
1003
1029
  }
1004
1030
 
1031
+ async function workspaceAppDefaultVisibility(): Promise<WorkspaceAppVisibility> {
1032
+ const orgId = currentOrgId();
1033
+ if (!orgId) return "org";
1034
+ const setting = await getOrgSetting(
1035
+ orgId,
1036
+ WORKSPACE_APP_DEFAULT_VISIBILITY_KEY,
1037
+ );
1038
+ if (setting === null) return "org";
1039
+ if (setting.visibility === "private" || setting.visibility === "org") {
1040
+ return setting.visibility;
1041
+ }
1042
+ throw new Error(
1043
+ "Workspace app default visibility is invalid; refusing to widen access.",
1044
+ );
1045
+ }
1046
+
1047
+ function appRecordTimestamp(value: string | null | undefined): number {
1048
+ const parsed = value ? Date.parse(value) : NaN;
1049
+ return Number.isFinite(parsed) ? parsed : Date.now();
1050
+ }
1051
+
1052
+ /**
1053
+ * Persist creator and visibility separately from the deploy manifest. The
1054
+ * manifest describes code; this SQL record is the access-control source of
1055
+ * truth shared by Dispatch and the mounted app's auth guard.
1056
+ */
1057
+ async function ensureWorkspaceAppRecords(
1058
+ apps: WorkspaceAppSummary[],
1059
+ ): Promise<WorkspaceAppSummary[]> {
1060
+ const readyApps = apps.filter(
1061
+ (app) => app.status !== "pending" && !app.isDispatch,
1062
+ );
1063
+ if (readyApps.length === 0) return apps;
1064
+
1065
+ const orgId = currentOrgId();
1066
+ const metadata = await readWorkspaceAppMetadataSettings();
1067
+ const defaultVisibility = await workspaceAppDefaultVisibility();
1068
+ const db = getDbExec();
1069
+ const records = new Map<
1070
+ string,
1071
+ {
1072
+ ownerEmail: string;
1073
+ orgId: string | null;
1074
+ visibility: WorkspaceAppVisibility;
1075
+ }
1076
+ >();
1077
+
1078
+ try {
1079
+ const existingRecords = new Map<
1080
+ string,
1081
+ { owner_email?: unknown; org_id?: unknown; visibility?: unknown }
1082
+ >();
1083
+ for (let start = 0; start < readyApps.length; start += 500) {
1084
+ const ids = readyApps.slice(start, start + 500).map((app) => app.id);
1085
+ const result = await db.execute({
1086
+ sql: `SELECT id, owner_email, org_id, visibility
1087
+ FROM workspace_apps
1088
+ WHERE id IN (${ids.map(() => "?").join(", ")})`,
1089
+ args: ids,
1090
+ });
1091
+ for (const row of result.rows) {
1092
+ const id = cleanOptionalText((row as Record<string, unknown>).id);
1093
+ if (id) {
1094
+ existingRecords.set(id, row as Record<string, unknown>);
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ for (const app of readyApps) {
1100
+ const existing = existingRecords.get(app.id);
1101
+ if (!existing) {
1102
+ const override = metadata.apps[app.id];
1103
+ // Never infer ownership from the person who happened to list apps.
1104
+ // Legacy manifests without trusted creation metadata remain
1105
+ // ownerless until an admin-controlled migration/claim flow exists.
1106
+ const ownerEmail =
1107
+ cleanOptionalText(override?.createdBy) ??
1108
+ cleanOptionalText(app.createdBy) ??
1109
+ "";
1110
+ const hasTrustedCreationMetadata = Boolean(ownerEmail);
1111
+ const visibility: WorkspaceAppVisibility =
1112
+ override?.visibility === "private"
1113
+ ? "private"
1114
+ : override?.visibility === "org"
1115
+ ? "org"
1116
+ : hasTrustedCreationMetadata
1117
+ ? defaultVisibility
1118
+ : "org";
1119
+ const createdAt = appRecordTimestamp(app.createdAt);
1120
+ await db.execute({
1121
+ sql: `INSERT INTO workspace_apps
1122
+ (id, owner_email, org_id, visibility, name, description, path, created_at, updated_at)
1123
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1124
+ args: [
1125
+ app.id,
1126
+ ownerEmail,
1127
+ orgId,
1128
+ visibility,
1129
+ app.name,
1130
+ app.description || null,
1131
+ app.path,
1132
+ createdAt,
1133
+ Date.now(),
1134
+ ],
1135
+ });
1136
+ records.set(app.id, { ownerEmail, orgId, visibility });
1137
+ } else {
1138
+ records.set(app.id, {
1139
+ ownerEmail:
1140
+ typeof existing.owner_email === "string"
1141
+ ? existing.owner_email
1142
+ : "",
1143
+ orgId:
1144
+ (typeof existing.org_id === "string"
1145
+ ? existing.org_id
1146
+ : ""
1147
+ ).trim() || null,
1148
+ visibility: existing.visibility === "private" ? "private" : "org",
1149
+ });
1150
+ }
1151
+ }
1152
+ } catch (error) {
1153
+ console.warn("[dispatch] workspace app access records unavailable", error);
1154
+ return apps;
1155
+ }
1156
+
1157
+ return apps.map((app) => {
1158
+ const record = records.get(app.id);
1159
+ return record ? { ...app, visibility: record.visibility } : app;
1160
+ });
1161
+ }
1162
+
1163
+ async function filterWorkspaceAppsByAccess(
1164
+ apps: WorkspaceAppSummary[],
1165
+ ): Promise<WorkspaceAppSummary[]> {
1166
+ let userEmail: string;
1167
+ try {
1168
+ userEmail = currentOwnerEmail();
1169
+ } catch {
1170
+ // coercion-ok: anonymous requests fail closed by receiving no app metadata.
1171
+ // App metadata is access-controlled. An anonymous request must not receive
1172
+ // the full registry simply because there is no caller to resolve.
1173
+ return [];
1174
+ }
1175
+ const orgId = currentOrgId() ?? undefined;
1176
+ const visibleIds = new Set<string>();
1177
+ const candidates: WorkspaceAppSummary[] = [];
1178
+ for (const app of apps) {
1179
+ if (app.status === "pending" || app.isDispatch) {
1180
+ visibleIds.add(app.id);
1181
+ continue;
1182
+ }
1183
+ candidates.push(app);
1184
+ }
1185
+
1186
+ for (
1187
+ let start = 0;
1188
+ start < candidates.length;
1189
+ start += WORKSPACE_APP_ACCESS_CONCURRENCY
1190
+ ) {
1191
+ const batch = candidates.slice(
1192
+ start,
1193
+ start + WORKSPACE_APP_ACCESS_CONCURRENCY,
1194
+ );
1195
+ const decisions = await Promise.all(
1196
+ batch.map(async (app) => {
1197
+ try {
1198
+ const access = await resolveAccess(
1199
+ "workspace-app",
1200
+ app.id,
1201
+ { userEmail, orgId },
1202
+ { skipResourceBody: true },
1203
+ );
1204
+ return { app, allowed: Boolean(access) };
1205
+ } catch (error) {
1206
+ // Rolling deployments may discover an app before the additive access
1207
+ // migrations have run. A missing schema is not an authorization
1208
+ // result: returning the candidate would expose private metadata
1209
+ // during a migration failure.
1210
+ console.warn("[dispatch] workspace app access lookup failed", error);
1211
+ return { app, allowed: false };
1212
+ }
1213
+ }),
1214
+ );
1215
+ for (const decision of decisions) {
1216
+ if (decision.allowed) visibleIds.add(decision.app.id);
1217
+ }
1218
+ }
1219
+
1220
+ return apps.filter((app) => visibleIds.has(app.id));
1221
+ }
1222
+
1005
1223
  async function recordPendingWorkspaceApp(input: {
1006
1224
  appId: string;
1007
1225
  projectId: string | null;
@@ -1054,6 +1272,7 @@ async function recordPendingWorkspaceApp(input: {
1054
1272
  generated: true,
1055
1273
  sourcePrompt: input.sourcePrompt,
1056
1274
  updatedBy: currentOwnerEmail(),
1275
+ createdBy: currentOwnerEmail(),
1057
1276
  });
1058
1277
 
1059
1278
  await recordAudit({
@@ -1413,12 +1632,15 @@ export async function updateWorkspaceAppMetadata(input: {
1413
1632
  export async function listWorkspaceApps(
1414
1633
  options: ListWorkspaceAppsOptions = {},
1415
1634
  ): Promise<WorkspaceAppSummary[]> {
1635
+ const finalize = async (apps: WorkspaceAppSummary[]) => {
1636
+ const annotated = await applyArchivedAndPending(apps, options);
1637
+ const recorded = await ensureWorkspaceAppRecords(annotated);
1638
+ const visible = await filterWorkspaceAppsByAccess(recorded);
1639
+ return maybeIncludeAgentCards(visible, options);
1640
+ };
1416
1641
  const gatewayApps = await readWorkspaceAppsFromGateway();
1417
1642
  if (gatewayApps) {
1418
- return maybeIncludeAgentCards(
1419
- await applyArchivedAndPending(gatewayApps, options),
1420
- options,
1421
- );
1643
+ return finalize(gatewayApps);
1422
1644
  }
1423
1645
 
1424
1646
  const workspaceRoot = findWorkspaceRoot();
@@ -1427,49 +1649,34 @@ export async function listWorkspaceApps(
1427
1649
  ? readWorkspaceAppsFromFilesystem(workspaceRoot)
1428
1650
  : null;
1429
1651
  if (localFilesystemApps) {
1430
- return maybeIncludeAgentCards(
1431
- await applyArchivedAndPending(localFilesystemApps, options),
1432
- options,
1433
- );
1652
+ return finalize(localFilesystemApps);
1434
1653
  }
1435
1654
 
1436
1655
  const manifestApps =
1437
1656
  readWorkspaceAppsFromEnv() ?? readWorkspaceAppsFromManifestFile();
1438
1657
  if (manifestApps) {
1439
- return maybeIncludeAgentCards(
1440
- await applyArchivedAndPending(manifestApps, options),
1441
- options,
1442
- );
1658
+ return finalize(manifestApps);
1443
1659
  }
1444
1660
 
1445
1661
  if (!workspaceRoot) {
1446
- return maybeIncludeAgentCards(
1447
- await applyArchivedAndPending(
1448
- [
1449
- {
1450
- id: "dispatch",
1451
- name: "Dispatch",
1452
- description: "Workspace control plane",
1453
- path: "/dispatch",
1454
- url: workspaceAppUrl("/dispatch"),
1455
- isDispatch: true,
1456
- audience: DEFAULT_WORKSPACE_APP_AUDIENCE,
1457
- publicPaths: [],
1458
- protectedPaths: [],
1459
- status: "ready",
1460
- },
1461
- ],
1462
- options,
1463
- ),
1464
- options,
1465
- );
1662
+ return finalize([
1663
+ {
1664
+ id: "dispatch",
1665
+ name: "Dispatch",
1666
+ description: "Workspace control plane",
1667
+ path: "/dispatch",
1668
+ url: workspaceAppUrl("/dispatch"),
1669
+ isDispatch: true,
1670
+ audience: DEFAULT_WORKSPACE_APP_AUDIENCE,
1671
+ publicPaths: [],
1672
+ protectedPaths: [],
1673
+ status: "ready",
1674
+ },
1675
+ ]);
1466
1676
  }
1467
1677
 
1468
1678
  const apps = readWorkspaceAppsFromFilesystem(workspaceRoot) ?? [];
1469
- return maybeIncludeAgentCards(
1470
- await applyArchivedAndPending(apps, options),
1471
- options,
1472
- );
1679
+ return finalize(apps);
1473
1680
  }
1474
1681
 
1475
1682
  /**
@@ -1644,6 +1851,22 @@ export async function scaffoldWorkspaceAppFromTemplate(input: {
1644
1851
  metadata: { template },
1645
1852
  });
1646
1853
 
1854
+ await ensureWorkspaceAppRecords([
1855
+ {
1856
+ id: appId,
1857
+ name: titleCase(appId),
1858
+ description: "",
1859
+ path: `/${appId}`,
1860
+ url: workspaceAppUrl(`/${appId}`),
1861
+ isDispatch: false,
1862
+ audience: DEFAULT_WORKSPACE_APP_AUDIENCE,
1863
+ publicPaths: [],
1864
+ protectedPaths: [],
1865
+ status: "ready",
1866
+ createdBy: currentOwnerEmail(),
1867
+ },
1868
+ ]);
1869
+
1647
1870
  return { appId, template, output };
1648
1871
  }
1649
1872