@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.js +8 -6
- package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts +148 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.interception.test.mjs +259 -4
- package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.mjs +429 -28
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/client.billing-usage.test.js +25 -0
- package/dist/client.billing-usage.test.js.map +1 -1
- package/dist/client.d.ts +19 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/collect-sdk-apis.d.mts +61 -3
- package/dist/collect-sdk-apis.d.mts.map +1 -1
- package/dist/collect-sdk-apis.mjs +97 -6
- package/dist/collect-sdk-apis.mjs.map +1 -1
- package/dist/collect-sdk-apis.test.mjs +277 -1
- package/dist/collect-sdk-apis.test.mjs.map +1 -1
- package/dist/dev-utils/dev-server-metrics.d.mts +17 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts +2 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs +108 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server-metrics.mjs +16 -2
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts +9 -0
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +44 -27
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/dev-utils/dev-server.status.test.mjs +31 -1
- package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
- package/dist/dev-utils/fatal-exit.d.mts +17 -4
- package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
- package/dist/dev-utils/fatal-exit.mjs +4 -3
- package/dist/dev-utils/fatal-exit.mjs.map +1 -1
- package/dist/extract-api-integrations.d.mts +1 -1
- package/dist/extract-api-integrations.d.mts.map +1 -1
- package/dist/extract-api-integrations.mjs +1 -1
- package/dist/extract-api-integrations.mjs.map +1 -1
- package/dist/flag.d.ts +5 -0
- package/dist/flag.d.ts.map +1 -1
- package/dist/flag.js +7 -0
- package/dist/flag.js.map +1 -1
- package/dist/flag.test.js +18 -0
- package/dist/flag.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types/common.d.ts +1 -0
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/common.js.map +1 -1
- package/dist/vite-plugin-generate-api-build-manifest.d.mts.map +1 -1
- package/dist/vite-plugin-generate-api-build-manifest.mjs +46 -0
- package/dist/vite-plugin-generate-api-build-manifest.mjs.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/automatic-upgrades.ts +8 -6
- package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
- package/src/cli-replacement/dev.interception.test.mts +334 -10
- package/src/cli-replacement/dev.mts +499 -33
- package/src/client.billing-usage.test.ts +26 -0
- package/src/client.ts +21 -0
- package/src/collect-sdk-apis.mts +141 -5
- package/src/collect-sdk-apis.test.mts +380 -1
- package/src/dev-utils/dev-server-metrics.dependency-failure.test.mts +149 -0
- package/src/dev-utils/dev-server-metrics.mts +25 -1
- package/src/dev-utils/dev-server.mts +52 -29
- package/src/dev-utils/dev-server.status.test.mts +42 -0
- package/src/dev-utils/fatal-exit.mts +18 -4
- package/src/extract-api-integrations.mts +3 -0
- package/src/flag.test.ts +21 -0
- package/src/flag.ts +8 -0
- package/src/index.ts +1 -0
- package/src/types/common.ts +1 -0
- package/src/vite-plugin-generate-api-build-manifest.mts +74 -4
- 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 {
|
package/src/collect-sdk-apis.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
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
|
-
* @
|
|
30
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
});
|