@nimbus-dev/sdk 1.1.2
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/LICENSE +21 -0
- package/README.md +34 -0
- package/dist/audit-logger.d.ts +6 -0
- package/dist/audit-logger.d.ts.map +1 -0
- package/dist/audit-logger.js +18 -0
- package/dist/audit-logger.js.map +1 -0
- package/dist/contract-tests.d.ts +45 -0
- package/dist/contract-tests.d.ts.map +1 -0
- package/dist/contract-tests.js +191 -0
- package/dist/contract-tests.js.map +1 -0
- package/dist/crypto/app-store-connect-jwt.d.ts +19 -0
- package/dist/crypto/app-store-connect-jwt.d.ts.map +1 -0
- package/dist/crypto/app-store-connect-jwt.js +30 -0
- package/dist/crypto/app-store-connect-jwt.js.map +1 -0
- package/dist/crypto/canonical-json.d.ts +36 -0
- package/dist/crypto/canonical-json.d.ts.map +1 -0
- package/dist/crypto/canonical-json.js +75 -0
- package/dist/crypto/canonical-json.js.map +1 -0
- package/dist/crypto/jwt.d.ts +30 -0
- package/dist/crypto/jwt.d.ts.map +1 -0
- package/dist/crypto/jwt.js +30 -0
- package/dist/crypto/jwt.js.map +1 -0
- package/dist/crypto/service-account-token.d.ts +36 -0
- package/dist/crypto/service-account-token.d.ts.map +1 -0
- package/dist/crypto/service-account-token.js +96 -0
- package/dist/crypto/service-account-token.js.map +1 -0
- package/dist/crypto/verify-signature.d.ts +57 -0
- package/dist/crypto/verify-signature.d.ts.map +1 -0
- package/dist/crypto/verify-signature.js +102 -0
- package/dist/crypto/verify-signature.js.map +1 -0
- package/dist/distribution-channel.d.ts +34 -0
- package/dist/distribution-channel.d.ts.map +1 -0
- package/dist/distribution-channel.js +73 -0
- package/dist/distribution-channel.js.map +1 -0
- package/dist/hitl-request.d.ts +7 -0
- package/dist/hitl-request.d.ts.map +1 -0
- package/dist/hitl-request.js +15 -0
- package/dist/hitl-request.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/index.d.ts +2 -0
- package/dist/ipc/index.d.ts.map +1 -0
- package/dist/ipc/index.js +2 -0
- package/dist/ipc/index.js.map +1 -0
- package/dist/ipc/ndjson-line-reader.d.ts +20 -0
- package/dist/ipc/ndjson-line-reader.d.ts.map +1 -0
- package/dist/ipc/ndjson-line-reader.js +56 -0
- package/dist/ipc/ndjson-line-reader.js.map +1 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +23 -0
- package/dist/server.js.map +1 -0
- package/dist/testing/index.d.ts +15 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +17 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/sandbox-contract.d.ts +83 -0
- package/dist/testing/sandbox-contract.d.ts.map +1 -0
- package/dist/testing/sandbox-contract.js +105 -0
- package/dist/testing/sandbox-contract.js.map +1 -0
- package/dist/testing/sandbox-probe.d.ts +23 -0
- package/dist/testing/sandbox-probe.d.ts.map +1 -0
- package/dist/testing/sandbox-probe.js +78 -0
- package/dist/testing/sandbox-probe.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/audit-logger.test.ts +33 -0
- package/src/audit-logger.ts +23 -0
- package/src/contract-tests.test.ts +203 -0
- package/src/contract-tests.ts +220 -0
- package/src/crypto/app-store-connect-jwt.test.ts +80 -0
- package/src/crypto/app-store-connect-jwt.ts +42 -0
- package/src/crypto/canonical-json.test.ts +121 -0
- package/src/crypto/canonical-json.ts +73 -0
- package/src/crypto/jwt.test.ts +62 -0
- package/src/crypto/jwt.ts +45 -0
- package/src/crypto/service-account-token.test.ts +128 -0
- package/src/crypto/service-account-token.ts +116 -0
- package/src/crypto/verify-signature.test.ts +118 -0
- package/src/crypto/verify-signature.ts +138 -0
- package/src/distribution-channel.test.ts +107 -0
- package/src/distribution-channel.ts +105 -0
- package/src/hitl-request.ts +22 -0
- package/src/index.ts +59 -0
- package/src/ipc/index.ts +5 -0
- package/src/ipc/ndjson-line-reader.test.ts +64 -0
- package/src/ipc/ndjson-line-reader.ts +70 -0
- package/src/plugin-api-v1.test.ts +50 -0
- package/src/sdk.test.ts +23 -0
- package/src/server.test.ts +96 -0
- package/src/server.ts +39 -0
- package/src/testing/index.ts +18 -0
- package/src/testing/sandbox-contract.test.ts +146 -0
- package/src/testing/sandbox-contract.ts +155 -0
- package/src/testing/sandbox-probe.ts +87 -0
- package/src/types.ts +42 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertNoRowDataTools,
|
|
5
|
+
ExtensionContractError,
|
|
6
|
+
ROW_DATA_TOOL_SEGMENTS,
|
|
7
|
+
runContractTests,
|
|
8
|
+
} from "./contract-tests.ts";
|
|
9
|
+
import type { ExtensionManifest } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const base = (): ExtensionManifest => ({
|
|
12
|
+
id: "demo.ext",
|
|
13
|
+
displayName: "Demo",
|
|
14
|
+
version: "0.1.0",
|
|
15
|
+
description: "Demo extension",
|
|
16
|
+
author: "nimbus",
|
|
17
|
+
entrypoint: "dist/index.js",
|
|
18
|
+
runtime: "bun",
|
|
19
|
+
permissions: ["read"],
|
|
20
|
+
hitlRequired: [],
|
|
21
|
+
minNimbusVersion: "0.1.0",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("runContractTests", () => {
|
|
25
|
+
test("accepts a minimal valid manifest", async () => {
|
|
26
|
+
await expect(runContractTests(base())).resolves.toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects invalid permission", async () => {
|
|
30
|
+
const m = base();
|
|
31
|
+
m.permissions = ["read", "admin"] as ExtensionManifest["permissions"];
|
|
32
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("runContractTests — v1 additions", () => {
|
|
37
|
+
test("v1 contract passes against a minimal extension manifest", async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
runContractTests({
|
|
40
|
+
id: "ext.v1-smoke",
|
|
41
|
+
displayName: "V1 Smoke",
|
|
42
|
+
version: "0.1.0",
|
|
43
|
+
description: "Smoke test extension",
|
|
44
|
+
author: "Nimbus",
|
|
45
|
+
entrypoint: "index.ts",
|
|
46
|
+
runtime: "bun",
|
|
47
|
+
permissions: [],
|
|
48
|
+
hitlRequired: [],
|
|
49
|
+
minNimbusVersion: "0.1.0",
|
|
50
|
+
}),
|
|
51
|
+
).resolves.toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("runContractTests — missing required fields", () => {
|
|
56
|
+
test("rejects manifest with empty id", async () => {
|
|
57
|
+
const m = base();
|
|
58
|
+
m.id = "";
|
|
59
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects manifest with empty displayName", async () => {
|
|
63
|
+
const m = base();
|
|
64
|
+
m.displayName = "";
|
|
65
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("rejects manifest with empty version", async () => {
|
|
69
|
+
const m = base();
|
|
70
|
+
m.version = "";
|
|
71
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("rejects manifest with empty description", async () => {
|
|
75
|
+
const m = base();
|
|
76
|
+
m.description = "";
|
|
77
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejects manifest with empty author", async () => {
|
|
81
|
+
const m = base();
|
|
82
|
+
m.author = "";
|
|
83
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects manifest with empty entrypoint", async () => {
|
|
87
|
+
const m = base();
|
|
88
|
+
m.entrypoint = "";
|
|
89
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("runContractTests — runtime validation", () => {
|
|
94
|
+
test("rejects manifest with unsupported runtime", async () => {
|
|
95
|
+
const m = base();
|
|
96
|
+
m.runtime = "deno" as ExtensionManifest["runtime"];
|
|
97
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("runContractTests — permissions validation", () => {
|
|
102
|
+
test("rejects manifest when permissions is not an array", async () => {
|
|
103
|
+
const m = base();
|
|
104
|
+
m.permissions = "read" as unknown as ExtensionManifest["permissions"];
|
|
105
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("runContractTests — hitlRequired validation", () => {
|
|
110
|
+
test("rejects manifest when hitlRequired is not an array", async () => {
|
|
111
|
+
const m = base();
|
|
112
|
+
m.hitlRequired = "write" as unknown as ExtensionManifest["hitlRequired"];
|
|
113
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("rejects manifest with invalid hitlRequired entry", async () => {
|
|
117
|
+
const m = base();
|
|
118
|
+
m.hitlRequired = ["admin"] as unknown as ExtensionManifest["hitlRequired"];
|
|
119
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("runContractTests — minNimbusVersion validation", () => {
|
|
124
|
+
test("rejects manifest with empty minNimbusVersion", async () => {
|
|
125
|
+
const m = base();
|
|
126
|
+
m.minNimbusVersion = "";
|
|
127
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rejects manifest with non-semver minNimbusVersion", async () => {
|
|
131
|
+
const m = base();
|
|
132
|
+
m.minNimbusVersion = "latest";
|
|
133
|
+
await expect(runContractTests(m)).rejects.toBeInstanceOf(ExtensionContractError);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("assertNoRowDataTools — Tier-3 no-row-data contract", () => {
|
|
138
|
+
test("accepts a metadata-only warehouse surface", () => {
|
|
139
|
+
expect(() =>
|
|
140
|
+
assertNoRowDataTools([
|
|
141
|
+
{ name: "bigquery_list" },
|
|
142
|
+
{ name: "bigquery_get" },
|
|
143
|
+
{ name: "bigquery_search" },
|
|
144
|
+
{ name: "bigquery_list_datasets" },
|
|
145
|
+
{ name: "bigquery_get_table_schema" },
|
|
146
|
+
{ name: "athena_list_databases" },
|
|
147
|
+
{ name: "cloudwatch_list_log_groups" },
|
|
148
|
+
{ name: "vertexai_get_model" },
|
|
149
|
+
{ name: "great_expectations_list_suites" },
|
|
150
|
+
]),
|
|
151
|
+
).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("does NOT false-positive on the 'bigquery' service prefix (single token, not 'query')", () => {
|
|
155
|
+
expect(() => assertNoRowDataTools([{ name: "bigquery_get" }])).not.toThrow();
|
|
156
|
+
// The denylisted segment IS present as its own token, though:
|
|
157
|
+
expect(ROW_DATA_TOOL_SEGMENTS.has("query")).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("accepts an empty tool surface", () => {
|
|
161
|
+
expect(() => assertNoRowDataTools([])).not.toThrow();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test.each([
|
|
165
|
+
"bigquery_run_query",
|
|
166
|
+
"bigquery_get_rows",
|
|
167
|
+
"athena_query_results",
|
|
168
|
+
"dynamodb_scan",
|
|
169
|
+
"bigquery_head",
|
|
170
|
+
"bigquery_preview_rows",
|
|
171
|
+
"warehouse_sample",
|
|
172
|
+
"table_select",
|
|
173
|
+
"cloudwatch_get_log_records",
|
|
174
|
+
"cloudwatch_get_log_events",
|
|
175
|
+
"cloudwatch_filter_log_events",
|
|
176
|
+
"bigquery_export_table",
|
|
177
|
+
"snowflake_download",
|
|
178
|
+
"sheet_get_cell",
|
|
179
|
+
])("rejects row/cell/result fetcher %p", (name) => {
|
|
180
|
+
expect(() => assertNoRowDataTools([{ name }])).toThrow(ExtensionContractError);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("error names every offending tool", () => {
|
|
184
|
+
try {
|
|
185
|
+
assertNoRowDataTools([
|
|
186
|
+
{ name: "bigquery_list" },
|
|
187
|
+
{ name: "bigquery_run_query" },
|
|
188
|
+
{ name: "bigquery_get_rows" },
|
|
189
|
+
]);
|
|
190
|
+
throw new Error("expected assertNoRowDataTools to throw");
|
|
191
|
+
} catch (err) {
|
|
192
|
+
expect(err).toBeInstanceOf(ExtensionContractError);
|
|
193
|
+
const msg = (err as ExtensionContractError).message;
|
|
194
|
+
expect(msg).toContain("bigquery_run_query");
|
|
195
|
+
expect(msg).toContain("bigquery_get_rows");
|
|
196
|
+
expect(msg).not.toContain("bigquery_list (");
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("ignores blank tool names", () => {
|
|
201
|
+
expect(() => assertNoRowDataTools([{ name: "" }, { name: " " }])).not.toThrow();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { type AuditLogger, createScopedAuditLogger } from "./audit-logger";
|
|
2
|
+
import { type HitlRequest, isHitlRequest } from "./hitl-request";
|
|
3
|
+
import type { ExtensionManifest } from "./types";
|
|
4
|
+
|
|
5
|
+
export class ExtensionContractError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ExtensionContractError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PERMS = new Set<ExtensionManifest["permissions"][number]>(["read", "write", "delete"]);
|
|
13
|
+
const HITL = new Set<ExtensionManifest["hitlRequired"][number]>(["write", "delete"]);
|
|
14
|
+
|
|
15
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
16
|
+
return typeof v === "string" && v.trim() !== "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validateRequiredStrings(manifest: ExtensionManifest): string[] {
|
|
20
|
+
const errors: string[] = [];
|
|
21
|
+
if (!isNonEmptyString(manifest.id)) {
|
|
22
|
+
errors.push("manifest.id is required");
|
|
23
|
+
}
|
|
24
|
+
if (!isNonEmptyString(manifest.displayName)) {
|
|
25
|
+
errors.push("manifest.displayName is required");
|
|
26
|
+
}
|
|
27
|
+
if (!isNonEmptyString(manifest.version)) {
|
|
28
|
+
errors.push("manifest.version is required");
|
|
29
|
+
}
|
|
30
|
+
if (!isNonEmptyString(manifest.description)) {
|
|
31
|
+
errors.push("manifest.description is required");
|
|
32
|
+
}
|
|
33
|
+
if (!isNonEmptyString(manifest.author)) {
|
|
34
|
+
errors.push("manifest.author is required");
|
|
35
|
+
}
|
|
36
|
+
if (!isNonEmptyString(manifest.entrypoint)) {
|
|
37
|
+
errors.push("manifest.entrypoint is required");
|
|
38
|
+
}
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateRuntime(manifest: ExtensionManifest): string[] {
|
|
43
|
+
if (manifest.runtime === "bun" || manifest.runtime === "node") {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return ['manifest.runtime must be "bun" or "node"'];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validatePermissions(manifest: ExtensionManifest): string[] {
|
|
50
|
+
const errors: string[] = [];
|
|
51
|
+
if (!Array.isArray(manifest.permissions)) {
|
|
52
|
+
errors.push("manifest.permissions must be an array");
|
|
53
|
+
return errors;
|
|
54
|
+
}
|
|
55
|
+
for (const p of manifest.permissions) {
|
|
56
|
+
if (!PERMS.has(p)) {
|
|
57
|
+
errors.push(`invalid manifest.permissions entry: ${String(p)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateHitlRequired(manifest: ExtensionManifest): string[] {
|
|
64
|
+
const errors: string[] = [];
|
|
65
|
+
if (Array.isArray(manifest.hitlRequired)) {
|
|
66
|
+
for (const h of manifest.hitlRequired) {
|
|
67
|
+
if (!HITL.has(h)) {
|
|
68
|
+
errors.push(`invalid manifest.hitlRequired entry: ${String(h)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
errors.push("manifest.hitlRequired must be an array");
|
|
73
|
+
}
|
|
74
|
+
return errors;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateMinNimbusVersion(manifest: ExtensionManifest): string[] {
|
|
78
|
+
if (!isNonEmptyString(manifest.minNimbusVersion)) {
|
|
79
|
+
return ["manifest.minNimbusVersion is required"];
|
|
80
|
+
}
|
|
81
|
+
if (!/^\d+\.\d+\.\d+/.test(manifest.minNimbusVersion.trim())) {
|
|
82
|
+
return ["manifest.minNimbusVersion must start with semver x.y.z"];
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertV1AuditLoggerShape(logger: AuditLogger, extensionId: string): void {
|
|
88
|
+
const ret = logger.log("test.action", {});
|
|
89
|
+
if (typeof ret.then !== "function") {
|
|
90
|
+
throw new ExtensionContractError(
|
|
91
|
+
`AuditLogger.log must return a Promise (extension ${extensionId})`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertV1HitlRequestGuard(): void {
|
|
97
|
+
const good: HitlRequest = { actionId: "x", summary: "y" };
|
|
98
|
+
if (!isHitlRequest(good)) {
|
|
99
|
+
throw new ExtensionContractError("isHitlRequest must accept a valid HitlRequest");
|
|
100
|
+
}
|
|
101
|
+
if (isHitlRequest({})) {
|
|
102
|
+
throw new ExtensionContractError("isHitlRequest must reject an empty object");
|
|
103
|
+
}
|
|
104
|
+
if (isHitlRequest({ actionId: "", summary: "y" })) {
|
|
105
|
+
throw new ExtensionContractError("isHitlRequest must reject empty actionId");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tool-name segments that indicate a tool fetches actual ROW / CELL / query-result
|
|
111
|
+
* data (including log EVENTS) — as opposed to schema/metadata. Used by
|
|
112
|
+
* {@link assertNoRowDataTools}.
|
|
113
|
+
*
|
|
114
|
+
* The service prefix of a tool name must be a single token (e.g. `bigquery_list`,
|
|
115
|
+
* not `big_query_list`) so that, for example, `bigquery` never splits into a
|
|
116
|
+
* spurious `query` segment.
|
|
117
|
+
*/
|
|
118
|
+
export const ROW_DATA_TOOL_SEGMENTS: ReadonlySet<string> = new Set<string>([
|
|
119
|
+
"query",
|
|
120
|
+
"queries",
|
|
121
|
+
"row",
|
|
122
|
+
"rows",
|
|
123
|
+
"cell",
|
|
124
|
+
"cells",
|
|
125
|
+
"record",
|
|
126
|
+
"records",
|
|
127
|
+
"event",
|
|
128
|
+
"events",
|
|
129
|
+
"result",
|
|
130
|
+
"results",
|
|
131
|
+
"tabledata",
|
|
132
|
+
"scan",
|
|
133
|
+
"sample",
|
|
134
|
+
"samples",
|
|
135
|
+
"select",
|
|
136
|
+
"values",
|
|
137
|
+
"preview",
|
|
138
|
+
"head",
|
|
139
|
+
"dump",
|
|
140
|
+
"export",
|
|
141
|
+
"download",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
/** A registered MCP tool, reduced to the fields the no-row-data check inspects. */
|
|
145
|
+
export interface RowDataToolCandidate {
|
|
146
|
+
readonly name: string;
|
|
147
|
+
readonly description?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toolNameSegments(name: string): string[] {
|
|
151
|
+
return name
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.split(/[^a-z0-9]+/)
|
|
154
|
+
.filter((s) => s.length > 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Tier-3 "no-row-data" contract assertion. A warehouse / logging / query connector
|
|
159
|
+
* must expose ONLY schema/metadata tools (list / get / search over datasets, tables,
|
|
160
|
+
* schemas, jobs, log groups, models, expectation suites …) and MUST NOT register any
|
|
161
|
+
* tool that pulls actual row / cell / query-result data into the local index.
|
|
162
|
+
*
|
|
163
|
+
* Enforcement is structural at the connector surface (NOT a runtime Gateway
|
|
164
|
+
* invariant): if the connector never registers a row/cell tool there is nothing to
|
|
165
|
+
* block at runtime. This assertion is the executable backstop — call it from the
|
|
166
|
+
* connector's contract test with its registered tool surface so that a future edit
|
|
167
|
+
* adding a `<svc>_run_query` / `<svc>_get_rows` / `<svc>_sample` / `<svc>_scan` tool
|
|
168
|
+
* fails CI. A connector that genuinely needs a live-gated row tool is a discrete
|
|
169
|
+
* `I17` design discussion, out of scope for the no-row-data tier.
|
|
170
|
+
*
|
|
171
|
+
* The check is name-based (tool descriptions are not scanned, to avoid false
|
|
172
|
+
* positives like "does not fetch rows"). Each tool name is split on non-alphanumeric
|
|
173
|
+
* boundaries and rejected if any segment is in {@link ROW_DATA_TOOL_SEGMENTS}.
|
|
174
|
+
*
|
|
175
|
+
* @throws {ExtensionContractError} if any tool name looks like a row/cell fetcher.
|
|
176
|
+
*/
|
|
177
|
+
export function assertNoRowDataTools(
|
|
178
|
+
tools: ReadonlyArray<RowDataToolCandidate>,
|
|
179
|
+
context = "connector",
|
|
180
|
+
): void {
|
|
181
|
+
const offenders: string[] = [];
|
|
182
|
+
for (const tool of tools) {
|
|
183
|
+
if (typeof tool?.name !== "string" || tool.name.trim() === "") {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const hit = toolNameSegments(tool.name).find((s) => ROW_DATA_TOOL_SEGMENTS.has(s));
|
|
187
|
+
if (hit !== undefined) {
|
|
188
|
+
offenders.push(`${tool.name} (row-data segment "${hit}")`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (offenders.length > 0) {
|
|
192
|
+
throw new ExtensionContractError(
|
|
193
|
+
`no-row-data contract violated: ${context} must expose only schema/metadata tools, ` +
|
|
194
|
+
`but these look like row/cell/result fetchers: ${offenders.join(", ")}. Remove the ` +
|
|
195
|
+
`tool, or — if a live-gated row tool is genuinely required — raise it as a discrete ` +
|
|
196
|
+
`I17 design discussion (out of scope for the no-row-data tier).`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Validates a {@link ExtensionManifest} for CI / `nimbus test` (no network, no Gateway).
|
|
203
|
+
*/
|
|
204
|
+
export async function runContractTests(manifest: ExtensionManifest): Promise<void> {
|
|
205
|
+
const errors: string[] = [
|
|
206
|
+
...validateRequiredStrings(manifest),
|
|
207
|
+
...validateRuntime(manifest),
|
|
208
|
+
...validatePermissions(manifest),
|
|
209
|
+
...validateHitlRequired(manifest),
|
|
210
|
+
...validateMinNimbusVersion(manifest),
|
|
211
|
+
];
|
|
212
|
+
if (errors.length > 0) {
|
|
213
|
+
throw new ExtensionContractError(errors.join("; "));
|
|
214
|
+
}
|
|
215
|
+
assertV1HitlRequestGuard();
|
|
216
|
+
assertV1AuditLoggerShape(
|
|
217
|
+
createScopedAuditLogger(manifest.id, async () => {}),
|
|
218
|
+
manifest.id,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import { signAppStoreConnectJwt } from "./app-store-connect-jwt";
|
|
5
|
+
|
|
6
|
+
function generateP8Pem(): string {
|
|
7
|
+
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
8
|
+
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function decode(segment: string): Record<string, unknown> {
|
|
12
|
+
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NOW_MS = 1_700_000_000_000;
|
|
16
|
+
|
|
17
|
+
describe("signAppStoreConnectJwt", () => {
|
|
18
|
+
const privateKeyPem = generateP8Pem();
|
|
19
|
+
const params = { issuerId: "issuer-123", keyId: "KEY456", privateKeyPem };
|
|
20
|
+
|
|
21
|
+
test("produces a 3-part token with the documented ES256 header", () => {
|
|
22
|
+
const parts = signAppStoreConnectJwt(params, NOW_MS).split(".");
|
|
23
|
+
expect(parts).toHaveLength(3);
|
|
24
|
+
expect(decode(parts[0] as string)).toEqual({ alg: "ES256", kid: "KEY456", typ: "JWT" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("payload carries iss/aud and an exp within Apple's 20-min cap", () => {
|
|
28
|
+
const payload = decode(signAppStoreConnectJwt(params, NOW_MS).split(".")[1] as string);
|
|
29
|
+
const nowSec = Math.floor(NOW_MS / 1000);
|
|
30
|
+
expect(payload["iss"]).toBe("issuer-123");
|
|
31
|
+
expect(payload["aud"]).toBe("appstoreconnect-v1");
|
|
32
|
+
expect(payload["iat"]).toBe(nowSec);
|
|
33
|
+
expect(payload["exp"]).toBe(nowSec + 600);
|
|
34
|
+
expect((payload["exp"] as number) - (payload["iat"] as number)).toBeLessThanOrEqual(20 * 60);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("signature verifies under ES256 / ieee-p1363", () => {
|
|
38
|
+
const [h, p, sig] = signAppStoreConnectJwt(params, NOW_MS).split(".");
|
|
39
|
+
const ok = crypto.verify(
|
|
40
|
+
"sha256",
|
|
41
|
+
Buffer.from(`${h}.${p}`, "utf8"),
|
|
42
|
+
{ key: crypto.createPublicKey(privateKeyPem), dsaEncoding: "ieee-p1363" },
|
|
43
|
+
Buffer.from(sig as string, "base64url"), // NOSONAR S4325: sig is string|undefined from the JWT split under noUncheckedIndexedAccess
|
|
44
|
+
);
|
|
45
|
+
expect(ok).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("default nowMs branch — omitting nowMs uses Date.now() and produces a valid iat/exp", () => {
|
|
49
|
+
const before = Math.floor(Date.now() / 1000);
|
|
50
|
+
const token = signAppStoreConnectJwt(params); // no explicit nowMs → hits the default-param branch
|
|
51
|
+
const after = Math.floor(Date.now() / 1000);
|
|
52
|
+
|
|
53
|
+
const parts = token.split(".");
|
|
54
|
+
expect(parts).toHaveLength(3);
|
|
55
|
+
|
|
56
|
+
const payload = JSON.parse(
|
|
57
|
+
Buffer.from(parts[1] as string, "base64url").toString("utf8"),
|
|
58
|
+
) as Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
const iat = payload["iat"] as number;
|
|
61
|
+
const exp = payload["exp"] as number;
|
|
62
|
+
|
|
63
|
+
// iat must fall within the wall-clock window bracketing the call
|
|
64
|
+
expect(iat).toBeGreaterThanOrEqual(before);
|
|
65
|
+
expect(iat).toBeLessThanOrEqual(after);
|
|
66
|
+
|
|
67
|
+
// exp must be exactly iat + 600 (TOKEN_TTL_SECONDS)
|
|
68
|
+
expect(exp).toBe(iat + 600);
|
|
69
|
+
|
|
70
|
+
// The JWT is structurally and cryptographically valid
|
|
71
|
+
const [h, p, sig] = parts;
|
|
72
|
+
const ok = crypto.verify(
|
|
73
|
+
"sha256",
|
|
74
|
+
Buffer.from(`${h}.${p}`, "utf8"),
|
|
75
|
+
{ key: crypto.createPublicKey(privateKeyPem), dsaEncoding: "ieee-p1363" },
|
|
76
|
+
Buffer.from(sig as string, "base64url"), // NOSONAR S4325: sig is string|undefined from the JWT split under noUncheckedIndexedAccess
|
|
77
|
+
);
|
|
78
|
+
expect(ok).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple App Store Connect API authentication — a short-lived ES256 JWT.
|
|
3
|
+
*
|
|
4
|
+
* The App Store Connect API authenticates with a JWT bearer token minted from
|
|
5
|
+
* the developer's EC P-256 `.p8` private key (Apple caps the lifetime at 20
|
|
6
|
+
* min). Shared so the gateway sync and a connector's MCP server sign
|
|
7
|
+
* identically without duplicating the flow.
|
|
8
|
+
*
|
|
9
|
+
* See: https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { signJwt } from "./jwt";
|
|
13
|
+
|
|
14
|
+
export interface AppStoreConnectJwtParams {
|
|
15
|
+
readonly issuerId: string;
|
|
16
|
+
readonly keyId: string;
|
|
17
|
+
/** Full `.p8` PEM text (`-----BEGIN PRIVATE KEY----- …`). */
|
|
18
|
+
readonly privateKeyPem: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AUDIENCE = "appstoreconnect-v1";
|
|
22
|
+
const TOKEN_TTL_SECONDS = 600;
|
|
23
|
+
|
|
24
|
+
/** Mint an ES256 JWT bearer token for the App Store Connect API. */
|
|
25
|
+
export function signAppStoreConnectJwt(
|
|
26
|
+
params: AppStoreConnectJwtParams,
|
|
27
|
+
nowMs: number = Date.now(),
|
|
28
|
+
): string {
|
|
29
|
+
const nowSec = Math.floor(nowMs / 1000);
|
|
30
|
+
return signJwt({
|
|
31
|
+
header: { alg: "ES256", kid: params.keyId, typ: "JWT" },
|
|
32
|
+
payload: {
|
|
33
|
+
iss: params.issuerId,
|
|
34
|
+
iat: nowSec,
|
|
35
|
+
exp: nowSec + TOKEN_TTL_SECONDS,
|
|
36
|
+
aud: AUDIENCE,
|
|
37
|
+
},
|
|
38
|
+
privateKeyPem: params.privateKeyPem,
|
|
39
|
+
// ES256 = ECDSA over P-256; `ieee-p1363` gives the raw r||s JWS needs.
|
|
40
|
+
dsaEncoding: "ieee-p1363",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `canonical-json.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The existing SDK test suite imports `canonicalize` indirectly through
|
|
5
|
+
* `signManifest` / `verifyManifestSignature`, but does not exercise the
|
|
6
|
+
* function directly. These tests target lines that are currently uncovered:
|
|
7
|
+
*
|
|
8
|
+
* - Object key sorting (the explicit UTF-16 comparator added in the main
|
|
9
|
+
* branch merge, and flagged by Sonar S2871).
|
|
10
|
+
* - The `null` / boolean / array / number literal paths.
|
|
11
|
+
* - The three error classes.
|
|
12
|
+
* - `canonicalizeManifest` (strips `signature` then delegates).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
canonicalize,
|
|
19
|
+
canonicalizeManifest,
|
|
20
|
+
ManifestNestedTooDeep,
|
|
21
|
+
NonIntegerNumberInManifest,
|
|
22
|
+
UnsupportedManifestValueType,
|
|
23
|
+
} from "./canonical-json.ts";
|
|
24
|
+
|
|
25
|
+
describe("canonicalize — primitives", () => {
|
|
26
|
+
test("null serializes to 'null'", () => {
|
|
27
|
+
expect(canonicalize(null)).toBe("null");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("true serializes to 'true'", () => {
|
|
31
|
+
expect(canonicalize(true)).toBe("true");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("false serializes to 'false'", () => {
|
|
35
|
+
expect(canonicalize(false)).toBe("false");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("integer number serializes to its string representation", () => {
|
|
39
|
+
expect(canonicalize(42)).toBe("42");
|
|
40
|
+
expect(canonicalize(0)).toBe("0");
|
|
41
|
+
expect(canonicalize(-7)).toBe("-7");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("string is JSON-encoded", () => {
|
|
45
|
+
expect(canonicalize("hello")).toBe('"hello"');
|
|
46
|
+
expect(canonicalize('say "hi"')).toBe(String.raw`"say \"hi\""`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("empty array serializes to '[]'", () => {
|
|
50
|
+
expect(canonicalize([])).toBe("[]");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("array of mixed primitives", () => {
|
|
54
|
+
expect(canonicalize([1, "a", null, true])).toBe('[1,"a",null,true]');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("canonicalize — object key sorting", () => {
|
|
59
|
+
test("keys are sorted in UTF-16 code-unit order (lexicographic)", () => {
|
|
60
|
+
expect(canonicalize({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("numeric string keys sort before alphabetic", () => {
|
|
64
|
+
const result = canonicalize({ z: "z", "0": "zero", a: "a" });
|
|
65
|
+
expect(result).toBe('{"0":"zero","a":"a","z":"z"}');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("nested objects have keys sorted independently", () => {
|
|
69
|
+
const result = canonicalize({ z: { b: 2, a: 1 }, a: 0 });
|
|
70
|
+
expect(result).toBe('{"a":0,"z":{"a":1,"b":2}}');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("empty object serializes to '{}'", () => {
|
|
74
|
+
expect(canonicalize({})).toBe("{}");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("canonicalize — error paths", () => {
|
|
79
|
+
test("non-integer number throws NonIntegerNumberInManifest", () => {
|
|
80
|
+
expect(() => canonicalize(1.5)).toThrow(NonIntegerNumberInManifest);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("unsupported value type (function) throws UnsupportedManifestValueType", () => {
|
|
84
|
+
expect(() => canonicalize(() => {})).toThrow(UnsupportedManifestValueType);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("undefined throws UnsupportedManifestValueType", () => {
|
|
88
|
+
expect(() => canonicalize(undefined)).toThrow(UnsupportedManifestValueType);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("exceeding MAX_DEPTH throws ManifestNestedTooDeep", () => {
|
|
92
|
+
let deep: unknown = "leaf";
|
|
93
|
+
for (let i = 0; i < 34; i++) {
|
|
94
|
+
deep = [deep];
|
|
95
|
+
}
|
|
96
|
+
expect(() => canonicalize(deep)).toThrow(ManifestNestedTooDeep);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("canonicalizeManifest", () => {
|
|
101
|
+
test("strips the signature field before canonicalizing", () => {
|
|
102
|
+
const manifest = { id: "ext.test", version: "1.0.0", signature: "abc123" };
|
|
103
|
+
const bytes = canonicalizeManifest(manifest);
|
|
104
|
+
const text = new TextDecoder().decode(bytes);
|
|
105
|
+
expect(text).not.toContain("signature");
|
|
106
|
+
expect(text).toContain("ext.test");
|
|
107
|
+
expect(text).toContain("1.0.0");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns a Uint8Array of UTF-8 bytes", () => {
|
|
111
|
+
const bytes = canonicalizeManifest({ id: "x", version: "0.1.0" });
|
|
112
|
+
expect(bytes).toBeInstanceOf(Uint8Array);
|
|
113
|
+
expect(bytes.length).toBeGreaterThan(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("keys are sorted in the output (id before version)", () => {
|
|
117
|
+
const bytes = canonicalizeManifest({ version: "1.0.0", id: "ext.x" });
|
|
118
|
+
const text = new TextDecoder().decode(bytes);
|
|
119
|
+
expect(text.indexOf('"id"')).toBeLessThan(text.indexOf('"version"'));
|
|
120
|
+
});
|
|
121
|
+
});
|