@m-kopa/launchpad-cli 0.23.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/CHANGELOG.md +854 -0
- package/README.md +109 -0
- package/dist/auth/browser.d.ts +18 -0
- package/dist/auth/browser.d.ts.map +1 -0
- package/dist/auth/callback-server.d.ts +24 -0
- package/dist/auth/callback-server.d.ts.map +1 -0
- package/dist/auth/discovery.d.ts +25 -0
- package/dist/auth/discovery.d.ts.map +1 -0
- package/dist/auth/flow.d.ts +39 -0
- package/dist/auth/flow.d.ts.map +1 -0
- package/dist/auth/jwt.d.ts +27 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/pkce.d.ts +26 -0
- package/dist/auth/pkce.d.ts.map +1 -0
- package/dist/auth/registration.d.ts +8 -0
- package/dist/auth/registration.d.ts.map +1 -0
- package/dist/auth/session.d.ts +54 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/token.d.ts +37 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/bundle/cron-bundle.d.ts +77 -0
- package/dist/bundle/cron-bundle.d.ts.map +1 -0
- package/dist/bundle/cwd-walker.d.ts +43 -0
- package/dist/bundle/cwd-walker.d.ts.map +1 -0
- package/dist/bundle/orchestrate.d.ts +51 -0
- package/dist/bundle/orchestrate.d.ts.map +1 -0
- package/dist/bundle/upload.d.ts +66 -0
- package/dist/bundle/upload.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9757 -0
- package/dist/clone/git-init.d.ts +18 -0
- package/dist/clone/git-init.d.ts.map +1 -0
- package/dist/clone/tar-extract.d.ts +59 -0
- package/dist/clone/tar-extract.d.ts.map +1 -0
- package/dist/commands/apps.d.ts +14 -0
- package/dist/commands/apps.d.ts.map +1 -0
- package/dist/commands/channel-auth.d.ts +31 -0
- package/dist/commands/channel-auth.d.ts.map +1 -0
- package/dist/commands/clone.d.ts +3 -0
- package/dist/commands/clone.d.ts.map +1 -0
- package/dist/commands/create.d.ts +27 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/deploy-flags.d.ts +75 -0
- package/dist/commands/deploy-flags.d.ts.map +1 -0
- package/dist/commands/deploy-modes.d.ts +59 -0
- package/dist/commands/deploy-modes.d.ts.map +1 -0
- package/dist/commands/deploy.d.ts +29 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/destroy.d.ts +14 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/envvars.d.ts +28 -0
- package/dist/commands/envvars.d.ts.map +1 -0
- package/dist/commands/generate.d.ts +3 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/groups-whoami.d.ts +3 -0
- package/dist/commands/groups-whoami.d.ts.map +1 -0
- package/dist/commands/groups.d.ts +3 -0
- package/dist/commands/groups.d.ts.map +1 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logs.d.ts +16 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/merge.d.ts +29 -0
- package/dist/commands/merge.d.ts.map +1 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.d.ts.map +1 -0
- package/dist/commands/pull.d.ts +12 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/review.d.ts +22 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/rollback.d.ts +3 -0
- package/dist/commands/rollback.d.ts.map +1 -0
- package/dist/commands/secrets-template.d.ts +3 -0
- package/dist/commands/secrets-template.d.ts.map +1 -0
- package/dist/commands/secrets.d.ts +3 -0
- package/dist/commands/secrets.d.ts.map +1 -0
- package/dist/commands/skills.d.ts +13 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/status.d.ts +54 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/update.d.ts +114 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/validate.d.ts +3 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deploy/apply.d.ts +29 -0
- package/dist/deploy/apply.d.ts.map +1 -0
- package/dist/deploy/dry-run.d.ts +13 -0
- package/dist/deploy/dry-run.d.ts.map +1 -0
- package/dist/deploy/git-files.d.ts +33 -0
- package/dist/deploy/git-files.d.ts.map +1 -0
- package/dist/deploy/group-pin.d.ts +66 -0
- package/dist/deploy/group-pin.d.ts.map +1 -0
- package/dist/deploy/manifest-state.d.ts +20 -0
- package/dist/deploy/manifest-state.d.ts.map +1 -0
- package/dist/deploy/manifest-status.d.ts +11 -0
- package/dist/deploy/manifest-status.d.ts.map +1 -0
- package/dist/deploy/resolve.d.ts +53 -0
- package/dist/deploy/resolve.d.ts.map +1 -0
- package/dist/deploy/rollback.d.ts +23 -0
- package/dist/deploy/rollback.d.ts.map +1 -0
- package/dist/deploy/runner.d.ts +29 -0
- package/dist/deploy/runner.d.ts.map +1 -0
- package/dist/deploy/stage-exit-codes.d.ts +41 -0
- package/dist/deploy/stage-exit-codes.d.ts.map +1 -0
- package/dist/deploy/status-polling.d.ts +37 -0
- package/dist/deploy/status-polling.d.ts.map +1 -0
- package/dist/deploy/tar-pack.d.ts +22 -0
- package/dist/deploy/tar-pack.d.ts.map +1 -0
- package/dist/detect/index.d.ts +53 -0
- package/dist/detect/index.d.ts.map +1 -0
- package/dist/dispatcher.d.ts +30 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/groups/client.d.ts +62 -0
- package/dist/groups/client.d.ts.map +1 -0
- package/dist/http/api-client.d.ts +33 -0
- package/dist/http/api-client.d.ts.map +1 -0
- package/dist/http/errors.d.ts +31 -0
- package/dist/http/errors.d.ts.map +1 -0
- package/dist/manifest/load.d.ts +38 -0
- package/dist/manifest/load.d.ts.map +1 -0
- package/dist/manifest/schema.d.ts +3 -0
- package/dist/manifest/schema.d.ts.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +37 -0
- package/dist/secrets/env-parse.d.ts +19 -0
- package/dist/secrets/env-parse.d.ts.map +1 -0
- package/dist/secrets/push.d.ts +13 -0
- package/dist/secrets/push.d.ts.map +1 -0
- package/dist/secrets/set.d.ts +19 -0
- package/dist/secrets/set.d.ts.map +1 -0
- package/dist/secrets/status.d.ts +19 -0
- package/dist/secrets/status.d.ts.map +1 -0
- package/dist/types/api.d.ts +112 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/update-notifier.d.ts +69 -0
- package/dist/update-notifier.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +62 -0
- package/skills/README.md +100 -0
- package/skills/_partials/shell-contract.md +42 -0
- package/skills/launchpad-content-pr/SKILL.md +255 -0
- package/skills/launchpad-deploy/SKILL.md +415 -0
- package/skills/launchpad-deploy-status/SKILL.md +231 -0
- package/skills/launchpad-destroy/SKILL.md +317 -0
- package/skills/launchpad-onboard/SKILL.md +179 -0
- package/skills/launchpad-status/SKILL.md +263 -0
- package/skills/marquee-share/README.md +155 -0
- package/skills/marquee-share/SKILL.md +94 -0
- package/skills/marquee-share/SYNC.md +27 -0
- package/skills/marquee-share/dist/cli.js +896 -0
- package/skills/marquee-share/eslint.config.mjs +71 -0
- package/skills/marquee-share/install.sh +103 -0
- package/skills/marquee-share/package-lock.json +3946 -0
- package/skills/marquee-share/package.json +30 -0
- package/skills/marquee-share/src/auth/PROVENANCE.md +103 -0
- package/skills/marquee-share/src/auth/browser.ts +75 -0
- package/skills/marquee-share/src/auth/callback-server.ts +171 -0
- package/skills/marquee-share/src/auth/discovery.ts +171 -0
- package/skills/marquee-share/src/auth/flow.ts +262 -0
- package/skills/marquee-share/src/auth/index.ts +171 -0
- package/skills/marquee-share/src/auth/jwt.ts +77 -0
- package/skills/marquee-share/src/auth/pkce.ts +79 -0
- package/skills/marquee-share/src/auth/registration.ts +87 -0
- package/skills/marquee-share/src/auth/session.ts +205 -0
- package/skills/marquee-share/src/auth/token.ts +162 -0
- package/skills/marquee-share/src/cli.ts +246 -0
- package/skills/marquee-share/src/config.ts +101 -0
- package/skills/marquee-share/src/render/template.ts +171 -0
- package/skills/marquee-share/src/upload/index.ts +11 -0
- package/skills/marquee-share/src/upload/upload.ts +191 -0
- package/skills/marquee-share/tests/cli.test.ts +281 -0
- package/skills/marquee-share/tests/config.test.ts +119 -0
- package/skills/marquee-share/tests/flow.test.ts +356 -0
- package/skills/marquee-share/tests/no-token-leak.test.ts +240 -0
- package/skills/marquee-share/tests/pkce.test.ts +121 -0
- package/skills/marquee-share/tests/session.test.ts +173 -0
- package/skills/marquee-share/tests/template.test.ts +170 -0
- package/skills/marquee-share/tests/upload.test.ts +311 -0
- package/skills/marquee-share/tsconfig.json +23 -0
- package/skills/marquee-share/vitest.config.ts +15 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// PKCE primitive tests for the Marquee share skill auth layer.
|
|
2
|
+
//
|
|
3
|
+
// Covers: verifier/challenge shape, the S256 relationship, the
|
|
4
|
+
// Cloudflare "challenge must start with [a-zA-Z0-9]" regeneration
|
|
5
|
+
// quirk, and the regeneration cap.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
generatePkcePair,
|
|
10
|
+
sha256Base64Url,
|
|
11
|
+
MAX_PKCE_REGEN_ATTEMPTS,
|
|
12
|
+
PkceGenerationError,
|
|
13
|
+
} from "../src/auth/pkce.js";
|
|
14
|
+
|
|
15
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
16
|
+
|
|
17
|
+
describe("pkce — pair shape", () => {
|
|
18
|
+
test("verifier is unpadded base64url, 43 chars (32 bytes of entropy)", () => {
|
|
19
|
+
const { verifier } = generatePkcePair();
|
|
20
|
+
expect(verifier).toMatch(BASE64URL);
|
|
21
|
+
expect(verifier).toHaveLength(43);
|
|
22
|
+
expect(verifier).not.toContain("=");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("challenge is unpadded base64url", () => {
|
|
26
|
+
const { challenge } = generatePkcePair();
|
|
27
|
+
expect(challenge).toMatch(BASE64URL);
|
|
28
|
+
expect(challenge).not.toContain("=");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("challenge is the S256 hash of the verifier", () => {
|
|
32
|
+
const { verifier, challenge } = generatePkcePair();
|
|
33
|
+
expect(challenge).toBe(sha256Base64Url(verifier));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("each call produces a fresh, distinct pair", () => {
|
|
37
|
+
const a = generatePkcePair();
|
|
38
|
+
const b = generatePkcePair();
|
|
39
|
+
expect(a.verifier).not.toBe(b.verifier);
|
|
40
|
+
expect(a.challenge).not.toBe(b.challenge);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("pkce — Cloudflare leading-char quirk", () => {
|
|
45
|
+
test("challenge always starts with [a-zA-Z0-9]", () => {
|
|
46
|
+
// Many real draws — the regeneration loop must hold every time.
|
|
47
|
+
for (let i = 0; i < 200; i++) {
|
|
48
|
+
const { challenge } = generatePkcePair();
|
|
49
|
+
expect(challenge[0]).toMatch(/[a-zA-Z0-9]/);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a verifier whose challenge starts with '-' or '_' is discarded", () => {
|
|
54
|
+
// Inject a deterministic RNG that first yields a verifier whose
|
|
55
|
+
// S256 challenge has a non-alphanumeric leading char, then a
|
|
56
|
+
// good one. The pair returned must be the SECOND draw (the
|
|
57
|
+
// verifier is thrown away wholesale, not salted/reused).
|
|
58
|
+
const draws: Uint8Array[] = [];
|
|
59
|
+
// Search for one 32-byte seed that hashes to a bad leading char
|
|
60
|
+
// and one that hashes to a good one.
|
|
61
|
+
let bad: Uint8Array | undefined;
|
|
62
|
+
let good: Uint8Array | undefined;
|
|
63
|
+
for (let n = 0; n < 5000 && (!bad || !good); n++) {
|
|
64
|
+
const seed = new Uint8Array(32).fill(0);
|
|
65
|
+
seed[0] = n & 0xff;
|
|
66
|
+
seed[1] = (n >> 8) & 0xff;
|
|
67
|
+
const verifier = Buffer.from(seed)
|
|
68
|
+
.toString("base64")
|
|
69
|
+
.replace(/\+/g, "-")
|
|
70
|
+
.replace(/\//g, "_")
|
|
71
|
+
.replace(/=+$/, "");
|
|
72
|
+
const challenge = sha256Base64Url(verifier);
|
|
73
|
+
if (!bad && /^[-_]/.test(challenge)) bad = seed;
|
|
74
|
+
if (!good && /^[a-zA-Z0-9]/.test(challenge)) good = seed;
|
|
75
|
+
}
|
|
76
|
+
expect(bad).toBeDefined();
|
|
77
|
+
expect(good).toBeDefined();
|
|
78
|
+
draws.push(bad as Uint8Array, good as Uint8Array);
|
|
79
|
+
let i = 0;
|
|
80
|
+
const rng = (_size: number): Uint8Array => {
|
|
81
|
+
const d = draws[i] ?? (good as Uint8Array);
|
|
82
|
+
i++;
|
|
83
|
+
return d;
|
|
84
|
+
};
|
|
85
|
+
const pair = generatePkcePair(rng);
|
|
86
|
+
// The challenge handed back is acceptable...
|
|
87
|
+
expect(pair.challenge[0]).toMatch(/[a-zA-Z0-9]/);
|
|
88
|
+
// ...and it is the second (good) draw, proving the bad verifier
|
|
89
|
+
// was discarded entirely rather than reused.
|
|
90
|
+
const goodVerifier = Buffer.from(good as Uint8Array)
|
|
91
|
+
.toString("base64")
|
|
92
|
+
.replace(/\+/g, "-")
|
|
93
|
+
.replace(/\//g, "_")
|
|
94
|
+
.replace(/=+$/, "");
|
|
95
|
+
expect(pair.verifier).toBe(goodVerifier);
|
|
96
|
+
expect(i).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("throws PkceGenerationError if the RNG can never produce a good challenge", () => {
|
|
100
|
+
// A pathological RNG that always yields the same bad seed.
|
|
101
|
+
let badSeed: Uint8Array | undefined;
|
|
102
|
+
for (let n = 0; n < 5000 && !badSeed; n++) {
|
|
103
|
+
const seed = new Uint8Array(32).fill(0);
|
|
104
|
+
seed[0] = n & 0xff;
|
|
105
|
+
seed[1] = (n >> 8) & 0xff;
|
|
106
|
+
const verifier = Buffer.from(seed)
|
|
107
|
+
.toString("base64")
|
|
108
|
+
.replace(/\+/g, "-")
|
|
109
|
+
.replace(/\//g, "_")
|
|
110
|
+
.replace(/=+$/, "");
|
|
111
|
+
if (/^[-_]/.test(sha256Base64Url(verifier))) badSeed = seed;
|
|
112
|
+
}
|
|
113
|
+
expect(badSeed).toBeDefined();
|
|
114
|
+
const rng = (): Uint8Array => badSeed as Uint8Array;
|
|
115
|
+
expect(() => generatePkcePair(rng)).toThrow(PkceGenerationError);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("regeneration cap is a sane positive number", () => {
|
|
119
|
+
expect(MAX_PKCE_REGEN_ATTEMPTS).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Session-storage tests for the Marquee share skill auth layer.
|
|
2
|
+
//
|
|
3
|
+
// Covers: write/read round-trip, the 0600 file mode (the requirement
|
|
4
|
+
// that the cached credential is not world-readable), parse failure
|
|
5
|
+
// modes, and logout (clearSession) idempotency.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
readSession,
|
|
13
|
+
writeSession,
|
|
14
|
+
clearSession,
|
|
15
|
+
SESSION_VERSION,
|
|
16
|
+
SessionParseError,
|
|
17
|
+
type MarqueeSession,
|
|
18
|
+
} from "../src/auth/session.js";
|
|
19
|
+
|
|
20
|
+
let tmpDir: string;
|
|
21
|
+
let sessionPath: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "marquee-skill-session-"));
|
|
25
|
+
sessionPath = path.join(tmpDir, "deeper", "session.json");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function makeSession(): MarqueeSession {
|
|
33
|
+
return {
|
|
34
|
+
version: SESSION_VERSION,
|
|
35
|
+
accessToken: "access-aaa",
|
|
36
|
+
refreshToken: "refresh-bbb",
|
|
37
|
+
accessTokenExpiresAt: 1_700_000_000_000,
|
|
38
|
+
clientId: "client-ccc",
|
|
39
|
+
tokenEndpoint: "https://example/oauth/token",
|
|
40
|
+
resource: "https://marquee.launchpad.m-kopa.us",
|
|
41
|
+
issuedAt: "2026-05-17T12:00:00Z",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("session — read/write round-trip", () => {
|
|
46
|
+
test("write then read returns the exact same shape", async () => {
|
|
47
|
+
const s = makeSession();
|
|
48
|
+
await writeSession(sessionPath, s);
|
|
49
|
+
const back = await readSession(sessionPath);
|
|
50
|
+
expect(back).toEqual(s);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("write creates parent dir if absent (mkdir -p)", async () => {
|
|
54
|
+
await writeSession(sessionPath, makeSession());
|
|
55
|
+
const stat = await fs.stat(path.dirname(sessionPath));
|
|
56
|
+
expect(stat.isDirectory()).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("written file has mode 0600 (POSIX only)", async () => {
|
|
60
|
+
if (process.platform === "win32") return;
|
|
61
|
+
await writeSession(sessionPath, makeSession());
|
|
62
|
+
const stat = await fs.stat(sessionPath);
|
|
63
|
+
// Mask off the type bits, compare permission bits only.
|
|
64
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("parent directory is created at mode 0700 (POSIX only)", async () => {
|
|
68
|
+
if (process.platform === "win32") return;
|
|
69
|
+
await writeSession(sessionPath, makeSession());
|
|
70
|
+
const stat = await fs.stat(path.dirname(sessionPath));
|
|
71
|
+
expect(stat.mode & 0o777).toBe(0o700);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("readSession returns null when the file doesn't exist", async () => {
|
|
75
|
+
const back = await readSession(sessionPath);
|
|
76
|
+
expect(back).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a session without resource still parses (resource optional)", async () => {
|
|
80
|
+
const s = makeSession();
|
|
81
|
+
const { resource: _resource, ...withoutResource } = s;
|
|
82
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
83
|
+
await fs.writeFile(sessionPath, JSON.stringify(withoutResource));
|
|
84
|
+
const back = await readSession(sessionPath);
|
|
85
|
+
expect(back?.resource).toBeUndefined();
|
|
86
|
+
expect(back?.accessToken).toBe("access-aaa");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("session — parse failure modes", () => {
|
|
91
|
+
test("non-JSON file throws SessionParseError", async () => {
|
|
92
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
93
|
+
await fs.writeFile(sessionPath, "not json{", { mode: 0o600 });
|
|
94
|
+
await expect(readSession(sessionPath)).rejects.toBeInstanceOf(
|
|
95
|
+
SessionParseError,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("non-object JSON throws SessionParseError", async () => {
|
|
100
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
101
|
+
await fs.writeFile(sessionPath, "[]", { mode: 0o600 });
|
|
102
|
+
await expect(readSession(sessionPath)).rejects.toBeInstanceOf(
|
|
103
|
+
SessionParseError,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("wrong schema version throws SessionParseError", async () => {
|
|
108
|
+
const bad = { ...makeSession(), version: 99 };
|
|
109
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
110
|
+
await fs.writeFile(sessionPath, JSON.stringify(bad));
|
|
111
|
+
await expect(readSession(sessionPath)).rejects.toThrow(
|
|
112
|
+
/unsupported session version 99/,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("missing required string field throws SessionParseError", async () => {
|
|
117
|
+
const bad = { ...makeSession() } as Record<string, unknown>;
|
|
118
|
+
delete bad.refreshToken;
|
|
119
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
120
|
+
await fs.writeFile(sessionPath, JSON.stringify(bad));
|
|
121
|
+
await expect(readSession(sessionPath)).rejects.toThrow(/refreshToken/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("non-numeric accessTokenExpiresAt throws SessionParseError", async () => {
|
|
125
|
+
const bad = { ...makeSession(), accessTokenExpiresAt: "soon" };
|
|
126
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
127
|
+
await fs.writeFile(sessionPath, JSON.stringify(bad));
|
|
128
|
+
await expect(readSession(sessionPath)).rejects.toThrow(
|
|
129
|
+
/accessTokenExpiresAt/,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("resource present but non-string throws SessionParseError", async () => {
|
|
134
|
+
const bad = { ...makeSession(), resource: 123 };
|
|
135
|
+
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
|
|
136
|
+
await fs.writeFile(sessionPath, JSON.stringify(bad));
|
|
137
|
+
await expect(readSession(sessionPath)).rejects.toThrow(/resource/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("session — expiry semantics", () => {
|
|
142
|
+
test("accessTokenExpiresAt round-trips as a number for expiry math", async () => {
|
|
143
|
+
const future = Date.now() + 900_000;
|
|
144
|
+
await writeSession(sessionPath, {
|
|
145
|
+
...makeSession(),
|
|
146
|
+
accessTokenExpiresAt: future,
|
|
147
|
+
});
|
|
148
|
+
const back = await readSession(sessionPath);
|
|
149
|
+
expect(typeof back?.accessTokenExpiresAt).toBe("number");
|
|
150
|
+
expect(back?.accessTokenExpiresAt).toBe(future);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("session — clear (logout)", () => {
|
|
155
|
+
test("clearSession returns true when a session existed", async () => {
|
|
156
|
+
await writeSession(sessionPath, makeSession());
|
|
157
|
+
const had = await clearSession(sessionPath);
|
|
158
|
+
expect(had).toBe(true);
|
|
159
|
+
const back = await readSession(sessionPath);
|
|
160
|
+
expect(back).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("clearSession returns false when nothing was there (idempotent)", async () => {
|
|
164
|
+
const had = await clearSession(sessionPath);
|
|
165
|
+
expect(had).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("clearSession twice in a row is safe", async () => {
|
|
169
|
+
await writeSession(sessionPath, makeSession());
|
|
170
|
+
expect(await clearSession(sessionPath)).toBe(true);
|
|
171
|
+
expect(await clearSession(sessionPath)).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Tests for the branded HTML template + escaping-safe injection.
|
|
2
|
+
//
|
|
3
|
+
// The skill is a thin uploader: the AI produces the inner content
|
|
4
|
+
// HTML, this layer wraps it into one M-KOPA-branded skeleton. The
|
|
5
|
+
// security-load-bearing property here is that the title slot — a
|
|
6
|
+
// plain string supplied by the caller — is HTML-escaped so a hostile
|
|
7
|
+
// title cannot break out of the `<title>`/`<h1>` context and inject
|
|
8
|
+
// markup or script into the document shell.
|
|
9
|
+
//
|
|
10
|
+
// The CONTENT slot is, by contract, already-rendered HTML and is
|
|
11
|
+
// inserted verbatim into the document `<body>`. That trust decision
|
|
12
|
+
// lives in the SKILL.md guidance (Task 5) and Marquee's own
|
|
13
|
+
// sandboxed-iframe render isolation — NOT here. These tests pin the
|
|
14
|
+
// behaviour we DO own: a valid self-contained document, an escaped
|
|
15
|
+
// title, and a content slot that cannot subvert the SHELL'S OWN
|
|
16
|
+
// structure (it cannot, e.g., close the document early and append
|
|
17
|
+
// sibling markup to the shell — there is nothing after the slot that
|
|
18
|
+
// a payload could escape INTO that isn't already inside <body>).
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from "vitest";
|
|
21
|
+
import { renderBrandedDocument } from "../src/render/template.js";
|
|
22
|
+
|
|
23
|
+
describe("renderBrandedDocument — document shape", () => {
|
|
24
|
+
test("produces one self-contained HTML document", () => {
|
|
25
|
+
const html = renderBrandedDocument({
|
|
26
|
+
title: "Quarterly Review",
|
|
27
|
+
contentHtml: "<p>Hello</p>",
|
|
28
|
+
});
|
|
29
|
+
// A single, well-formed document.
|
|
30
|
+
expect(html.startsWith("<!DOCTYPE html>")).toBe(true);
|
|
31
|
+
expect(html).toContain("<html");
|
|
32
|
+
expect(html).toContain("</html>");
|
|
33
|
+
// Exactly one document — no duplicated roots.
|
|
34
|
+
expect(html.match(/<!DOCTYPE html>/gi)?.length).toBe(1);
|
|
35
|
+
expect(html.match(/<html[\s>]/gi)?.length).toBe(1);
|
|
36
|
+
expect(html.match(/<\/html>/gi)?.length).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("is self-contained — no external CSS/JS/font references", () => {
|
|
40
|
+
const html = renderBrandedDocument({
|
|
41
|
+
title: "T",
|
|
42
|
+
contentHtml: "<p>body</p>",
|
|
43
|
+
});
|
|
44
|
+
// No external resource references — the document must render
|
|
45
|
+
// offline / inside Marquee's sandboxed iframe with no network.
|
|
46
|
+
expect(html).not.toMatch(/<link[^>]+rel=["']?stylesheet/i);
|
|
47
|
+
expect(html).not.toMatch(/<script[^>]+\bsrc=/i);
|
|
48
|
+
expect(html).not.toMatch(/@import/i);
|
|
49
|
+
// The brand styling is inline in a <style> block.
|
|
50
|
+
expect(html).toContain("<style>");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("places the content verbatim inside the document body", () => {
|
|
54
|
+
const marker = "<section data-test=\"content-marker\">unique-payload-xyz</section>";
|
|
55
|
+
const html = renderBrandedDocument({ title: "T", contentHtml: marker });
|
|
56
|
+
expect(html).toContain(marker);
|
|
57
|
+
// The content sits inside <body>, after the opening tag.
|
|
58
|
+
const bodyOpen = html.indexOf("<body");
|
|
59
|
+
expect(bodyOpen).toBeGreaterThan(-1);
|
|
60
|
+
expect(html.indexOf(marker)).toBeGreaterThan(bodyOpen);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("renders the title in both the <title> and the visible header", () => {
|
|
64
|
+
const html = renderBrandedDocument({
|
|
65
|
+
title: "Migration Plan",
|
|
66
|
+
contentHtml: "<p>x</p>",
|
|
67
|
+
});
|
|
68
|
+
expect(html).toContain("<title>Migration Plan</title>");
|
|
69
|
+
expect(html).toContain("Migration Plan");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("carries the M-KOPA brand palette (white background, green accent)", () => {
|
|
73
|
+
const html = renderBrandedDocument({ title: "T", contentHtml: "<p>x</p>" });
|
|
74
|
+
// White background somewhere in the inline style.
|
|
75
|
+
expect(html.toLowerCase()).toMatch(/background[^;}]*#fff|background[^;}]*#ffffff/);
|
|
76
|
+
// The green brand accent token is present.
|
|
77
|
+
expect(html).toContain("--brand-green");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("renderBrandedDocument — escaping-safe title injection", () => {
|
|
82
|
+
test("a hostile title cannot break out of the <title> element", () => {
|
|
83
|
+
const html = renderBrandedDocument({
|
|
84
|
+
title: "</title><script>alert(1)</script>",
|
|
85
|
+
contentHtml: "<p>x</p>",
|
|
86
|
+
});
|
|
87
|
+
// The raw closing tag + script must NOT appear — they are escaped.
|
|
88
|
+
expect(html).not.toContain("</title><script>");
|
|
89
|
+
// The escaped form is what lands in the document.
|
|
90
|
+
expect(html).toContain("</title><script>");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a hostile title cannot inject an attribute or new element into the header", () => {
|
|
94
|
+
const html = renderBrandedDocument({
|
|
95
|
+
title: '"><img src=x onerror=alert(1)>',
|
|
96
|
+
contentHtml: "<p>x</p>",
|
|
97
|
+
});
|
|
98
|
+
// No live <img> element from the title.
|
|
99
|
+
expect(html).not.toContain("<img src=x onerror=alert(1)>");
|
|
100
|
+
// Quotes and angle brackets are entity-encoded.
|
|
101
|
+
expect(html).toContain("<img");
|
|
102
|
+
expect(html).toContain(">");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("escapes the full set of HTML-significant characters in the title", () => {
|
|
106
|
+
const html = renderBrandedDocument({
|
|
107
|
+
title: `& < > " '`,
|
|
108
|
+
contentHtml: "<p>x</p>",
|
|
109
|
+
});
|
|
110
|
+
expect(html).toContain("&");
|
|
111
|
+
expect(html).toContain("<");
|
|
112
|
+
expect(html).toContain(">");
|
|
113
|
+
expect(html).toContain(""");
|
|
114
|
+
expect(html).toContain("'");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("an ordinary title is rendered readably (not over-escaped)", () => {
|
|
118
|
+
const html = renderBrandedDocument({
|
|
119
|
+
title: "Q3 Results",
|
|
120
|
+
contentHtml: "<p>x</p>",
|
|
121
|
+
});
|
|
122
|
+
expect(html).toContain("<title>Q3 Results</title>");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a missing/empty title falls back to a default and still escapes", () => {
|
|
126
|
+
const html = renderBrandedDocument({ title: "", contentHtml: "<p>x</p>" });
|
|
127
|
+
// Document is still valid with a non-empty <title>.
|
|
128
|
+
expect(html).toMatch(/<title>[^<][^]*<\/title>/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("renderBrandedDocument — content slot cannot subvert the shell", () => {
|
|
133
|
+
test("a hostile payload lands strictly inside the body, after the real shell open", () => {
|
|
134
|
+
// A maximally hostile content payload that tries to close
|
|
135
|
+
// <body>/<html> and append its own document. The content slot is
|
|
136
|
+
// verbatim HTML by contract, so the payload's BYTES (including a
|
|
137
|
+
// literal "<!DOCTYPE html>") do appear — what matters is WHERE:
|
|
138
|
+
// the template's OWN structural opening tags come first and are
|
|
139
|
+
// untouched, so the payload is always parsed as content inside
|
|
140
|
+
// <body>, never as a peer root.
|
|
141
|
+
const payload =
|
|
142
|
+
"</body></html><!DOCTYPE html><html><body><h1>evil shell</h1>";
|
|
143
|
+
const html = renderBrandedDocument({ title: "T", contentHtml: payload });
|
|
144
|
+
// The template's real shell opens exactly once and FIRST.
|
|
145
|
+
const docType = html.indexOf("<!DOCTYPE html>");
|
|
146
|
+
const htmlOpen = html.indexOf("<html");
|
|
147
|
+
const bodyOpen = html.indexOf("<body");
|
|
148
|
+
expect(docType).toBe(0);
|
|
149
|
+
// The payload bytes are present verbatim…
|
|
150
|
+
expect(html).toContain(payload);
|
|
151
|
+
// …and sit AFTER the template's own <html> and <body> opens — the
|
|
152
|
+
// shell prefix the parser uses to build the document is the
|
|
153
|
+
// template's, not the payload's.
|
|
154
|
+
expect(html.indexOf(payload)).toBeGreaterThan(htmlOpen);
|
|
155
|
+
expect(html.indexOf(payload)).toBeGreaterThan(bodyOpen);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("the shell's structural markup is fixed regardless of content", () => {
|
|
159
|
+
const a = renderBrandedDocument({ title: "T", contentHtml: "<p>plain</p>" });
|
|
160
|
+
const b = renderBrandedDocument({
|
|
161
|
+
title: "T",
|
|
162
|
+
contentHtml: "</body></html>",
|
|
163
|
+
});
|
|
164
|
+
// The shell prefix (everything up to the content slot) is byte
|
|
165
|
+
// identical — the content cannot reach back and alter it.
|
|
166
|
+
const prefixA = a.slice(0, a.indexOf("<p>plain</p>"));
|
|
167
|
+
const prefixB = b.slice(0, b.indexOf("</body></html>"));
|
|
168
|
+
expect(prefixA).toBe(prefixB);
|
|
169
|
+
});
|
|
170
|
+
});
|