@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,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "marquee-share-skill",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Claude Code skill that uploads an AI-chat HTML artefact to Marquee, attributed to the real user via Cloudflare Access PKCE auth.",
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.0.0"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"marquee-share": "./dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "bun build src/cli.ts --target node --outfile dist/cli.js",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"lint": "eslint src tests",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"marquee-share-dev": "tsx ./src/cli.ts"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "22.18.10",
|
|
23
|
+
"@typescript-eslint/eslint-plugin": "8.46.4",
|
|
24
|
+
"@typescript-eslint/parser": "8.46.4",
|
|
25
|
+
"eslint": "9.39.4",
|
|
26
|
+
"tsx": "4.20.3",
|
|
27
|
+
"typescript": "5.9.3",
|
|
28
|
+
"vitest": "4.1.5"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Vendored auth modules — provenance
|
|
2
|
+
|
|
3
|
+
The files in this directory (`browser.ts`, `callback-server.ts`,
|
|
4
|
+
`discovery.ts`, `flow.ts`, `jwt.ts`, `pkce.ts`, `registration.ts`,
|
|
5
|
+
`session.ts`, `token.ts`) are a **vendored copy** of the OAuth /
|
|
6
|
+
PKCE auth layer from the Launchpad CLI.
|
|
7
|
+
|
|
8
|
+
## Why vendored, not depended-on
|
|
9
|
+
|
|
10
|
+
`@m-kopa/launchpad-cli` is published as an executable binary
|
|
11
|
+
(`bin: launchpad`). It has no library export surface — `package.json`
|
|
12
|
+
ships only `dist/cli.js` plus skills, and exposes no `exports` map for
|
|
13
|
+
the `auth/` modules. There is no supported way to `import` these
|
|
14
|
+
modules as a dependency. A self-contained vendored copy is therefore
|
|
15
|
+
the chosen mechanism: it is small, it has zero runtime dependencies
|
|
16
|
+
(only Node built-ins), and it is auditable in-tree.
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
| Field | Value |
|
|
21
|
+
|-------|-------|
|
|
22
|
+
| Repo | `M-KOPA/launchpad-platform` |
|
|
23
|
+
| Package | `packages/launchpad-cli` (`@m-kopa/launchpad-cli`) |
|
|
24
|
+
| Package version | `0.9.0` |
|
|
25
|
+
| Source commit | `951304e4a5c60be78bbec7b88493049f140236ff` |
|
|
26
|
+
| Commit subject | `Enable Managed OAuth on the Marquee Access app (M-960 / M-962)` |
|
|
27
|
+
| Source paths | `src/auth/*.ts`, `src/config.ts` |
|
|
28
|
+
|
|
29
|
+
## Adaptations for Marquee
|
|
30
|
+
|
|
31
|
+
The OAuth/PKCE protocol logic is **functionally identical** to the
|
|
32
|
+
upstream — the Cloudflare Access Managed OAuth flow is the same flow
|
|
33
|
+
Marquee's Access app exposes (verified by the M-960 gating spike).
|
|
34
|
+
|
|
35
|
+
### Naming / config adaptations
|
|
36
|
+
|
|
37
|
+
- **User-facing strings.** `launchpad` → `marquee` in error messages
|
|
38
|
+
and the callback-server "you can close this tab" HTML page.
|
|
39
|
+
- **Dynamic-client-registration `client_name`.** `launchpad-cli` →
|
|
40
|
+
`marquee-share-skill`, so the registered public client is
|
|
41
|
+
identifiable in Cloudflare Access logs.
|
|
42
|
+
- **Config defaults** (`config.ts`): `botUrl` → `resourceUrl`
|
|
43
|
+
defaulting to `https://marquee.launchpad.m-kopa.us`; session path
|
|
44
|
+
default `~/.marquee/session.json`; env overrides renamed
|
|
45
|
+
`LAUNCHPAD_*` → `MARQUEE_*`.
|
|
46
|
+
- **`getValidAccessToken` pre-0.7.1 guard message** updated to refer
|
|
47
|
+
to `marquee login`.
|
|
48
|
+
|
|
49
|
+
### Hardening divergences (M-960 CodeRabbit review)
|
|
50
|
+
|
|
51
|
+
The following changes diverge from upstream behaviour. They were made
|
|
52
|
+
in response to the CodeRabbit review of marquee PR #4 and **should be
|
|
53
|
+
contributed back to `@m-kopa/launchpad-cli`** so the vendored copy can
|
|
54
|
+
re-converge with upstream on the next re-sync.
|
|
55
|
+
|
|
56
|
+
- **`browser.ts` — spawn-success detection.** Upstream resolves the
|
|
57
|
+
`openBrowser` promise from a `setImmediate` microtask. The vendored
|
|
58
|
+
copy instead resolves from the child process's `spawn` event (and
|
|
59
|
+
calls `child.unref()` inside that listener). The `spawn` event is
|
|
60
|
+
the idiomatic Node API: it fires only on a successful OS-level
|
|
61
|
+
spawn and is not emitted on failure, so the `error` listener still
|
|
62
|
+
owns rejection.
|
|
63
|
+
- **`flow.ts` — bounded callback wait.** Upstream awaits
|
|
64
|
+
`server.result` with no timeout, so a never-arriving OAuth callback
|
|
65
|
+
(closed tab, blocked loopback, bad redirect) hangs the command
|
|
66
|
+
forever. The vendored copy races `server.result` against a
|
|
67
|
+
`CALLBACK_TIMEOUT_MS` (5 min) timeout that rejects with a
|
|
68
|
+
`LoginRequiredError`; the existing `finally` still closes the
|
|
69
|
+
callback server when the timeout fires.
|
|
70
|
+
- **`session.ts` — `accessTokenExpiresAt` validation.** Upstream
|
|
71
|
+
type-checks `accessTokenExpiresAt` only (`typeof === "number"`),
|
|
72
|
+
which accepts `NaN`, `±Infinity`, and non-positive values — all of
|
|
73
|
+
which corrupt the silent-refresh expiry comparison. The vendored
|
|
74
|
+
copy additionally requires `Number.isFinite(...)` and a value `> 0`.
|
|
75
|
+
- **`token.ts` — token-endpoint error body.** Upstream embeds up to
|
|
76
|
+
300 chars of the raw token-endpoint response body in the
|
|
77
|
+
`TokenError` message. A reflecting upstream/proxy could leak
|
|
78
|
+
secrets into a user-visible error that way. The vendored copy
|
|
79
|
+
surfaces only the HTTP status plus, when the body is well-formed
|
|
80
|
+
OAuth JSON, the standard `error` code (RFC 6749 §5.2) — never
|
|
81
|
+
free-form body text.
|
|
82
|
+
|
|
83
|
+
The `resource` indicator (RFC 8707) is still derived at runtime from
|
|
84
|
+
the protected-resource discovery document
|
|
85
|
+
(`/.well-known/cloudflare-access-protected-resource/`) — pointing the
|
|
86
|
+
flow at Marquee's base URL makes discovery yield Marquee's canonical
|
|
87
|
+
resource automatically. No protocol value is hard-coded.
|
|
88
|
+
|
|
89
|
+
## What was deliberately NOT vendored
|
|
90
|
+
|
|
91
|
+
- JWT **signature verification** — there is none here, and there must
|
|
92
|
+
not be (see `marquee` repo `ANTI-PATTERNS.md`: "Do not implement
|
|
93
|
+
custom JWT verification"). `jwt.ts` is a payload-only decoder used
|
|
94
|
+
for display (`whoami`-style identity readout). The skill is an OAuth
|
|
95
|
+
*client*: it presents the token; Marquee's Pages Functions verify it.
|
|
96
|
+
- Any client secret. This is a PKCE **public client**
|
|
97
|
+
(`token_endpoint_auth_method: "none"`). No secret is generated,
|
|
98
|
+
stored, or shipped.
|
|
99
|
+
|
|
100
|
+
## Re-syncing
|
|
101
|
+
|
|
102
|
+
If the upstream auth layer changes materially, re-vendor from a fresh
|
|
103
|
+
commit and update the table above. Keep the adaptations list current.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Cross-platform default-browser opener.
|
|
2
|
+
//
|
|
3
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/browser.ts — see
|
|
4
|
+
// PROVENANCE.md. Marquee divergence: spawn-success detection uses the
|
|
5
|
+
// child's `spawn` event rather than a `setImmediate` microtask.
|
|
6
|
+
//
|
|
7
|
+
// Why hand-rolled rather than the `open` npm package: keep the
|
|
8
|
+
// supply-chain footprint of the shipped skill to zero runtime deps.
|
|
9
|
+
// Three OSes, three commands; the table is short.
|
|
10
|
+
//
|
|
11
|
+
// On platforms where opening fails — typically headless CI — the
|
|
12
|
+
// caller is responsible for printing the URL and instructions.
|
|
13
|
+
// We surface that as a thrown `BrowserOpenError`.
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
export class BrowserOpenError extends Error {
|
|
18
|
+
readonly code = "browser_open_error" as const;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Open `url` in the user's default browser. Resolves once the
|
|
23
|
+
* spawn succeeds; the OS opener is fire-and-forget after that.
|
|
24
|
+
*
|
|
25
|
+
* `platform` is injected for tests so the dispatch table is
|
|
26
|
+
* exercised without actually opening a browser. The default reads
|
|
27
|
+
* `process.platform`.
|
|
28
|
+
*/
|
|
29
|
+
export async function openBrowser(
|
|
30
|
+
url: string,
|
|
31
|
+
platform: NodeJS.Platform = process.platform,
|
|
32
|
+
spawner: typeof spawn = spawn,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const { command, args } = chooseOpener(platform, url);
|
|
35
|
+
return new Promise<void>((resolve, reject) => {
|
|
36
|
+
const child = spawner(command, args, {
|
|
37
|
+
stdio: "ignore",
|
|
38
|
+
detached: true,
|
|
39
|
+
});
|
|
40
|
+
child.once("error", (err) => {
|
|
41
|
+
reject(
|
|
42
|
+
new BrowserOpenError(
|
|
43
|
+
`could not open browser via ${command}: ${err.message}`,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
// The `spawn` event fires once the OS has successfully created
|
|
48
|
+
// the process; it is not emitted on a spawn failure (the `error`
|
|
49
|
+
// event handles that). Once spawned we don't care about the
|
|
50
|
+
// child's exit — the OS opener has already handed off the URL.
|
|
51
|
+
child.once("spawn", () => {
|
|
52
|
+
child.unref();
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function chooseOpener(
|
|
59
|
+
platform: NodeJS.Platform,
|
|
60
|
+
url: string,
|
|
61
|
+
): { command: string; args: string[] } {
|
|
62
|
+
switch (platform) {
|
|
63
|
+
case "darwin":
|
|
64
|
+
return { command: "open", args: [url] };
|
|
65
|
+
case "win32":
|
|
66
|
+
// `start ""` consumes the empty title arg; the URL is the
|
|
67
|
+
// real argument. We invoke through `cmd` so shell quoting is
|
|
68
|
+
// handled.
|
|
69
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
70
|
+
default:
|
|
71
|
+
// Linux and other Unixes — xdg-open is the de-facto standard,
|
|
72
|
+
// installed by default on every common distribution.
|
|
73
|
+
return { command: "xdg-open", args: [url] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Local HTTP server that catches the OAuth redirect.
|
|
2
|
+
//
|
|
3
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/callback-server.ts —
|
|
4
|
+
// see PROVENANCE.md. Marquee adaptation: the "you can close this tab"
|
|
5
|
+
// HTML page title says "marquee" instead of "launchpad".
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle:
|
|
8
|
+
// 1. `bindCallbackServer()` returns an OS-assigned free port + a
|
|
9
|
+
// Promise that resolves on the first valid
|
|
10
|
+
// `/callback?code=...&state=...` hit.
|
|
11
|
+
// 2. The browser is opened to the authorization URL pointing at
|
|
12
|
+
// `http://127.0.0.1:<port>/callback`.
|
|
13
|
+
// 3. After Cf Access + IdP, the browser hits our endpoint with the
|
|
14
|
+
// authorization code in the query string. We send a tiny "you
|
|
15
|
+
// can close this tab" HTML response and resolve the promise.
|
|
16
|
+
// 4. The caller calls `close()` to free the port.
|
|
17
|
+
//
|
|
18
|
+
// Security notes:
|
|
19
|
+
// * We bind to `127.0.0.1` only — never a wildcard. A wildcard
|
|
20
|
+
// bind would expose the callback to the LAN, where another
|
|
21
|
+
// machine could potentially race the user's browser.
|
|
22
|
+
// * We require the OAuth `state` parameter to match the value the
|
|
23
|
+
// authorization URL sent. RFC 6749 §10.12 makes it the standard
|
|
24
|
+
// CSRF guard.
|
|
25
|
+
// * If a request comes in for any path other than `/callback` we
|
|
26
|
+
// return 404 and KEEP listening — opportunistic preview
|
|
27
|
+
// fetches (favicons, prefetchers) shouldn't tear down the flow.
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createServer,
|
|
31
|
+
type IncomingMessage,
|
|
32
|
+
type Server,
|
|
33
|
+
type ServerResponse,
|
|
34
|
+
} from "node:http";
|
|
35
|
+
import type { AddressInfo } from "node:net";
|
|
36
|
+
|
|
37
|
+
export interface CallbackResult {
|
|
38
|
+
readonly code: string;
|
|
39
|
+
readonly state: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CallbackServer {
|
|
43
|
+
readonly port: number;
|
|
44
|
+
/** Resolves when the user's browser hits /callback with a valid
|
|
45
|
+
* code + matching state. Rejects on `cancel()`. */
|
|
46
|
+
readonly result: Promise<CallbackResult>;
|
|
47
|
+
/** Tear down the listener. Idempotent. */
|
|
48
|
+
readonly close: () => Promise<void>;
|
|
49
|
+
/** Reject the pending `result` and close. */
|
|
50
|
+
readonly cancel: (reason: string) => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class CallbackError extends Error {
|
|
54
|
+
readonly code = "callback_error" as const;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bind a fresh callback server. `expectedState` is the value the
|
|
59
|
+
* authorization URL will carry in `state`; the server rejects any
|
|
60
|
+
* request whose `state` differs.
|
|
61
|
+
*/
|
|
62
|
+
export async function bindCallbackServer(
|
|
63
|
+
expectedState: string,
|
|
64
|
+
): Promise<CallbackServer> {
|
|
65
|
+
let resolveResult!: (r: CallbackResult) => void;
|
|
66
|
+
let rejectResult!: (e: Error) => void;
|
|
67
|
+
const result = new Promise<CallbackResult>((resolve, reject) => {
|
|
68
|
+
resolveResult = resolve;
|
|
69
|
+
rejectResult = reject;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const server: Server = createServer((req, res) => {
|
|
73
|
+
handleRequest(req, res, expectedState, resolveResult, rejectResult);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await new Promise<void>((resolve, reject) => {
|
|
77
|
+
server.once("error", reject);
|
|
78
|
+
// 127.0.0.1 + port 0 = OS picks a free loopback port.
|
|
79
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const addr = server.address() as AddressInfo | null;
|
|
83
|
+
if (addr === null || typeof addr === "string") {
|
|
84
|
+
server.close();
|
|
85
|
+
throw new CallbackError(`callback server: failed to bind`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let closed = false;
|
|
89
|
+
const close = async (): Promise<void> => {
|
|
90
|
+
if (closed) return;
|
|
91
|
+
closed = true;
|
|
92
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
93
|
+
};
|
|
94
|
+
const cancel = async (reason: string): Promise<void> => {
|
|
95
|
+
rejectResult(new CallbackError(`callback server cancelled: ${reason}`));
|
|
96
|
+
await close();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return { port: addr.port, result, close, cancel };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleRequest(
|
|
103
|
+
req: IncomingMessage,
|
|
104
|
+
res: ServerResponse,
|
|
105
|
+
expectedState: string,
|
|
106
|
+
resolveResult: (r: CallbackResult) => void,
|
|
107
|
+
rejectResult: (e: Error) => void,
|
|
108
|
+
): void {
|
|
109
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
110
|
+
if (url.pathname !== "/callback") {
|
|
111
|
+
res.statusCode = 404;
|
|
112
|
+
res.setHeader("content-type", "text/plain");
|
|
113
|
+
res.end("not found");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const error = url.searchParams.get("error");
|
|
117
|
+
if (error !== null) {
|
|
118
|
+
const desc = url.searchParams.get("error_description") ?? "";
|
|
119
|
+
res.statusCode = 400;
|
|
120
|
+
res.setHeader("content-type", "text/html");
|
|
121
|
+
res.end(htmlPage(`Authentication failed: ${error}. ${desc}`));
|
|
122
|
+
rejectResult(
|
|
123
|
+
new CallbackError(
|
|
124
|
+
`authorization endpoint returned error=${error}: ${desc}`,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const code = url.searchParams.get("code");
|
|
130
|
+
const state = url.searchParams.get("state");
|
|
131
|
+
if (code === null || state === null) {
|
|
132
|
+
res.statusCode = 400;
|
|
133
|
+
res.setHeader("content-type", "text/plain");
|
|
134
|
+
res.end("missing code or state");
|
|
135
|
+
// Don't reject the outer promise — a stray prefetch could land
|
|
136
|
+
// on /callback with no params; keep waiting for the real one.
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (state !== expectedState) {
|
|
140
|
+
res.statusCode = 400;
|
|
141
|
+
res.setHeader("content-type", "text/plain");
|
|
142
|
+
res.end("state mismatch");
|
|
143
|
+
rejectResult(
|
|
144
|
+
new CallbackError(
|
|
145
|
+
`OAuth callback state mismatch — possible CSRF, aborting`,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
res.statusCode = 200;
|
|
151
|
+
res.setHeader("content-type", "text/html");
|
|
152
|
+
res.end(htmlPage("Logged in. You can close this tab."));
|
|
153
|
+
resolveResult({ code, state });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function htmlPage(message: string): string {
|
|
157
|
+
// Tiny self-contained page; no external assets so the user's
|
|
158
|
+
// browser doesn't make network requests after the redirect.
|
|
159
|
+
return `<!doctype html>
|
|
160
|
+
<html><head><meta charset="utf-8"><title>marquee</title>
|
|
161
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;padding:3rem;color:#222}h1{font-size:1.2rem;margin:0 0 1rem}</style>
|
|
162
|
+
</head><body><h1>marquee</h1><p>${escapeHtml(message)}</p></body></html>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeHtml(s: string): string {
|
|
166
|
+
return s
|
|
167
|
+
.replace(/&/g, "&")
|
|
168
|
+
.replace(/</g, "<")
|
|
169
|
+
.replace(/>/g, ">")
|
|
170
|
+
.replace(/"/g, """);
|
|
171
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// OAuth discovery for Cloudflare Access Managed OAuth.
|
|
2
|
+
//
|
|
3
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/discovery.ts — see
|
|
4
|
+
// PROVENANCE.md. Unchanged from upstream: the discovery walk is
|
|
5
|
+
// generic. Pointing it at Marquee's base URL is purely a config
|
|
6
|
+
// concern (see config.ts) — this module bakes in no host.
|
|
7
|
+
//
|
|
8
|
+
// The skill doesn't bake the token / authorization endpoints in. It
|
|
9
|
+
// reads them from two well-known URLs at login time:
|
|
10
|
+
//
|
|
11
|
+
// 1. `https://<resource>/.well-known/cloudflare-access-protected-resource/`
|
|
12
|
+
// → returns `{ authorization_servers: [<server>], resource }`
|
|
13
|
+
// 2. `<server>/.well-known/oauth-authorization-server`
|
|
14
|
+
// → returns `{ authorization_endpoint, token_endpoint,
|
|
15
|
+
// registration_endpoint, … }`
|
|
16
|
+
//
|
|
17
|
+
// We need three endpoints out of step 2:
|
|
18
|
+
// * `authorization_endpoint` — where the browser is sent
|
|
19
|
+
// * `token_endpoint` — code-for-token exchange + refresh
|
|
20
|
+
// * `registration_endpoint` — RFC 7591 dynamic client registration
|
|
21
|
+
//
|
|
22
|
+
// Plus the `resource` indicator (RFC 8707) from step 1.
|
|
23
|
+
//
|
|
24
|
+
// When `<resource>` is Marquee's base URL the step-1 doc is served by
|
|
25
|
+
// Marquee's own Access app and yields Marquee's canonical resource
|
|
26
|
+
// — no protocol value is hard-coded here.
|
|
27
|
+
|
|
28
|
+
export interface OAuthEndpoints {
|
|
29
|
+
readonly authorizationEndpoint: string;
|
|
30
|
+
readonly tokenEndpoint: string;
|
|
31
|
+
readonly registrationEndpoint: string;
|
|
32
|
+
/**
|
|
33
|
+
* The protected resource's canonical URL (per RFC 8707 OAuth 2.0
|
|
34
|
+
* Resource Indicators). Cf Access REQUIRES this parameter on the
|
|
35
|
+
* authorization request — omitting it returns
|
|
36
|
+
* `error=invalid_target, error_description="No resource parameter
|
|
37
|
+
* found"`. Sourced from the protected-resource metadata's
|
|
38
|
+
* `resource` field; falls back to the configured base URL if the
|
|
39
|
+
* metadata omits it.
|
|
40
|
+
*/
|
|
41
|
+
readonly resource: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class DiscoveryError extends Error {
|
|
45
|
+
readonly code = "discovery_error" as const;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Walk the two-step discovery and return the endpoints. `resourceUrl`
|
|
50
|
+
* is the base of the protected app (no trailing slash) — for Marquee,
|
|
51
|
+
* `https://marquee.launchpad.m-kopa.us`. The fetcher is injected so
|
|
52
|
+
* tests don't need a network.
|
|
53
|
+
*/
|
|
54
|
+
export async function discoverOauthEndpoints(
|
|
55
|
+
resourceUrl: string,
|
|
56
|
+
fetcher: typeof fetch = fetch,
|
|
57
|
+
): Promise<OAuthEndpoints> {
|
|
58
|
+
// Step 1: protected-resource metadata.
|
|
59
|
+
const wellKnownUrl = `${resourceUrl}/.well-known/cloudflare-access-protected-resource/`;
|
|
60
|
+
const resourceMeta = (await fetchJson(fetcher, wellKnownUrl)) as {
|
|
61
|
+
authorization_servers?: unknown;
|
|
62
|
+
resource?: unknown;
|
|
63
|
+
};
|
|
64
|
+
if (
|
|
65
|
+
!Array.isArray(resourceMeta.authorization_servers) ||
|
|
66
|
+
typeof resourceMeta.authorization_servers[0] !== "string"
|
|
67
|
+
) {
|
|
68
|
+
throw new DiscoveryError(
|
|
69
|
+
`discovery: ${wellKnownUrl} returned no authorization_servers entry`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
// RFC 8707 resource indicator. Cf Access surfaces the protected
|
|
73
|
+
// application's URL here; we MUST forward it on the auth request
|
|
74
|
+
// (Cf rejects with `invalid_target` otherwise). Fall back to the
|
|
75
|
+
// base URL we already have when the metadata omits the field OR
|
|
76
|
+
// emits something useless (whitespace-only).
|
|
77
|
+
const rawResource =
|
|
78
|
+
typeof resourceMeta.resource === "string"
|
|
79
|
+
? resourceMeta.resource.trim()
|
|
80
|
+
: "";
|
|
81
|
+
const resource = rawResource.length > 0 ? rawResource : resourceUrl;
|
|
82
|
+
// Defence-in-depth: refuse to walk to step 2 over HTTP. Without
|
|
83
|
+
// this check a malicious step-1 response could redirect us to a
|
|
84
|
+
// plaintext metadata endpoint and harvest the auth flow.
|
|
85
|
+
const rawServer = resourceMeta.authorization_servers[0];
|
|
86
|
+
let parsed: URL;
|
|
87
|
+
try {
|
|
88
|
+
parsed = new URL(rawServer);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new DiscoveryError(
|
|
91
|
+
`${wellKnownUrl}: authorization_servers[0] is not a valid URL: ${rawServer}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (parsed.protocol !== "https:") {
|
|
95
|
+
throw new DiscoveryError(
|
|
96
|
+
`${wellKnownUrl}: authorization_servers[0] is not https: ${rawServer}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const server = rawServer.replace(/\/+$/, "");
|
|
100
|
+
|
|
101
|
+
// Step 2: OAuth server metadata.
|
|
102
|
+
const serverUrl = `${server}/.well-known/oauth-authorization-server`;
|
|
103
|
+
const serverMeta = (await fetchJson(fetcher, serverUrl)) as Record<
|
|
104
|
+
string,
|
|
105
|
+
unknown
|
|
106
|
+
>;
|
|
107
|
+
const authorizationEndpoint = requireHttpsString(
|
|
108
|
+
serverMeta,
|
|
109
|
+
"authorization_endpoint",
|
|
110
|
+
serverUrl,
|
|
111
|
+
);
|
|
112
|
+
const tokenEndpoint = requireHttpsString(
|
|
113
|
+
serverMeta,
|
|
114
|
+
"token_endpoint",
|
|
115
|
+
serverUrl,
|
|
116
|
+
);
|
|
117
|
+
const registrationEndpoint = requireHttpsString(
|
|
118
|
+
serverMeta,
|
|
119
|
+
"registration_endpoint",
|
|
120
|
+
serverUrl,
|
|
121
|
+
);
|
|
122
|
+
return {
|
|
123
|
+
authorizationEndpoint,
|
|
124
|
+
tokenEndpoint,
|
|
125
|
+
registrationEndpoint,
|
|
126
|
+
resource,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function fetchJson(
|
|
131
|
+
fetcher: typeof fetch,
|
|
132
|
+
url: string,
|
|
133
|
+
): Promise<unknown> {
|
|
134
|
+
let res: Response;
|
|
135
|
+
try {
|
|
136
|
+
res = await fetcher(url, { method: "GET" });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new DiscoveryError(
|
|
139
|
+
`discovery: network error fetching ${url}: ${describe(e)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new DiscoveryError(`discovery: ${url} returned HTTP ${res.status}`);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return await res.json();
|
|
147
|
+
} catch (e) {
|
|
148
|
+
throw new DiscoveryError(
|
|
149
|
+
`discovery: ${url} returned non-JSON body: ${describe(e)}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function requireHttpsString(
|
|
155
|
+
obj: Record<string, unknown>,
|
|
156
|
+
key: string,
|
|
157
|
+
source: string,
|
|
158
|
+
): string {
|
|
159
|
+
const v = obj[key];
|
|
160
|
+
if (typeof v !== "string") {
|
|
161
|
+
throw new DiscoveryError(`${source} missing string ${key}`);
|
|
162
|
+
}
|
|
163
|
+
if (!v.startsWith("https://")) {
|
|
164
|
+
throw new DiscoveryError(`${source} ${key} is not https: ${v}`);
|
|
165
|
+
}
|
|
166
|
+
return v;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function describe(e: unknown): string {
|
|
170
|
+
return e instanceof Error ? e.message : String(e);
|
|
171
|
+
}
|