@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,262 @@
1
+ // Orchestration: putting discovery + registration + PKCE +
2
+ // callback server + browser open + token exchange together into
3
+ // a single "log the user in" function.
4
+ //
5
+ // Vendored from @m-kopa/launchpad-cli src/auth/flow.ts — see
6
+ // PROVENANCE.md. Marquee adaptations:
7
+ // * `botUrl` → `resourceUrl` (the option that names the protected
8
+ // app — for Marquee, `https://marquee.launchpad.m-kopa.us`).
9
+ // * `CliSession` → `MarqueeSession`.
10
+ // * User-facing error strings say `marquee login`.
11
+ // * The callback wait is bounded by `CALLBACK_TIMEOUT_MS` so a
12
+ // never-arriving callback can't hang the command forever.
13
+ // The state-machine logic itself is otherwise unchanged.
14
+ //
15
+ // Also: `getValidAccessToken` — the symmetric read path the upload
16
+ // task (a later task) will go through. Returns the current
17
+ // `accessToken` if it's still valid, otherwise silently refreshes
18
+ // using the `refreshToken`. If the refresh fails (grant session
19
+ // expired, token revoked, etc.) the caller gets a typed
20
+ // `LoginRequiredError`.
21
+ //
22
+ // The login flow is a state machine in narrative form:
23
+ //
24
+ // 1. discoverOauthEndpoints(resourceUrl) — two GETs of well-known
25
+ // metadata.
26
+ // 2. bindCallbackServer(state) — pick a free localhost port.
27
+ // 3. registerClient(registrationEndpoint, redirectUri) — get a
28
+ // `client_id` for this loopback URI (public client, no secret).
29
+ // 4. generatePkcePair() — verifier + challenge with the
30
+ // Cloudflare-quirk regen.
31
+ // 5. openBrowser(authUrl) — kick the user into Cf Access.
32
+ // 6. server.result — wait for /callback?code=...
33
+ // 7. exchangeCodeForTokens(...) — code → access + refresh.
34
+ // 8. writeSession(...) — persist with mode 0600.
35
+ // 9. server.close().
36
+ //
37
+ // The whole thing is wrapped in a try/finally that always closes
38
+ // the server, even on cancellation or thrown errors.
39
+
40
+ import { randomBytes } from "node:crypto";
41
+ import { bindCallbackServer } from "./callback-server.js";
42
+ import { discoverOauthEndpoints } from "./discovery.js";
43
+ import { generatePkcePair } from "./pkce.js";
44
+ import { registerClient } from "./registration.js";
45
+ import { exchangeCodeForTokens, refreshTokens, TokenError } from "./token.js";
46
+ import {
47
+ readSession,
48
+ writeSession,
49
+ type MarqueeSession,
50
+ SESSION_VERSION,
51
+ } from "./session.js";
52
+ import { openBrowser } from "./browser.js";
53
+
54
+ export class LoginRequiredError extends Error {
55
+ readonly code = "login_required" as const;
56
+ }
57
+
58
+ /** Refresh-window buffer: refresh `REFRESH_SKEW_MS` before expiry
59
+ * so a request fired right at the edge doesn't hit a 401. */
60
+ export const REFRESH_SKEW_MS = 30_000;
61
+
62
+ /** Upper bound on how long `login` waits for the browser to round-trip
63
+ * back to the localhost callback. Without it a closed tab, a blocked
64
+ * loopback, or a bad redirect hangs `marquee login` indefinitely. */
65
+ export const CALLBACK_TIMEOUT_MS = 5 * 60_000;
66
+
67
+ export interface LoginOptions {
68
+ /** Base URL of the protected app — for Marquee,
69
+ * `https://marquee.launchpad.m-kopa.us`. Discovery walks the
70
+ * well-known docs under this host. */
71
+ readonly resourceUrl: string;
72
+ readonly sessionPath: string;
73
+ /** Receives the auth URL once the localhost server is bound +
74
+ * the browser opener is about to fire. The caller prints it to
75
+ * stdout so a headless dev can copy-paste. */
76
+ readonly onAuthUrl?: (url: string) => void;
77
+ /** Injection points for tests. */
78
+ readonly fetcher?: typeof fetch;
79
+ readonly browserOpener?: (url: string) => Promise<void>;
80
+ }
81
+
82
+ /**
83
+ * Run the full PKCE flow and persist the session. Returns the
84
+ * resulting session for the caller (so it can print confirmation
85
+ * diagnostics — never the token itself).
86
+ */
87
+ export async function login(opts: LoginOptions): Promise<MarqueeSession> {
88
+ const fetcher = opts.fetcher ?? fetch;
89
+ const opener = opts.browserOpener ?? ((url: string) => openBrowser(url));
90
+
91
+ const endpoints = await discoverOauthEndpoints(opts.resourceUrl, fetcher);
92
+ const state = randomBytes(16).toString("hex");
93
+ const server = await bindCallbackServer(state);
94
+ try {
95
+ const redirectUri = `http://127.0.0.1:${server.port}/callback`;
96
+ const reg = await registerClient(
97
+ endpoints.registrationEndpoint,
98
+ redirectUri,
99
+ fetcher,
100
+ );
101
+ const pkce = generatePkcePair();
102
+ const authUrl = buildAuthorizationUrl({
103
+ authorizationEndpoint: endpoints.authorizationEndpoint,
104
+ clientId: reg.clientId,
105
+ redirectUri,
106
+ challenge: pkce.challenge,
107
+ state,
108
+ resource: endpoints.resource,
109
+ });
110
+ opts.onAuthUrl?.(authUrl);
111
+ // Opener failure (no `xdg-open` on a headless box, etc.) must NOT
112
+ // tear down the flow — `onAuthUrl` already printed the URL for
113
+ // the user to copy-paste. Log the error to the onAuthUrl sink so
114
+ // the caller sees something, then keep waiting on the callback.
115
+ try {
116
+ await opener(authUrl);
117
+ } catch (e) {
118
+ opts.onAuthUrl?.(
119
+ `(could not auto-open browser: ${describe(e)} — copy the URL above into a browser instead)`,
120
+ );
121
+ }
122
+ // Bound the callback wait — the `finally` below still closes the
123
+ // server when the timeout fires, so no listener / port leaks.
124
+ let callbackTimer: ReturnType<typeof setTimeout> | undefined;
125
+ const callback = await Promise.race([
126
+ server.result,
127
+ new Promise<never>((_, reject) => {
128
+ callbackTimer = setTimeout(
129
+ () =>
130
+ reject(
131
+ new LoginRequiredError(
132
+ "timed out waiting for the browser callback — run `marquee login` again",
133
+ ),
134
+ ),
135
+ CALLBACK_TIMEOUT_MS,
136
+ );
137
+ }),
138
+ ]).finally(() => {
139
+ if (callbackTimer !== undefined) clearTimeout(callbackTimer);
140
+ });
141
+ const tokens = await exchangeCodeForTokens(
142
+ {
143
+ tokenEndpoint: endpoints.tokenEndpoint,
144
+ clientId: reg.clientId,
145
+ code: callback.code,
146
+ codeVerifier: pkce.verifier,
147
+ redirectUri,
148
+ resource: endpoints.resource,
149
+ },
150
+ fetcher,
151
+ );
152
+ const session: MarqueeSession = {
153
+ version: SESSION_VERSION,
154
+ accessToken: tokens.accessToken,
155
+ refreshToken: tokens.refreshToken,
156
+ accessTokenExpiresAt: Date.now() + tokens.expiresInSec * 1000,
157
+ clientId: reg.clientId,
158
+ tokenEndpoint: endpoints.tokenEndpoint,
159
+ resource: endpoints.resource,
160
+ issuedAt: new Date().toISOString(),
161
+ };
162
+ await writeSession(opts.sessionPath, session);
163
+ return session;
164
+ } finally {
165
+ await server.close();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Read the session and return a still-valid access token. If the
171
+ * persisted access token is past (or near) expiry, silently
172
+ * refresh and persist the new pair before returning. If the
173
+ * refresh fails because the grant has expired, throws
174
+ * `LoginRequiredError`.
175
+ *
176
+ * `now` is injected for tests so we can simulate expiry without
177
+ * mocking the system clock.
178
+ */
179
+ export async function getValidAccessToken(
180
+ sessionPath: string,
181
+ fetcher: typeof fetch = fetch,
182
+ now: () => number = Date.now,
183
+ ): Promise<{ accessToken: string; session: MarqueeSession }> {
184
+ const session = await readSession(sessionPath);
185
+ if (session === null) {
186
+ throw new LoginRequiredError("no session — run `marquee login`");
187
+ }
188
+ if (session.accessTokenExpiresAt - REFRESH_SKEW_MS > now()) {
189
+ return { accessToken: session.accessToken, session };
190
+ }
191
+ // Expired (or close to it). Try silent refresh.
192
+ //
193
+ // A session written without `resource` cannot be refreshed (Cf
194
+ // Access would fail the `invalid_target` check). Force the user
195
+ // back through `marquee login` rather than send a doomed request.
196
+ if (session.resource === undefined) {
197
+ throw new LoginRequiredError(
198
+ "session is missing the resource indicator — run `marquee login`",
199
+ );
200
+ }
201
+ let next;
202
+ try {
203
+ next = await refreshTokens(
204
+ {
205
+ tokenEndpoint: session.tokenEndpoint,
206
+ clientId: session.clientId,
207
+ refreshToken: session.refreshToken,
208
+ resource: session.resource,
209
+ },
210
+ fetcher,
211
+ );
212
+ } catch (e) {
213
+ if (
214
+ e instanceof TokenError &&
215
+ e.httpStatus !== undefined &&
216
+ e.httpStatus >= 400 &&
217
+ e.httpStatus < 500
218
+ ) {
219
+ // 4xx on refresh = grant rejected by the server. The user
220
+ // must re-auth via browser; nothing the skill can do silently.
221
+ throw new LoginRequiredError(
222
+ `session expired — run \`marquee login\` (token endpoint said: ${e.message})`,
223
+ );
224
+ }
225
+ throw e;
226
+ }
227
+ const refreshed: MarqueeSession = {
228
+ ...session,
229
+ accessToken: next.accessToken,
230
+ refreshToken: next.refreshToken,
231
+ accessTokenExpiresAt: now() + next.expiresInSec * 1000,
232
+ issuedAt: new Date(now()).toISOString(),
233
+ };
234
+ await writeSession(sessionPath, refreshed);
235
+ return { accessToken: refreshed.accessToken, session: refreshed };
236
+ }
237
+
238
+ function describe(e: unknown): string {
239
+ return e instanceof Error ? e.message : String(e);
240
+ }
241
+
242
+ function buildAuthorizationUrl(params: {
243
+ readonly authorizationEndpoint: string;
244
+ readonly clientId: string;
245
+ readonly redirectUri: string;
246
+ readonly challenge: string;
247
+ readonly state: string;
248
+ readonly resource: string;
249
+ }): string {
250
+ const u = new URL(params.authorizationEndpoint);
251
+ u.searchParams.set("client_id", params.clientId);
252
+ u.searchParams.set("redirect_uri", params.redirectUri);
253
+ u.searchParams.set("response_type", "code");
254
+ u.searchParams.set("code_challenge", params.challenge);
255
+ u.searchParams.set("code_challenge_method", "S256");
256
+ u.searchParams.set("state", params.state);
257
+ // RFC 8707 — Cf Access requires this on the auth request
258
+ // (`invalid_target` otherwise). The resulting access_token's `aud`
259
+ // is bound to this value; Marquee's JWT validation expects a match.
260
+ u.searchParams.set("resource", params.resource);
261
+ return u.toString();
262
+ }
@@ -0,0 +1,171 @@
1
+ // Public auth surface for the Marquee share skill.
2
+ //
3
+ // This is the ONLY module later tasks (HTML render, upload) should
4
+ // import from. It wraps the vendored OAuth/PKCE machinery (see
5
+ // PROVENANCE.md) with Marquee config loading and exposes three
6
+ // operations:
7
+ //
8
+ // * `login` — one-time browser PKCE login; persists a
9
+ // session at `~/.marquee/session.json` (0600).
10
+ // * `logout` — clears the cached session.
11
+ // * `getValidToken` — returns a still-valid access token, silently
12
+ // reusing a cached session while valid and
13
+ // refreshing on expiry. If the refresh grant
14
+ // has also expired it runs a fresh browser
15
+ // login (when `interactive` is allowed) so the
16
+ // upload task gets a token without the caller
17
+ // orchestrating the re-auth.
18
+ //
19
+ // SECURITY POSTURE
20
+ // * The access + refresh tokens are NEVER returned to stdout, never
21
+ // logged, and never passed to a logging sink. `getValidToken`
22
+ // returns the token string to its in-process caller only; the
23
+ // caller (the upload task) puts it in an `Authorization` header.
24
+ // * PKCE public client — no client secret is generated, received,
25
+ // or stored anywhere in this skill.
26
+ // * JWT signatures are NOT verified here. The skill is an OAuth
27
+ // client; Marquee's Pages Functions verify. `whoami`-style
28
+ // identity readout uses the payload-only decoder in `jwt.ts`.
29
+
30
+ import { loadConfig, type SkillConfig } from "../config.js";
31
+ import { login as runLoginFlow, getValidAccessToken } from "./flow.js";
32
+ import { LoginRequiredError } from "./flow.js";
33
+ import { clearSession, type MarqueeSession } from "./session.js";
34
+
35
+ export { LoginRequiredError } from "./flow.js";
36
+ export type { MarqueeSession } from "./session.js";
37
+ export type { SkillConfig } from "../config.js";
38
+
39
+ /** A logging sink restricted to non-secret diagnostics. The auth
40
+ * layer only ever passes auth URLs and human-readable status here —
41
+ * never a token. Defaults to `console.error` (stderr). */
42
+ export type DiagnosticSink = (line: string) => void;
43
+
44
+ export interface AuthOptions {
45
+ /** Override the resolved config (resourceUrl + sessionPath).
46
+ * Defaults to `loadConfig()` which reads `MARQUEE_*` env vars. */
47
+ readonly config?: SkillConfig;
48
+ /** Non-secret diagnostic sink. Defaults to stderr. NEVER receives
49
+ * a token. */
50
+ readonly onDiagnostic?: DiagnosticSink;
51
+ /** Injected for tests. */
52
+ readonly fetcher?: typeof fetch;
53
+ readonly browserOpener?: (url: string) => Promise<void>;
54
+ /** Injected for tests — the clock `getValidToken` uses to decide
55
+ * whether the cached token is still fresh. */
56
+ readonly now?: () => number;
57
+ }
58
+
59
+ function resolveConfig(opts: AuthOptions): SkillConfig {
60
+ return opts.config ?? loadConfig();
61
+ }
62
+
63
+ function diag(opts: AuthOptions, line: string): void {
64
+ (opts.onDiagnostic ?? ((l: string) => console.error(l)))(line);
65
+ }
66
+
67
+ /**
68
+ * One-time interactive login. Opens the browser to Cloudflare Access,
69
+ * completes the PKCE exchange, and persists the session.
70
+ *
71
+ * Returns the session for diagnostics. The caller MUST NOT print
72
+ * `session.accessToken` / `session.refreshToken`.
73
+ */
74
+ export async function login(
75
+ opts: AuthOptions = {},
76
+ ): Promise<MarqueeSession> {
77
+ const cfg = resolveConfig(opts);
78
+ diag(opts, "Opening browser to authenticate with Cloudflare Access…");
79
+ diag(opts, "(if it doesn't open automatically, copy the URL below)");
80
+ const session = await runLoginFlow({
81
+ resourceUrl: cfg.resourceUrl,
82
+ sessionPath: cfg.sessionPath,
83
+ onAuthUrl: (url) => diag(opts, url),
84
+ ...(opts.fetcher !== undefined ? { fetcher: opts.fetcher } : {}),
85
+ ...(opts.browserOpener !== undefined
86
+ ? { browserOpener: opts.browserOpener }
87
+ : {}),
88
+ });
89
+ diag(opts, `Logged in. Session stored at ${cfg.sessionPath}`);
90
+ return session;
91
+ }
92
+
93
+ /**
94
+ * Clear the persisted session. Idempotent — returns `true` if a
95
+ * session existed and was removed, `false` if there was nothing to
96
+ * clear.
97
+ */
98
+ export async function logout(opts: AuthOptions = {}): Promise<boolean> {
99
+ const cfg = resolveConfig(opts);
100
+ const had = await clearSession(cfg.sessionPath);
101
+ diag(
102
+ opts,
103
+ had
104
+ ? `Logged out. Session cleared at ${cfg.sessionPath}`
105
+ : "Already logged out.",
106
+ );
107
+ return had;
108
+ }
109
+
110
+ export interface GetTokenOptions extends AuthOptions {
111
+ /**
112
+ * Whether `getValidToken` may fall back to an interactive browser
113
+ * login when there is no session, or when the refresh grant has
114
+ * expired. Defaults to `true` — the skill runs on a developer's
115
+ * machine and the whole point is a frictionless token. Set to
116
+ * `false` in non-interactive contexts (CI, tests) to get a thrown
117
+ * `LoginRequiredError` instead of a hung browser open.
118
+ */
119
+ readonly interactive?: boolean;
120
+ }
121
+
122
+ /**
123
+ * Return a still-valid Marquee access token.
124
+ *
125
+ * Resolution order:
126
+ * 1. Cached session present and the access token is comfortably
127
+ * within its lifetime → return it directly (no network).
128
+ * 2. Cached session present but the access token is expired (or
129
+ * within the refresh-skew window) → silently refresh via the
130
+ * refresh token, persist the new pair, return the fresh token.
131
+ * 3. No session, or the refresh grant itself has expired → if
132
+ * `interactive` (default), run a fresh browser PKCE login and
133
+ * return the resulting token; otherwise throw
134
+ * `LoginRequiredError`.
135
+ *
136
+ * The returned string is a bearer token. The caller is responsible
137
+ * for transmitting it ONLY in an `Authorization` header to Marquee
138
+ * over HTTPS, and for never logging it.
139
+ */
140
+ export async function getValidToken(
141
+ opts: GetTokenOptions = {},
142
+ ): Promise<string> {
143
+ const cfg = resolveConfig(opts);
144
+ const interactive = opts.interactive ?? true;
145
+ const fetcher = opts.fetcher ?? fetch;
146
+ const now = opts.now ?? Date.now;
147
+ try {
148
+ const { accessToken } = await getValidAccessToken(
149
+ cfg.sessionPath,
150
+ fetcher,
151
+ now,
152
+ );
153
+ return accessToken;
154
+ } catch (e) {
155
+ if (e instanceof LoginRequiredError) {
156
+ if (!interactive) {
157
+ // Re-throw so a non-interactive caller (CI, tests) gets a
158
+ // typed signal instead of an unexpected browser launch.
159
+ throw e;
160
+ }
161
+ // No usable session — fall back to a fresh interactive login.
162
+ diag(
163
+ opts,
164
+ "No valid Marquee session — starting a browser login…",
165
+ );
166
+ const session = await login(opts);
167
+ return session.accessToken;
168
+ }
169
+ throw e;
170
+ }
171
+ }
@@ -0,0 +1,77 @@
1
+ // Tiny payload-only JWT decoder used for identity display.
2
+ //
3
+ // Vendored from @m-kopa/launchpad-cli src/auth/jwt.ts — see
4
+ // PROVENANCE.md. Unchanged from upstream.
5
+ //
6
+ // We are NOT validating the signature. The token came back from
7
+ // Cloudflare's OAuth token endpoint over HTTPS; we trust its content
8
+ // for *display* purposes only (e.g. showing "logged in as <email>").
9
+ //
10
+ // Marquee's Pages Functions are the authority for actual authz — the
11
+ // skill is a client. The marquee repo's ANTI-PATTERNS.md is explicit:
12
+ // "Do not implement custom JWT verification." This decoder NEVER
13
+ // verifies a signature; it only base64-decodes the payload segment.
14
+ // If real client-side validation were ever needed (it should not be),
15
+ // the answer is `jose`, not crypto rolled here.
16
+
17
+ export interface JwtPayload {
18
+ /** Cf Access user-scoped JWT subject — the stable Entra UUID.
19
+ * Marquee keys ownership off this `sub`, never off `email`. */
20
+ readonly sub?: string;
21
+ /** Email / UPN claim. Cloudflare populates this from the IdP. */
22
+ readonly email?: string;
23
+ /** Cf Access aud (the App's UUID). */
24
+ readonly aud?: string | readonly string[];
25
+ /** Issued-at + expiry, seconds since epoch (RFC 7519). */
26
+ readonly iat?: number;
27
+ readonly exp?: number;
28
+ /** Token type — "app" for Cf Access app tokens. */
29
+ readonly type?: string;
30
+ /** Anything else the IdP / Cf added; we don't validate it. */
31
+ readonly [k: string]: unknown;
32
+ }
33
+
34
+ export class JwtParseError extends Error {
35
+ readonly code = "jwt_parse_error" as const;
36
+ }
37
+
38
+ /**
39
+ * Decode the payload of a JWT. Returns `null` for inputs that
40
+ * don't look like a JWT at all (e.g. an opaque token). Throws
41
+ * `JwtParseError` only for shapes that look JWT-like but have an
42
+ * undecodable payload — callers should treat both null and throw
43
+ * as "show empty identity".
44
+ */
45
+ export function decodeJwtPayload(token: string): JwtPayload | null {
46
+ const parts = token.split(".");
47
+ if (parts.length !== 3) return null;
48
+ const payload = parts[1] ?? "";
49
+ let json: string;
50
+ try {
51
+ json = Buffer.from(b64UrlToB64(payload), "base64").toString("utf8");
52
+ } catch (e) {
53
+ throw new JwtParseError(
54
+ `could not base64-decode JWT payload: ${describe(e)}`,
55
+ );
56
+ }
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(json);
60
+ } catch (e) {
61
+ throw new JwtParseError(`JWT payload is not JSON: ${describe(e)}`);
62
+ }
63
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
64
+ throw new JwtParseError(`JWT payload is not an object`);
65
+ }
66
+ return parsed as JwtPayload;
67
+ }
68
+
69
+ function b64UrlToB64(s: string): string {
70
+ // Pad to length-multiple-of-4 + swap URL-safe alphabet back.
71
+ const padded = s.padEnd(s.length + ((4 - (s.length % 4)) % 4), "=");
72
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
73
+ }
74
+
75
+ function describe(e: unknown): string {
76
+ return e instanceof Error ? e.message : String(e);
77
+ }
@@ -0,0 +1,79 @@
1
+ // PKCE (RFC 7636) primitives, with one Cloudflare-specific deviation.
2
+ //
3
+ // Vendored from @m-kopa/launchpad-cli src/auth/pkce.ts — see
4
+ // PROVENANCE.md. Unchanged from upstream: the protocol logic is
5
+ // generic and not Marquee-specific.
6
+ //
7
+ // **The quirk.** The RFC says the `code_challenge` is base64url
8
+ // (i.e. `[A-Za-z0-9_-]*`). Cloudflare's Access OAuth server rejects
9
+ // challenges that don't START with `[a-zA-Z0-9]` — so a leading `-`
10
+ // or `_` in the base64url output silently fails. Strategy: generate;
11
+ // if the challenge starts with `-` or `_`, throw away both the
12
+ // verifier AND the challenge and regenerate. We do NOT reuse the
13
+ // verifier with a salt — that would not produce a fresh challenge.
14
+ //
15
+ // Verifier length: 32 bytes of entropy → 43 base64url chars (no
16
+ // padding). Within the RFC's 43–128-char window.
17
+
18
+ import { randomBytes, createHash } from "node:crypto";
19
+
20
+ export interface PkcePair {
21
+ /** Plain-text random secret, kept on the client. */
22
+ readonly verifier: string;
23
+ /** S256 hash of the verifier, sent in the authorization URL. */
24
+ readonly challenge: string;
25
+ }
26
+
27
+ /** Hard cap on regeneration attempts — defends against an
28
+ * algorithmic mistake silently spinning forever. The probability
29
+ * of a `-` or `_` first byte is ~2/64 per attempt, so even 5 tries
30
+ * is enough for ~6-nines reliability; we pick a higher bound so a
31
+ * pathological RNG (or a future encoding change) surfaces as a
32
+ * clear error rather than a hang. */
33
+ export const MAX_PKCE_REGEN_ATTEMPTS = 32;
34
+
35
+ export class PkceGenerationError extends Error {
36
+ readonly code = "pkce_generation_failed" as const;
37
+ }
38
+
39
+ /**
40
+ * Generate a fresh PKCE pair satisfying both RFC 7636 and the
41
+ * Cloudflare Access "challenge must start with [a-zA-Z0-9]" rule.
42
+ * Pure (modulo `randomBytes`) — caller can re-test by injecting
43
+ * a deterministic RNG via the optional `rng` parameter.
44
+ */
45
+ export function generatePkcePair(
46
+ rng: (size: number) => Uint8Array = (s) => new Uint8Array(randomBytes(s)),
47
+ ): PkcePair {
48
+ for (let attempt = 0; attempt < MAX_PKCE_REGEN_ATTEMPTS; attempt++) {
49
+ const verifierBytes = rng(32);
50
+ const verifier = base64Url(verifierBytes);
51
+ const challenge = sha256Base64Url(verifier);
52
+ if (/^[a-zA-Z0-9]/.test(challenge)) {
53
+ return { verifier, challenge };
54
+ }
55
+ // Else: leading `-` or `_` — re-roll. Discarding the verifier
56
+ // entirely is intentional: re-hashing the same verifier with a
57
+ // salt would mean the server-side `verifier` derivation no
58
+ // longer matches the challenge.
59
+ }
60
+ throw new PkceGenerationError(
61
+ `could not produce a Cloudflare-acceptable PKCE challenge after ${MAX_PKCE_REGEN_ATTEMPTS} attempts; check the RNG`,
62
+ );
63
+ }
64
+
65
+ /** S256 challenge for an arbitrary verifier. Exposed for tests. */
66
+ export function sha256Base64Url(verifier: string): string {
67
+ const hash = createHash("sha256").update(verifier).digest();
68
+ return base64Url(hash);
69
+ }
70
+
71
+ /** RFC 4648 §5 unpadded base64url. */
72
+ function base64Url(bytes: Uint8Array | Buffer): string {
73
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
74
+ return buf
75
+ .toString("base64")
76
+ .replace(/\+/g, "-")
77
+ .replace(/\//g, "_")
78
+ .replace(/=+$/, "");
79
+ }
@@ -0,0 +1,87 @@
1
+ // Dynamic client registration (RFC 7591) for Cloudflare Access
2
+ // Managed OAuth.
3
+ //
4
+ // Vendored from @m-kopa/launchpad-cli src/auth/registration.ts — see
5
+ // PROVENANCE.md. Marquee adaptation: `client_name` is
6
+ // "marquee-share-skill" (was "launchpad-cli") so the registered
7
+ // public client is identifiable in Cloudflare Access logs.
8
+ //
9
+ // The first time a skill installation runs `login`, it POSTs a tiny
10
+ // JSON body to the OAuth server's `registration_endpoint` announcing
11
+ // itself as a PUBLIC client (`token_endpoint_auth_method: "none"`)
12
+ // with the loopback redirect URI it just bound. The server returns a
13
+ // `client_id` we use for this session and persist alongside the
14
+ // tokens.
15
+ //
16
+ // We do NOT persist — or even receive and keep — a `client_secret`.
17
+ // Public clients don't have one; PKCE replaces it. This is the
18
+ // "PKCE public client — no client secret anywhere in the shipped
19
+ // skill" requirement, enforced structurally: `RegistrationResult`
20
+ // carries only `clientId`.
21
+
22
+ export interface RegistrationResult {
23
+ readonly clientId: string;
24
+ }
25
+
26
+ export class RegistrationError extends Error {
27
+ readonly code = "registration_error" as const;
28
+ }
29
+
30
+ export async function registerClient(
31
+ registrationEndpoint: string,
32
+ redirectUri: string,
33
+ fetcher: typeof fetch = fetch,
34
+ ): Promise<RegistrationResult> {
35
+ const body = {
36
+ client_name: "marquee-share-skill",
37
+ redirect_uris: [redirectUri],
38
+ // Public client: no secret. PKCE is the proof-of-possession.
39
+ token_endpoint_auth_method: "none",
40
+ grant_types: ["authorization_code", "refresh_token"],
41
+ response_types: ["code"],
42
+ };
43
+ let res: Response;
44
+ try {
45
+ res = await fetcher(registrationEndpoint, {
46
+ method: "POST",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ accept: "application/json",
50
+ },
51
+ body: JSON.stringify(body),
52
+ });
53
+ } catch (e) {
54
+ throw new RegistrationError(`registration: network error: ${describe(e)}`);
55
+ }
56
+ if (!res.ok) {
57
+ const detail = await res.text().catch(() => "");
58
+ throw new RegistrationError(
59
+ `registration: ${registrationEndpoint} returned HTTP ${res.status}: ${detail.slice(0, 200)}`,
60
+ );
61
+ }
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = await res.json();
65
+ } catch (e) {
66
+ throw new RegistrationError(
67
+ `registration: non-JSON response: ${describe(e)}`,
68
+ );
69
+ }
70
+ if (
71
+ parsed === null ||
72
+ typeof parsed !== "object" ||
73
+ typeof (parsed as { client_id?: unknown }).client_id !== "string"
74
+ ) {
75
+ throw new RegistrationError(
76
+ `registration: response missing string client_id`,
77
+ );
78
+ }
79
+ // Deliberately read ONLY client_id. Even if the server echoes a
80
+ // `client_secret`, we never capture it — a public client has no
81
+ // use for one and it must not reach disk.
82
+ return { clientId: (parsed as { client_id: string }).client_id };
83
+ }
84
+
85
+ function describe(e: unknown): string {
86
+ return e instanceof Error ? e.message : String(e);
87
+ }