@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.
Files changed (191) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +109 -0
  3. package/dist/auth/browser.d.ts +18 -0
  4. package/dist/auth/browser.d.ts.map +1 -0
  5. package/dist/auth/callback-server.d.ts +24 -0
  6. package/dist/auth/callback-server.d.ts.map +1 -0
  7. package/dist/auth/discovery.d.ts +25 -0
  8. package/dist/auth/discovery.d.ts.map +1 -0
  9. package/dist/auth/flow.d.ts +39 -0
  10. package/dist/auth/flow.d.ts.map +1 -0
  11. package/dist/auth/jwt.d.ts +27 -0
  12. package/dist/auth/jwt.d.ts.map +1 -0
  13. package/dist/auth/pkce.d.ts +26 -0
  14. package/dist/auth/pkce.d.ts.map +1 -0
  15. package/dist/auth/registration.d.ts +8 -0
  16. package/dist/auth/registration.d.ts.map +1 -0
  17. package/dist/auth/session.d.ts +54 -0
  18. package/dist/auth/session.d.ts.map +1 -0
  19. package/dist/auth/token.d.ts +37 -0
  20. package/dist/auth/token.d.ts.map +1 -0
  21. package/dist/bundle/cron-bundle.d.ts +77 -0
  22. package/dist/bundle/cron-bundle.d.ts.map +1 -0
  23. package/dist/bundle/cwd-walker.d.ts +43 -0
  24. package/dist/bundle/cwd-walker.d.ts.map +1 -0
  25. package/dist/bundle/orchestrate.d.ts +51 -0
  26. package/dist/bundle/orchestrate.d.ts.map +1 -0
  27. package/dist/bundle/upload.d.ts +66 -0
  28. package/dist/bundle/upload.d.ts.map +1 -0
  29. package/dist/cli.d.ts +3 -0
  30. package/dist/cli.d.ts.map +1 -0
  31. package/dist/cli.js +9757 -0
  32. package/dist/clone/git-init.d.ts +18 -0
  33. package/dist/clone/git-init.d.ts.map +1 -0
  34. package/dist/clone/tar-extract.d.ts +59 -0
  35. package/dist/clone/tar-extract.d.ts.map +1 -0
  36. package/dist/commands/apps.d.ts +14 -0
  37. package/dist/commands/apps.d.ts.map +1 -0
  38. package/dist/commands/channel-auth.d.ts +31 -0
  39. package/dist/commands/channel-auth.d.ts.map +1 -0
  40. package/dist/commands/clone.d.ts +3 -0
  41. package/dist/commands/clone.d.ts.map +1 -0
  42. package/dist/commands/create.d.ts +27 -0
  43. package/dist/commands/create.d.ts.map +1 -0
  44. package/dist/commands/deploy-flags.d.ts +75 -0
  45. package/dist/commands/deploy-flags.d.ts.map +1 -0
  46. package/dist/commands/deploy-modes.d.ts +59 -0
  47. package/dist/commands/deploy-modes.d.ts.map +1 -0
  48. package/dist/commands/deploy.d.ts +29 -0
  49. package/dist/commands/deploy.d.ts.map +1 -0
  50. package/dist/commands/destroy.d.ts +14 -0
  51. package/dist/commands/destroy.d.ts.map +1 -0
  52. package/dist/commands/envvars.d.ts +28 -0
  53. package/dist/commands/envvars.d.ts.map +1 -0
  54. package/dist/commands/generate.d.ts +3 -0
  55. package/dist/commands/generate.d.ts.map +1 -0
  56. package/dist/commands/groups-whoami.d.ts +3 -0
  57. package/dist/commands/groups-whoami.d.ts.map +1 -0
  58. package/dist/commands/groups.d.ts +3 -0
  59. package/dist/commands/groups.d.ts.map +1 -0
  60. package/dist/commands/init.d.ts +44 -0
  61. package/dist/commands/init.d.ts.map +1 -0
  62. package/dist/commands/login.d.ts +3 -0
  63. package/dist/commands/login.d.ts.map +1 -0
  64. package/dist/commands/logout.d.ts +3 -0
  65. package/dist/commands/logout.d.ts.map +1 -0
  66. package/dist/commands/logs.d.ts +16 -0
  67. package/dist/commands/logs.d.ts.map +1 -0
  68. package/dist/commands/merge.d.ts +29 -0
  69. package/dist/commands/merge.d.ts.map +1 -0
  70. package/dist/commands/plan.d.ts +3 -0
  71. package/dist/commands/plan.d.ts.map +1 -0
  72. package/dist/commands/pull.d.ts +12 -0
  73. package/dist/commands/pull.d.ts.map +1 -0
  74. package/dist/commands/review.d.ts +22 -0
  75. package/dist/commands/review.d.ts.map +1 -0
  76. package/dist/commands/rollback.d.ts +3 -0
  77. package/dist/commands/rollback.d.ts.map +1 -0
  78. package/dist/commands/secrets-template.d.ts +3 -0
  79. package/dist/commands/secrets-template.d.ts.map +1 -0
  80. package/dist/commands/secrets.d.ts +3 -0
  81. package/dist/commands/secrets.d.ts.map +1 -0
  82. package/dist/commands/skills.d.ts +13 -0
  83. package/dist/commands/skills.d.ts.map +1 -0
  84. package/dist/commands/status.d.ts +54 -0
  85. package/dist/commands/status.d.ts.map +1 -0
  86. package/dist/commands/update.d.ts +114 -0
  87. package/dist/commands/update.d.ts.map +1 -0
  88. package/dist/commands/validate.d.ts +3 -0
  89. package/dist/commands/validate.d.ts.map +1 -0
  90. package/dist/commands/whoami.d.ts +3 -0
  91. package/dist/commands/whoami.d.ts.map +1 -0
  92. package/dist/config.d.ts +11 -0
  93. package/dist/config.d.ts.map +1 -0
  94. package/dist/deploy/apply.d.ts +29 -0
  95. package/dist/deploy/apply.d.ts.map +1 -0
  96. package/dist/deploy/dry-run.d.ts +13 -0
  97. package/dist/deploy/dry-run.d.ts.map +1 -0
  98. package/dist/deploy/git-files.d.ts +33 -0
  99. package/dist/deploy/git-files.d.ts.map +1 -0
  100. package/dist/deploy/group-pin.d.ts +66 -0
  101. package/dist/deploy/group-pin.d.ts.map +1 -0
  102. package/dist/deploy/manifest-state.d.ts +20 -0
  103. package/dist/deploy/manifest-state.d.ts.map +1 -0
  104. package/dist/deploy/manifest-status.d.ts +11 -0
  105. package/dist/deploy/manifest-status.d.ts.map +1 -0
  106. package/dist/deploy/resolve.d.ts +53 -0
  107. package/dist/deploy/resolve.d.ts.map +1 -0
  108. package/dist/deploy/rollback.d.ts +23 -0
  109. package/dist/deploy/rollback.d.ts.map +1 -0
  110. package/dist/deploy/runner.d.ts +29 -0
  111. package/dist/deploy/runner.d.ts.map +1 -0
  112. package/dist/deploy/stage-exit-codes.d.ts +41 -0
  113. package/dist/deploy/stage-exit-codes.d.ts.map +1 -0
  114. package/dist/deploy/status-polling.d.ts +37 -0
  115. package/dist/deploy/status-polling.d.ts.map +1 -0
  116. package/dist/deploy/tar-pack.d.ts +22 -0
  117. package/dist/deploy/tar-pack.d.ts.map +1 -0
  118. package/dist/detect/index.d.ts +53 -0
  119. package/dist/detect/index.d.ts.map +1 -0
  120. package/dist/dispatcher.d.ts +30 -0
  121. package/dist/dispatcher.d.ts.map +1 -0
  122. package/dist/groups/client.d.ts +62 -0
  123. package/dist/groups/client.d.ts.map +1 -0
  124. package/dist/http/api-client.d.ts +33 -0
  125. package/dist/http/api-client.d.ts.map +1 -0
  126. package/dist/http/errors.d.ts +31 -0
  127. package/dist/http/errors.d.ts.map +1 -0
  128. package/dist/manifest/load.d.ts +38 -0
  129. package/dist/manifest/load.d.ts.map +1 -0
  130. package/dist/manifest/schema.d.ts +3 -0
  131. package/dist/manifest/schema.d.ts.map +1 -0
  132. package/dist/postinstall.d.ts +3 -0
  133. package/dist/postinstall.d.ts.map +1 -0
  134. package/dist/postinstall.js +37 -0
  135. package/dist/secrets/env-parse.d.ts +19 -0
  136. package/dist/secrets/env-parse.d.ts.map +1 -0
  137. package/dist/secrets/push.d.ts +13 -0
  138. package/dist/secrets/push.d.ts.map +1 -0
  139. package/dist/secrets/set.d.ts +19 -0
  140. package/dist/secrets/set.d.ts.map +1 -0
  141. package/dist/secrets/status.d.ts +19 -0
  142. package/dist/secrets/status.d.ts.map +1 -0
  143. package/dist/types/api.d.ts +112 -0
  144. package/dist/types/api.d.ts.map +1 -0
  145. package/dist/update-notifier.d.ts +69 -0
  146. package/dist/update-notifier.d.ts.map +1 -0
  147. package/dist/version.d.ts +2 -0
  148. package/dist/version.d.ts.map +1 -0
  149. package/package.json +62 -0
  150. package/skills/README.md +100 -0
  151. package/skills/_partials/shell-contract.md +42 -0
  152. package/skills/launchpad-content-pr/SKILL.md +255 -0
  153. package/skills/launchpad-deploy/SKILL.md +415 -0
  154. package/skills/launchpad-deploy-status/SKILL.md +231 -0
  155. package/skills/launchpad-destroy/SKILL.md +317 -0
  156. package/skills/launchpad-onboard/SKILL.md +179 -0
  157. package/skills/launchpad-status/SKILL.md +263 -0
  158. package/skills/marquee-share/README.md +155 -0
  159. package/skills/marquee-share/SKILL.md +94 -0
  160. package/skills/marquee-share/SYNC.md +27 -0
  161. package/skills/marquee-share/dist/cli.js +896 -0
  162. package/skills/marquee-share/eslint.config.mjs +71 -0
  163. package/skills/marquee-share/install.sh +103 -0
  164. package/skills/marquee-share/package-lock.json +3946 -0
  165. package/skills/marquee-share/package.json +30 -0
  166. package/skills/marquee-share/src/auth/PROVENANCE.md +103 -0
  167. package/skills/marquee-share/src/auth/browser.ts +75 -0
  168. package/skills/marquee-share/src/auth/callback-server.ts +171 -0
  169. package/skills/marquee-share/src/auth/discovery.ts +171 -0
  170. package/skills/marquee-share/src/auth/flow.ts +262 -0
  171. package/skills/marquee-share/src/auth/index.ts +171 -0
  172. package/skills/marquee-share/src/auth/jwt.ts +77 -0
  173. package/skills/marquee-share/src/auth/pkce.ts +79 -0
  174. package/skills/marquee-share/src/auth/registration.ts +87 -0
  175. package/skills/marquee-share/src/auth/session.ts +205 -0
  176. package/skills/marquee-share/src/auth/token.ts +162 -0
  177. package/skills/marquee-share/src/cli.ts +246 -0
  178. package/skills/marquee-share/src/config.ts +101 -0
  179. package/skills/marquee-share/src/render/template.ts +171 -0
  180. package/skills/marquee-share/src/upload/index.ts +11 -0
  181. package/skills/marquee-share/src/upload/upload.ts +191 -0
  182. package/skills/marquee-share/tests/cli.test.ts +281 -0
  183. package/skills/marquee-share/tests/config.test.ts +119 -0
  184. package/skills/marquee-share/tests/flow.test.ts +356 -0
  185. package/skills/marquee-share/tests/no-token-leak.test.ts +240 -0
  186. package/skills/marquee-share/tests/pkce.test.ts +121 -0
  187. package/skills/marquee-share/tests/session.test.ts +173 -0
  188. package/skills/marquee-share/tests/template.test.ts +170 -0
  189. package/skills/marquee-share/tests/upload.test.ts +311 -0
  190. package/skills/marquee-share/tsconfig.json +23 -0
  191. 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, "&amp;")
168
+ .replace(/</g, "&lt;")
169
+ .replace(/>/g, "&gt;")
170
+ .replace(/"/g, "&quot;");
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
+ }