@superblocksteam/sdk 2.0.151 → 2.0.152-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +8 -6
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
  6. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  7. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
  8. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  9. package/dist/cli-replacement/dev.d.mts +148 -1
  10. package/dist/cli-replacement/dev.d.mts.map +1 -1
  11. package/dist/cli-replacement/dev.interception.test.mjs +259 -4
  12. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  13. package/dist/cli-replacement/dev.mjs +429 -28
  14. package/dist/cli-replacement/dev.mjs.map +1 -1
  15. package/dist/client.billing-usage.test.js +25 -0
  16. package/dist/client.billing-usage.test.js.map +1 -1
  17. package/dist/client.d.ts +19 -0
  18. package/dist/client.d.ts.map +1 -1
  19. package/dist/client.js.map +1 -1
  20. package/dist/collect-sdk-apis.d.mts +61 -3
  21. package/dist/collect-sdk-apis.d.mts.map +1 -1
  22. package/dist/collect-sdk-apis.mjs +97 -6
  23. package/dist/collect-sdk-apis.mjs.map +1 -1
  24. package/dist/collect-sdk-apis.test.mjs +277 -1
  25. package/dist/collect-sdk-apis.test.mjs.map +1 -1
  26. package/dist/dev-utils/dev-server-metrics.d.mts +17 -0
  27. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  28. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts +2 -0
  29. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts.map +1 -0
  30. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs +108 -0
  31. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs.map +1 -0
  32. package/dist/dev-utils/dev-server-metrics.mjs +16 -2
  33. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  34. package/dist/dev-utils/dev-server.d.mts +9 -0
  35. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  36. package/dist/dev-utils/dev-server.mjs +44 -27
  37. package/dist/dev-utils/dev-server.mjs.map +1 -1
  38. package/dist/dev-utils/dev-server.status.test.mjs +31 -1
  39. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
  40. package/dist/dev-utils/fatal-exit.d.mts +17 -4
  41. package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
  42. package/dist/dev-utils/fatal-exit.mjs +4 -3
  43. package/dist/dev-utils/fatal-exit.mjs.map +1 -1
  44. package/dist/extract-api-integrations.d.mts +1 -1
  45. package/dist/extract-api-integrations.d.mts.map +1 -1
  46. package/dist/extract-api-integrations.mjs +1 -1
  47. package/dist/extract-api-integrations.mjs.map +1 -1
  48. package/dist/flag.d.ts +5 -0
  49. package/dist/flag.d.ts.map +1 -1
  50. package/dist/flag.js +7 -0
  51. package/dist/flag.js.map +1 -1
  52. package/dist/flag.test.js +18 -0
  53. package/dist/flag.test.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/types/common.d.ts +1 -0
  58. package/dist/types/common.d.ts.map +1 -1
  59. package/dist/types/common.js.map +1 -1
  60. package/dist/vite-plugin-generate-api-build-manifest.d.mts.map +1 -1
  61. package/dist/vite-plugin-generate-api-build-manifest.mjs +46 -0
  62. package/dist/vite-plugin-generate-api-build-manifest.mjs.map +1 -1
  63. package/package.json +6 -6
  64. package/src/cli-replacement/automatic-upgrades.ts +8 -6
  65. package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
  66. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
  67. package/src/cli-replacement/dev.interception.test.mts +334 -10
  68. package/src/cli-replacement/dev.mts +499 -33
  69. package/src/client.billing-usage.test.ts +26 -0
  70. package/src/client.ts +21 -0
  71. package/src/collect-sdk-apis.mts +141 -5
  72. package/src/collect-sdk-apis.test.mts +380 -1
  73. package/src/dev-utils/dev-server-metrics.dependency-failure.test.mts +149 -0
  74. package/src/dev-utils/dev-server-metrics.mts +25 -1
  75. package/src/dev-utils/dev-server.mts +52 -29
  76. package/src/dev-utils/dev-server.status.test.mts +42 -0
  77. package/src/dev-utils/fatal-exit.mts +18 -4
  78. package/src/extract-api-integrations.mts +3 -0
  79. package/src/flag.test.ts +21 -0
  80. package/src/flag.ts +8 -0
  81. package/src/index.ts +1 -0
  82. package/src/types/common.ts +1 -0
  83. package/src/vite-plugin-generate-api-build-manifest.mts +74 -4
  84. package/tsconfig.tsbuildinfo +1 -1
package/src/client.ts CHANGED
@@ -2824,6 +2824,25 @@ export interface DollarLedgerRow {
2824
2824
  usageType: "ai_credits" | "deployed_app";
2825
2825
  }
2826
2826
 
2827
+ export interface UsageGroupRow {
2828
+ actorId: string | null;
2829
+ actorType: "ai_agent" | "user";
2830
+ applicationId: string | null;
2831
+ applicationName: string | null;
2832
+ /** Checkpoint (accepted AI change) this usage rolled up to; null = no
2833
+ * checkpoint. Absent entirely on servers that predate checkpoint mapping. */
2834
+ checkpointId?: string | null;
2835
+ /** Checkpoint commit message; null when the caller lacks application-update permission. */
2836
+ checkpointLabel: string | null;
2837
+ creditsUsed?: number;
2838
+ date: string;
2839
+ email: string | null;
2840
+ name: string | null;
2841
+ onDemandCreditsUsed?: number;
2842
+ recordedAt: string | null;
2843
+ unbilledCreditsUsed?: number;
2844
+ }
2845
+
2827
2846
  export interface BillingUsageRecordsResponse {
2828
2847
  agentUsage?: AgentUsageRow[];
2829
2848
  applicationUsageRows?: ApplicationUsageRow[];
@@ -2832,6 +2851,8 @@ export interface BillingUsageRecordsResponse {
2832
2851
  enterpriseBillingWindows?: unknown[];
2833
2852
  rows: UsageRecordRow[];
2834
2853
  seatAssignments?: unknown[];
2854
+ // undefined = server predates checkpoint rollups; [] = rollups supported, no usage in range.
2855
+ usageGroupRows?: UsageGroupRow[];
2835
2856
  }
2836
2857
 
2837
2858
  export interface UserCreditLimit {
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import {
4
- extractIntegrationsFromSource,
4
+ extractApiIntegrations,
5
5
  type IntegrationInfo,
6
6
  } from "./extract-api-integrations.mjs";
7
7
  import { parseSdkRegistryWithExportNames } from "./parse-sdk-registry.mjs";
@@ -10,6 +10,20 @@ type SdkApiEntry = {
10
10
  entryPoint: string;
11
11
  exportName?: string;
12
12
  integrations?: IntegrationInfo[];
13
+ unresolvedIntegrations?: string[];
14
+ };
15
+
16
+ export type CollectSdkApisOptions = {
17
+ onUnresolvedIntegrations?: (
18
+ apiName: string,
19
+ entryPoint: string,
20
+ unresolvedKeys: string[],
21
+ ) => void;
22
+ onExtractionError?: (
23
+ apiName: string,
24
+ entryPoint: string,
25
+ error: unknown,
26
+ ) => void;
13
27
  };
14
28
 
15
29
  type SdkApisResult = Record<string, SdkApiEntry>;
@@ -17,8 +31,99 @@ type SdkApisResult = Record<string, SdkApiEntry>;
17
31
  type CollectSdkApisFs = {
18
32
  pathExists: (p: string) => Promise<boolean>;
19
33
  readFile: (p: string, encoding: "utf-8") => Promise<string>;
34
+ /**
35
+ * Used to canonicalize symlinks for app-root containment checks. An
36
+ * in-root path can lexically pass `resolveContainedPath` while being a
37
+ * symlink whose real target lives outside `root`; when `realpath` is
38
+ * provided, `resolveContainedRealPath` re-verifies containment against
39
+ * the canonicalized (symlink-resolved) path before it is read. When
40
+ * omitted, containment falls back to the lexical check only.
41
+ */
42
+ realpath?: (p: string) => Promise<string>;
20
43
  };
21
44
 
45
+ /**
46
+ * Resolves `relativePath` against `root` and returns the resolved absolute
47
+ * path only if it stays contained within `root`; returns `undefined`
48
+ * otherwise (including for a path that resolves to exactly `root` plus a
49
+ * differently-cased or malformed suffix — only exact containment counts).
50
+ *
51
+ * Defense in depth against path traversal: the extractor
52
+ * (extract-api-integrations.ts) already filters `..`-prefixed candidates
53
+ * before ever proposing them, but that filtering is posix-only. `pathImpl`
54
+ * defaults to the real `node:path` (win32 semantics on Windows, posix
55
+ * elsewhere), so this re-verifies containment using whichever path
56
+ * semantics actually apply at runtime — a `relativePath` the extractor
57
+ * considered safe on POSIX could still escape `root` here on Windows, where
58
+ * backslashes are separators. Also protects against any future extractor
59
+ * change that stops filtering candidates. `pathImpl` is overridable so
60
+ * win32 containment semantics can be exercised directly in tests on a POSIX
61
+ * runner, independent of the host OS.
62
+ */
63
+ export function resolveContainedPath(
64
+ root: string,
65
+ relativePath: string,
66
+ pathImpl: typeof path = path,
67
+ ): string | undefined {
68
+ const resolvedRoot = pathImpl.resolve(root);
69
+ const resolved = pathImpl.resolve(root, relativePath);
70
+ if (
71
+ resolved !== resolvedRoot &&
72
+ !resolved.startsWith(resolvedRoot + pathImpl.sep)
73
+ ) {
74
+ return undefined;
75
+ }
76
+ return resolved;
77
+ }
78
+
79
+ /**
80
+ * Like `resolveContainedPath`, but also canonicalizes symlinks before
81
+ * asserting containment, so an in-root path that is a symlink pointing
82
+ * outside `root` is rejected rather than silently followed.
83
+ *
84
+ * First runs the cheap lexical `resolveContainedPath` check; if that already
85
+ * fails, returns `undefined` without touching the filesystem. If
86
+ * `deps.realpath` is not provided, returns the lexical result unchanged —
87
+ * containment falls back to the lexical check only (documented fallback,
88
+ * e.g. for callers/tests that don't need symlink defense). If `realpath` is
89
+ * provided, both `root` and the lexically-resolved path are canonicalized
90
+ * and the same containment check is re-run against the canonical values;
91
+ * the canonical resolved path is returned only if it is still contained.
92
+ * If `realpath` throws (e.g. the path does not exist), returns `undefined`
93
+ * rather than throwing.
94
+ */
95
+ export async function resolveContainedRealPath(
96
+ root: string,
97
+ relativePath: string,
98
+ deps: {
99
+ realpath?: (p: string) => Promise<string>;
100
+ pathImpl?: typeof path;
101
+ },
102
+ ): Promise<string | undefined> {
103
+ const pathImpl = deps.pathImpl ?? path;
104
+ const lexicallyResolved = resolveContainedPath(root, relativePath, pathImpl);
105
+ if (!lexicallyResolved) {
106
+ return undefined;
107
+ }
108
+ if (!deps.realpath) {
109
+ return lexicallyResolved;
110
+ }
111
+
112
+ try {
113
+ const canonicalRoot = await deps.realpath(pathImpl.resolve(root));
114
+ const canonicalResolved = await deps.realpath(lexicallyResolved);
115
+ if (
116
+ canonicalResolved !== canonicalRoot &&
117
+ !canonicalResolved.startsWith(canonicalRoot + pathImpl.sep)
118
+ ) {
119
+ return undefined;
120
+ }
121
+ return canonicalResolved;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
22
127
  /**
23
128
  * Collect SDK API entry points and declared integrations from the app's registry
24
129
  * at server/apis/index.ts. Keys match Object.keys(registry.default) so
@@ -26,12 +131,17 @@ type CollectSdkApisFs = {
26
131
  *
27
132
  * @param root - App root directory
28
133
  * @param fs - File system operations (pathExists, readFile)
29
- * @returns Map of API name { entryPoint, exportName?, integrations? } or
30
- * empty object if no registry exists
134
+ * @param options - Optional callbacks: onUnresolvedIntegrations for
135
+ * reporting integration keys that could not be resolved to a plugin/id, and
136
+ * onExtractionError for reporting when a registered API's source file could
137
+ * not be read or parsed (the entry point is still emitted bare in that case).
138
+ * @returns Map of API name → { entryPoint, exportName?, integrations?,
139
+ * unresolvedIntegrations? } or empty object if no registry exists
31
140
  */
32
141
  export async function collectSdkApisFromRegistry(
33
142
  root: string,
34
143
  fs: CollectSdkApisFs,
144
+ options?: CollectSdkApisOptions,
35
145
  ): Promise<SdkApisResult> {
36
146
  const sdkRegistryPath = path.join(root, "server/apis/index.ts");
37
147
  if (!(await fs.pathExists(sdkRegistryPath))) {
@@ -57,13 +167,39 @@ export async function collectSdkApisFromRegistry(
57
167
 
58
168
  try {
59
169
  const source = await fs.readFile(absolutePath, "utf-8");
60
- const integrations = extractIntegrationsFromSource(source);
170
+ const { integrations, unresolvedKeys } = await extractApiIntegrations({
171
+ source,
172
+ entryPoint: registryEntry.entryPoint,
173
+ readSource: async (relativePath) => {
174
+ const resolved = await resolveContainedRealPath(root, relativePath, {
175
+ realpath: fs.realpath,
176
+ });
177
+ if (!resolved) {
178
+ return undefined;
179
+ }
180
+ // realpath (above) already requires the path to exist, so this is
181
+ // a harmless backstop rather than the primary existence check.
182
+ if (!(await fs.pathExists(resolved))) {
183
+ return undefined;
184
+ }
185
+ return fs.readFile(resolved, "utf-8");
186
+ },
187
+ });
61
188
  if (integrations.length > 0) {
62
189
  entry.integrations = integrations;
63
190
  }
64
- } catch {
191
+ if (unresolvedKeys.length > 0) {
192
+ entry.unresolvedIntegrations = unresolvedKeys;
193
+ options?.onUnresolvedIntegrations?.(
194
+ apiName,
195
+ registryEntry.entryPoint,
196
+ unresolvedKeys,
197
+ );
198
+ }
199
+ } catch (error) {
65
200
  // Integration extraction is best-effort. If a source file cannot be read,
66
201
  // still emit the entry point so SDK execution continues to work.
202
+ options?.onExtractionError?.(apiName, registryEntry.entryPoint, error);
67
203
  }
68
204
 
69
205
  sdkApis[apiName] = entry;
@@ -2,7 +2,138 @@ import path from "node:path";
2
2
 
3
3
  import { describe, it, expect, vi } from "vitest";
4
4
 
5
- import { collectSdkApisFromRegistry } from "./collect-sdk-apis.mjs";
5
+ import {
6
+ collectSdkApisFromRegistry,
7
+ resolveContainedPath,
8
+ resolveContainedRealPath,
9
+ } from "./collect-sdk-apis.mjs";
10
+
11
+ describe("resolveContainedPath", () => {
12
+ it("returns undefined for a win32 relative path that traverses above root", () => {
13
+ // This genuinely exercises win32 path semantics (backslash separators)
14
+ // on a POSIX test runner by passing path.win32 explicitly, rather than
15
+ // relying on the host OS's real path module.
16
+ const result = resolveContainedPath(
17
+ "C:\\app",
18
+ "server\\apis\\..\\..\\..\\secret.ts",
19
+ path.win32,
20
+ );
21
+
22
+ expect(result).toBeUndefined();
23
+ });
24
+
25
+ it("returns the resolved absolute path for a win32 relative path that stays within root", () => {
26
+ const result = resolveContainedPath(
27
+ "C:\\app",
28
+ "server\\apis\\index.ts",
29
+ path.win32,
30
+ );
31
+
32
+ expect(result).toBe(
33
+ path.win32.resolve("C:\\app", "server\\apis\\index.ts"),
34
+ );
35
+ });
36
+
37
+ it("returns the resolved absolute path for a posix relative path that stays within root", () => {
38
+ const result = resolveContainedPath(
39
+ "/app",
40
+ "server/apis/index.ts",
41
+ path.posix,
42
+ );
43
+
44
+ expect(result).toBe(path.posix.resolve("/app", "server/apis/index.ts"));
45
+ });
46
+
47
+ it("returns undefined for a posix relative path that escapes root", () => {
48
+ const result = resolveContainedPath("/app", "../secret.ts", path.posix);
49
+
50
+ expect(result).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe("resolveContainedRealPath", () => {
55
+ it("returns undefined when an in-root symlink canonicalizes outside root", async () => {
56
+ const realpathMap: Record<string, string> = {
57
+ "/app": "/app",
58
+ "/app/server/apis/evil.ts": "/etc/passwd",
59
+ };
60
+ const realpath = async (p: string) => {
61
+ const canonical = realpathMap[p];
62
+ if (canonical === undefined) {
63
+ throw new Error(`ENOENT: no such file, realpath '${p}'`);
64
+ }
65
+ return canonical;
66
+ };
67
+
68
+ const result = await resolveContainedRealPath(
69
+ "/app",
70
+ "server/apis/evil.ts",
71
+ { realpath, pathImpl: path.posix },
72
+ );
73
+
74
+ expect(result).toBeUndefined();
75
+ });
76
+
77
+ it("returns the canonical path when an in-root symlink stays within root", async () => {
78
+ const realpathMap: Record<string, string> = {
79
+ "/app": "/app",
80
+ "/app/server/apis/link.ts": "/app/server/apis/real.ts",
81
+ };
82
+ const realpath = async (p: string) => {
83
+ const canonical = realpathMap[p];
84
+ if (canonical === undefined) {
85
+ throw new Error(`ENOENT: no such file, realpath '${p}'`);
86
+ }
87
+ return canonical;
88
+ };
89
+
90
+ const result = await resolveContainedRealPath(
91
+ "/app",
92
+ "server/apis/link.ts",
93
+ { realpath, pathImpl: path.posix },
94
+ );
95
+
96
+ expect(result).toBe("/app/server/apis/real.ts");
97
+ });
98
+
99
+ it("falls back to the lexical result unchanged when no realpath is provided", async () => {
100
+ const result = await resolveContainedRealPath(
101
+ "/app",
102
+ "server/apis/index.ts",
103
+ { pathImpl: path.posix },
104
+ );
105
+
106
+ expect(result).toBe(
107
+ resolveContainedPath("/app", "server/apis/index.ts", path.posix),
108
+ );
109
+ });
110
+
111
+ it("returns undefined when realpath throws (e.g. missing file)", async () => {
112
+ const realpath = async () => {
113
+ throw new Error("ENOENT: no such file, realpath");
114
+ };
115
+
116
+ const result = await resolveContainedRealPath(
117
+ "/app",
118
+ "server/apis/missing.ts",
119
+ { realpath, pathImpl: path.posix },
120
+ );
121
+
122
+ expect(result).toBeUndefined();
123
+ });
124
+
125
+ it("returns undefined when the lexical check itself fails (never calls realpath)", async () => {
126
+ const realpath = vi.fn();
127
+
128
+ const result = await resolveContainedRealPath("/app", "../secret.ts", {
129
+ realpath,
130
+ pathImpl: path.posix,
131
+ });
132
+
133
+ expect(result).toBeUndefined();
134
+ expect(realpath).not.toHaveBeenCalled();
135
+ });
136
+ });
6
137
 
7
138
  describe("collectSdkApisFromRegistry", () => {
8
139
  const root = "/app";
@@ -165,6 +296,37 @@ export default { GetUsers };
165
296
  });
166
297
  });
167
298
 
299
+ it("emits a bare entry and invokes onExtractionError when the API file read throws", async () => {
300
+ const registryContent = `
301
+ import GetUsers from './GetUsers/api.js';
302
+ export default { GetUsers };
303
+ `;
304
+ const readError = new Error("source unreadable");
305
+ const readFile = vi.fn(async (filePath: string) => {
306
+ const rel = path.relative(root, filePath).replace(/\\/g, "/");
307
+ if (rel === "server/apis/index.ts") {
308
+ return registryContent;
309
+ }
310
+ throw readError;
311
+ });
312
+ const onExtractionError = vi.fn();
313
+
314
+ const result = await collectSdkApisFromRegistry(
315
+ root,
316
+ { pathExists, readFile },
317
+ { onExtractionError },
318
+ );
319
+
320
+ expect(result).toEqual({
321
+ GetUsers: { entryPoint: "server/apis/GetUsers/api.ts" },
322
+ });
323
+ expect(onExtractionError).toHaveBeenCalledWith(
324
+ "GetUsers",
325
+ "server/apis/GetUsers/api.ts",
326
+ readError,
327
+ );
328
+ });
329
+
168
330
  it("reads from server/apis/index.ts relative to root", async () => {
169
331
  const readFile = vi
170
332
  .fn()
@@ -182,4 +344,221 @@ export default { GetUsers };
182
344
  "utf-8",
183
345
  );
184
346
  });
347
+
348
+ it("resolves integrations imported from shared modules into the manifest", async () => {
349
+ const files: Record<string, string> = {
350
+ "server/apis/index.ts": `
351
+ import CreateIssue from "./create-issue.js";
352
+ const apis = { CreateIssue } as const;
353
+ export default apis;
354
+ `,
355
+ "server/apis/create-issue.ts": `
356
+ import { api } from "@superblocksteam/sdk-api";
357
+ import { jira } from "../lib/integrations.js";
358
+ export default api({ integrations: { jira } });
359
+ `,
360
+ "server/lib/integrations.ts": `
361
+ import { restApiIntegration } from "@superblocksteam/sdk-api";
362
+ export const jira = restApiIntegration("jira-uuid");
363
+ `,
364
+ };
365
+ const rel = (p: string) => path.relative(root, p).replace(/\\/g, "/");
366
+
367
+ const result = await collectSdkApisFromRegistry(root, {
368
+ pathExists: async (p) => rel(p) in files,
369
+ readFile: async (p) => {
370
+ const source = files[rel(p)];
371
+ if (source === undefined) {
372
+ throw new Error(`missing: ${p}`);
373
+ }
374
+ return source;
375
+ },
376
+ });
377
+
378
+ expect(result.CreateIssue.integrations).toEqual([
379
+ { key: "jira", pluginId: "restApiIntegration", id: "jira-uuid" },
380
+ ]);
381
+ expect(result.CreateIssue.unresolvedIntegrations).toBeUndefined();
382
+ });
383
+
384
+ it("records unresolved integrations and reports them through the callback", async () => {
385
+ const files: Record<string, string> = {
386
+ "server/apis/index.ts": `
387
+ import CreateIssue from "./create-issue.js";
388
+ const apis = { CreateIssue } as const;
389
+ export default apis;
390
+ `,
391
+ "server/apis/create-issue.ts": `
392
+ import { api } from "@superblocksteam/sdk-api";
393
+ import { jira } from "missing-package";
394
+ export default api({ integrations: { jira } });
395
+ `,
396
+ };
397
+ const rel = (p: string) => path.relative(root, p).replace(/\\/g, "/");
398
+ const onUnresolvedIntegrations = vi.fn();
399
+
400
+ const result = await collectSdkApisFromRegistry(
401
+ root,
402
+ {
403
+ pathExists: async (p) => rel(p) in files,
404
+ readFile: async (p) => {
405
+ const source = files[rel(p)];
406
+ if (source === undefined) {
407
+ throw new Error(`missing: ${p}`);
408
+ }
409
+ return source;
410
+ },
411
+ },
412
+ { onUnresolvedIntegrations },
413
+ );
414
+
415
+ expect(result.CreateIssue.integrations).toBeUndefined();
416
+ expect(result.CreateIssue.unresolvedIntegrations).toEqual(["jira"]);
417
+ expect(onUnresolvedIntegrations).toHaveBeenCalledWith(
418
+ "CreateIssue",
419
+ "server/apis/create-issue.ts",
420
+ ["jira"],
421
+ );
422
+ });
423
+
424
+ it("marks the entry as unresolved and reports it when the API file source fails to parse", async () => {
425
+ // A parse failure on the entry file itself (e.g. parser-config drift —
426
+ // syntax the real build compiles but our @babel/parser config rejects)
427
+ // must not recreate the silent check-auth gap: the entry should carry
428
+ // unresolvedIntegrations: ["*"] and onUnresolvedIntegrations must fire,
429
+ // exactly as for any other unresolvable integrations surface.
430
+ const files: Record<string, string> = {
431
+ "server/apis/index.ts": `
432
+ import CreateIssue from "./create-issue.js";
433
+ const apis = { CreateIssue } as const;
434
+ export default apis;
435
+ `,
436
+ "server/apis/create-issue.ts": `export default api({ integrations: {`,
437
+ };
438
+ const rel = (p: string) => path.relative(root, p).replace(/\\/g, "/");
439
+ const onUnresolvedIntegrations = vi.fn();
440
+ const onExtractionError = vi.fn();
441
+
442
+ const result = await collectSdkApisFromRegistry(
443
+ root,
444
+ {
445
+ pathExists: async (p) => rel(p) in files,
446
+ readFile: async (p) => {
447
+ const source = files[rel(p)];
448
+ if (source === undefined) {
449
+ throw new Error(`missing: ${p}`);
450
+ }
451
+ return source;
452
+ },
453
+ },
454
+ { onUnresolvedIntegrations, onExtractionError },
455
+ );
456
+
457
+ expect(result.CreateIssue).toEqual({
458
+ entryPoint: "server/apis/create-issue.ts",
459
+ unresolvedIntegrations: ["*"],
460
+ });
461
+ expect(onUnresolvedIntegrations).toHaveBeenCalledWith(
462
+ "CreateIssue",
463
+ "server/apis/create-issue.ts",
464
+ ["*"],
465
+ );
466
+ expect(onExtractionError).not.toHaveBeenCalled();
467
+ });
468
+
469
+ it("never queries the filesystem outside root for a backslash import specifier", async () => {
470
+ // Backslash import specifiers are a known Windows path-traversal vector:
471
+ // the extractor's posix-only containment check doesn't catch them, but
472
+ // the collector's own readSource containment guard (defense in depth)
473
+ // must still refuse to touch anything outside root. `\\\\` is needed
474
+ // (not `\\`) so a literal single backslash survives being unescaped
475
+ // twice: once by this outer template literal, once by the parser that
476
+ // reads the fixture source under test.
477
+ const files: Record<string, string> = {
478
+ "server/apis/index.ts": `
479
+ import Leak from './leak.js';
480
+ const apis = { Leak } as const;
481
+ export default apis;
482
+ `,
483
+ "server/apis/leak.ts": `
484
+ import { api, restApiIntegration } from "@superblocksteam/sdk-api";
485
+ import { SECRET } from "..\\\\..\\\\..\\\\secret.js";
486
+ export default api({ integrations: { leak: restApiIntegration(SECRET) } });
487
+ `,
488
+ };
489
+ const rel = (p: string) => path.relative(root, p).replace(/\\/g, "/");
490
+ const pathExists = vi.fn(async (p: string) => rel(p) in files);
491
+
492
+ const result = await collectSdkApisFromRegistry(root, {
493
+ pathExists,
494
+ readFile: async (p) => {
495
+ const source = files[rel(p)];
496
+ if (source === undefined) {
497
+ throw new Error(`missing: ${p}`);
498
+ }
499
+ return source;
500
+ },
501
+ });
502
+
503
+ expect(result.Leak.integrations).toBeUndefined();
504
+ expect(result.Leak.unresolvedIntegrations).toEqual(["leak"]);
505
+ // The extractor must never hand the collector a candidate derived from
506
+ // the backslash specifier in the first place (that candidate would
507
+ // itself still contain a backslash once joined). This is what actually
508
+ // distinguishes fixed from unfixed here: on a POSIX test runner,
509
+ // path.join treats a stray backslash as an ordinary filename character,
510
+ // so a resolved-outside-root assertion alone would pass even without
511
+ // either fix.
512
+ for (const call of pathExists.mock.calls) {
513
+ expect(call[0] as string).not.toContain("\\");
514
+ }
515
+ });
516
+
517
+ it("does not read a file whose canonical (real) path escapes root, even though the lexical path is in-root", async () => {
518
+ // server/lib/integrations.ts exists lexically inside root (it passes
519
+ // pathExists and the lexical containment check), but it is a symlink
520
+ // whose canonical target resolves outside root. Without realpath
521
+ // canonicalization, readSource would happily read through the symlink.
522
+ const files: Record<string, string> = {
523
+ "server/apis/index.ts": `
524
+ import CreateIssue from "./create-issue.js";
525
+ const apis = { CreateIssue } as const;
526
+ export default apis;
527
+ `,
528
+ "server/apis/create-issue.ts": `
529
+ import { api } from "@superblocksteam/sdk-api";
530
+ import { jira } from "../lib/integrations.js";
531
+ export default api({ integrations: { jira } });
532
+ `,
533
+ "server/lib/integrations.ts": `
534
+ import { restApiIntegration } from "@superblocksteam/sdk-api";
535
+ export const jira = restApiIntegration("jira-uuid");
536
+ `,
537
+ };
538
+ const rel = (p: string) => path.relative(root, p).replace(/\\/g, "/");
539
+ const escapingRealPath = "/etc/evil-integrations.ts";
540
+ const escapingLexicalPath = path.join(root, "server/lib/integrations.ts");
541
+
542
+ const readFile = vi.fn(async (p: string) => {
543
+ const source = files[rel(p)];
544
+ if (source === undefined) {
545
+ throw new Error(`missing: ${p}`);
546
+ }
547
+ return source;
548
+ });
549
+ const realpath = vi.fn(async (p: string) =>
550
+ p === escapingLexicalPath ? escapingRealPath : p,
551
+ );
552
+
553
+ const result = await collectSdkApisFromRegistry(root, {
554
+ pathExists: async (p) => rel(p) in files,
555
+ readFile,
556
+ realpath,
557
+ });
558
+
559
+ expect(result.CreateIssue.integrations).toBeUndefined();
560
+ expect(result.CreateIssue.unresolvedIntegrations).toEqual(["jira"]);
561
+ expect(readFile).not.toHaveBeenCalledWith(escapingRealPath, "utf-8");
562
+ expect(readFile).not.toHaveBeenCalledWith(escapingLexicalPath, "utf-8");
563
+ });
185
564
  });