@m-kopa/launchpad-cli 0.31.0 → 0.32.1

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 CHANGED
@@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html);
7
7
  pre-1.0 minor bumps may carry breaking changes per ADR 0005.
8
8
 
9
+ ## 0.32.1 — 2026-06-17
10
+
11
+ Fix: `launchpad skills install/update` now installs `launchpad-identity`. The
12
+ 0.32.0 skill shipped as a `skills/` directory but was missing from the
13
+ installer's `BUNDLED_SKILLS` list, so it was never copied into
14
+ `~/.claude/skills/`. Added it, and added an anti-drift test that fails if any
15
+ `launchpad-*` skill directory is missing from `BUNDLED_SKILLS` (so this can't
16
+ recur). Re-run `launchpad update` (or `launchpad skills update`) to pick it up.
17
+
18
+ ## 0.32.0 — 2026-06-17
19
+
20
+ New Claude Code skill: `/launchpad-identity` (sp-idnt7k). Teaches app authors how
21
+ to read the signed-in user's identity inside a Launchpad app — verify the
22
+ gateway-forwarded `X-Launchpad-User-Assertion` in a Pages Function with
23
+ `@m-kopa/platform-auth` (RS256 signature + issuer + audience pinned to the app's
24
+ own host), **fail closed**, and read `{ sub, email, name }`. The first
25
+ how-to-build (dev) skill, distinct from the CLI-verb operator skills.
26
+
27
+ - Covers both app shapes: build-less `type: static` (vendored verifier, documented
28
+ as interim with a staleness warning + a pointer to the maintained-build
29
+ follow-up) and `react+api` (`import @m-kopa/platform-auth`).
30
+ - Teaches the `secret_text` env requirement (`GATEWAY_ISSUER` / `GATEWAY_AUD` /
31
+ `GATEWAY_JWKS_URL`) and the plain-text → deploy-wipe → fail-closed-anonymous
32
+ failure mode; warns against unsigned `decodeJwtPayload` token-reading.
33
+ - Examples are extracted from the deployed, verified peoplemgr app and pinned by a
34
+ drift-guard test so they cannot silently diverge from the live contract.
35
+ - Docs: extends the Auth & identity concept page; no new CLI command.
36
+
9
37
  ## 0.31.0 — 2026-06-17
10
38
 
11
39
  Telemetry: repoint at a fresh PostHog project (sp-phcli2). The prior project was
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ var __toESM = (mod, isNodeMode, target) => {
19
19
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  // src/version.ts
22
- var CLI_VERSION = "0.31.0";
22
+ var CLI_VERSION = "0.32.1";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -10260,6 +10260,7 @@ var BUNDLED_SKILLS = [
10260
10260
  "launchpad-content-pr",
10261
10261
  "launchpad-status",
10262
10262
  "launchpad-destroy",
10263
+ "launchpad-identity",
10263
10264
  "marquee-share"
10264
10265
  ];
10265
10266
  function isBundleManaged(name) {
@@ -1,4 +1,11 @@
1
1
  import type { Command } from "../dispatcher.js";
2
+ /**
3
+ * Skills shipped in this version of the CLI. Must match the `launchpad-*`
4
+ * dirs under `<pkg>/skills/` (plus the cross-repo `marquee-share`); the
5
+ * "bundled list matches the skills dir" test in `tests/skills-command.test.ts`
6
+ * enforces this so a newly-added skill dir can't silently go un-installed.
7
+ */
8
+ export declare const BUNDLED_SKILLS: readonly ["launchpad-onboard", "launchpad-deploy", "launchpad-deploy-status", "launchpad-content-pr", "launchpad-status", "launchpad-destroy", "launchpad-identity", "marquee-share"];
2
9
  export declare const skillsCommand: Command;
3
10
  interface InstallEnv {
4
11
  readonly bundleDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/commands/skills.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAoCjE,eAAO,MAAM,aAAa,EAAE,OAK3B,CAAC;AA2CF,UAAU,UAAU;IAClB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,UAAU,CAQ9C"}
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/commands/skills.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AASjE;;;;;GAKG;AACH,eAAO,MAAM,cAAc,uLAajB,CAAC;AAcX,eAAO,MAAM,aAAa,EAAE,OAK3B,CAAC;AA2CF,UAAU,UAAU;IAClB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,UAAU,CAQ9C"}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.31.0";
1
+ export declare const CLI_VERSION = "0.32.1";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m-kopa/launchpad-cli",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "description": "Launchpad CLI — clone / deploy / review / merge against Launchpad-managed apps. Talks to the portal-bot endpoints (SCOPE-M-760 / T4).",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/README.md CHANGED
@@ -15,11 +15,38 @@ skills/
15
15
  ├── launchpad-content-pr/SKILL.md
16
16
  ├── launchpad-status/SKILL.md
17
17
  ├── launchpad-destroy/SKILL.md
18
+ ├── launchpad-identity/SKILL.md # dev / how-to-build skill (see below)
18
19
  └── marquee-share/ # vendored from M-KOPA/marquee, do
19
20
  # not hand-edit — managed by
20
21
  # scripts/sync-marquee-share-skill.sh
21
22
  ```
22
23
 
24
+ ## Two kinds of skill: operator vs dev (how-to-build)
25
+
26
+ Most skills here are **operator skills** — they wrap a `launchpad` CLI verb
27
+ and walk the user through running it (`launchpad-deploy`, `-status`,
28
+ `-content-pr`, `-destroy`, `-onboard`). Their bash blocks invoke the CLI, so
29
+ they live under the Shell Contract.
30
+
31
+ `launchpad-identity` is the first **dev / how-to-build skill**: it teaches an
32
+ app author how to write *application code* (here: verifying the
33
+ gateway-forwarded identity in a Pages Function), not how to run a CLI verb. The
34
+ convention for this kind:
35
+
36
+ - It still ships through `launchpad skills install` and is registered in the
37
+ same gate enumerations (`tests/skill-contract.test.ts`,
38
+ `check-skill-bash-dialect.sh`, `check-skill-bash-parse.sh`,
39
+ `sync-skill-contract.sh`) so `version:` lock-step + the Shell Contract apply.
40
+ - Its code examples are **extracted from a deployed, verified app** (not
41
+ transcribed) and pinned by a drift-guard test
42
+ (`tests/skill-identity-extraction.test.ts`), because a how-to-build skill
43
+ that teaches a security path must not drift from the live contract.
44
+ - It stays a **thin pointer to one source of truth** (the contract doc +
45
+ `@m-kopa/platform-auth` + the verified example), so it can't fork into a
46
+ divergent copy.
47
+
48
+ The next dev-skill should follow this shape.
49
+
23
50
  ## Cross-platform shell contract
24
51
 
25
52
  Claude Code's `Bash` tool **always** runs `bash`, on every OS — on
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-content-pr
3
3
  description: Push a content change to a Launchpad app via `launchpad deploy` and verify it shipped via `launchpad status`. Covers the post-first-deploy iteration loop (edit → deploy → verify) — subsequent deploys commit directly to the app repo's main and the Pages build runs asynchronously, so verification is its own step. Use when someone says "push a content change", "ship an update", "/launchpad-content-pr", "verify my deploy", or after `/launchpad-deploy` reports `done` and they want to follow up with an edit.
4
- version: 0.31.0
4
+ version: 0.32.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-deploy
3
3
  description: Walk a Launchpad user through deploying an app from their local working directory (Model A — `launchpad init` + `launchpad deploy`). Wraps the CLI verbs end-to-end: detects the app shape, scaffolds `launchpad.yaml`, resolves the allowed Entra group via `launchpad groups`, bundles the CWD via `launchpad deploy`, and watches the rollout via `launchpad status`. Use when someone says "deploy a new app", "ship my app to Launchpad", "/launchpad-deploy", "I have an app locally — get it on Launchpad", or any variant. Resume/abandon for legacy in-flight provisioning is at the bottom.
4
- version: 0.31.0
4
+ version: 0.32.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-deploy-status
3
3
  description: Show the current provisioning stage + failure reason for a Launchpad app via `launchpad status` (Model A drift + deployment_verified) and `launchpad apps` (lifecycle bucket). Renders the M-892 stage trace for in-flight provisioning, and is the canonical home for `launchpad recover` (repair a terminal-failed app record that is actually live). Use when someone says "what's the status of demo-X", "/launchpad-deploy-status", "is my deploy stuck", "my app says failed but it's serving", or after `/launchpad-deploy` reports a non-`done` terminal stage.
4
- version: 0.31.0
4
+ version: 0.32.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-destroy
3
3
  description: Tear down a Launchpad app end-to-end via `launchpad destroy` — Cloudflare Pages project, edge-auth wiring (gateway KV/audience entries, or the Access app for `auth: access` apps), custom hostname, platform-repo TF, and the app repo (archive-renamed). Owner-only verb with a two-step destructive confirmation. Use when someone says "destroy this app", "/launchpad-destroy", "tear down `<slug>`", "delete the app", or asks to clean up a smoke-test / orphan / retired app.
4
- version: 0.31.0
4
+ version: 0.32.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -0,0 +1,307 @@
1
+ ---
2
+ name: launchpad-identity
3
+ description: Teach an app author how to use the signed-in user's identity inside a Launchpad app — read the gateway-forwarded X-Launchpad-User-Assertion in a Pages Function, VERIFY it with @m-kopa/platform-auth (fail-closed), and show who's logged in (sub/email/name). Use when someone says "who is logged in", "show the current user", "get the user's email in my app", "auth in my launchpad app", "read the user identity", "/launchpad-identity", or is wiring up an /api/me for a gateway-fronted app.
4
+ version: 0.32.1
5
+ ---
6
+
7
+ <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
8
+ ## Shell contract — read this first
9
+
10
+ Every fenced `bash` block below MUST be sent to the `Bash` tool **verbatim**.
11
+ Do not rewrite into PowerShell, cmd, zsh-isms, or "equivalent" forms.
12
+
13
+ - macOS / Linux: the `Bash` tool runs system bash.
14
+ - Windows: the `Bash` tool runs Git for Windows (MSYS) bash. `$HOME`,
15
+ forward slashes, `test -f`, `command -v`, heredocs, and `[[ … ]]` all
16
+ work. There is no reason to translate to `Test-Path`, `$env:USERPROFILE`,
17
+ `Get-Content`, `Where-Object`, `Get-ChildItem`, or backslash paths —
18
+ doing so will fail with `/usr/bin/bash: syntax error`.
19
+
20
+ If a step genuinely needs OS branching, branch *inside* bash:
21
+
22
+ ```bash
23
+ case "$(uname -s)" in
24
+ Darwin) : ;;
25
+ Linux) : ;;
26
+ MINGW*|MSYS*|CYGWIN*) : ;;
27
+ esac
28
+ ```
29
+ <!-- END shell-contract -->
30
+
31
+ # /launchpad-identity
32
+
33
+ How an app author reads **who is signed in** inside a Launchpad app, the
34
+ right way: verify the gateway-forwarded identity token and fail closed.
35
+
36
+ This is a **how-to-build** skill — it writes app code, it does not wrap a
37
+ `launchpad` CLI verb. (`launchpad whoami` reports your *CLI session*, not the
38
+ end-user signed into your deployed app — different thing; see § Don'ts.)
39
+
40
+ ## What you get, in one sentence
41
+
42
+ A Launchpad app behind the Entra-OIDC gateway receives the signed-in user's
43
+ **already-verified** identity as a signed token on every request; your app
44
+ verifies that token and reads `{ sub, email, name }`. That is the whole job.
45
+
46
+ ## Prerequisite — you need a Pages Function (not bare static HTML)
47
+
48
+ **Bare static HTML cannot read request headers**, so it cannot see the
49
+ identity token. To read identity your app needs a server-side **Pages
50
+ Function** (a file under `functions/`). Two app shapes qualify:
51
+
52
+ - **`type: static` with a `functions/` directory** — a build-less static app
53
+ that still ships Pages Functions (this is what peoplemgr does).
54
+ - **`react+api`** (or any app with a build) — your `/api/*` is already a
55
+ Pages Function.
56
+
57
+ If your app is pure HTML/CSS/JS with no `functions/`, you must add a Pages
58
+ Function first. There is no client-only way to read the verified identity.
59
+
60
+ ## The contract (read this before copying code)
61
+
62
+ The platform gateway authenticates the user against Entra, then forwards its
63
+ **own RS256-signed user token** to your origin under a dedicated header:
64
+
65
+ ```
66
+ X-Launchpad-User-Assertion: <RS256 JWT minted by the gateway>
67
+ ```
68
+
69
+ The canonical contract — claims, trust model, the header name — lives in ONE
70
+ place: **`docs/identity-forwarding-to-confined-origins.md`** (sp-xush3d) in
71
+ the launchpad-platform repo. This skill is the worked example; that doc is
72
+ the source of truth. If they ever disagree, the contract doc wins.
73
+
74
+ > **Trust by SIGNATURE, not by network position.** Do **not** trust the
75
+ > header just because it arrived. Cloudflare's origin-confinement locks the
76
+ > origin, but it is *punctured* by the unauthenticated `/.well-known/*` path
77
+ > and any future non-gateway route. The only thing that makes the token
78
+ > trustworthy is that **you verified its RS256 signature** against the
79
+ > gateway JWKS. A forged header on a bypass path fails verification — and you
80
+ > reject it. That is why a signed token exists instead of a plain header.
81
+
82
+ ### The two things you MUST get right
83
+
84
+ 1. **Verify, never decode-and-trust.** Use `@m-kopa/platform-auth`'s
85
+ `verifyAccessJwt` (it checks the RS256 signature, issuer, and audience).
86
+ **Never** read claims out of the token with an unsigned
87
+ `decodeJwtPayload` / base64-split — an unverified payload is attacker-controlled.
88
+ 2. **Pin the audience to YOUR app's host.** Verification must require
89
+ `audience = <your app's hostname>`. If you omit the audience check or share
90
+ one audience across apps, a token minted for app A is **replayable** against
91
+ app B (cross-app token replay). Audience-pinning is what stops that.
92
+
93
+ ## The canonical Pages Function — `functions/api/me.js`
94
+
95
+ Lifted from the **deployed-and-verified** peoplemgr app
96
+ (`repos/m-kopa-peoplemgr-hub/functions/api/me.js`). Copy it; change only the
97
+ app-specific bits called out below.
98
+
99
+ ```js
100
+ import { verifyAccessJwt, createJwksCache } from '../_lib/platform-auth.mjs';
101
+
102
+ const IDENTITY_HEADER = 'X-Launchpad-User-Assertion';
103
+
104
+ // Memoise the JWKS cache per isolate (env is only available per-request),
105
+ // keyed by URL so a config change rebuilds it.
106
+ let _jwks = null;
107
+ let _jwksUrl = null;
108
+ function jwksFor(url) {
109
+ if (!_jwks || _jwksUrl !== url) {
110
+ _jwks = createJwksCache(url);
111
+ _jwksUrl = url;
112
+ }
113
+ return _jwks;
114
+ }
115
+
116
+ function json(body) {
117
+ return new Response(JSON.stringify(body), {
118
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
119
+ });
120
+ }
121
+
122
+ // Fail-closed anonymous result. The shape your client gates on.
123
+ function anonymous() {
124
+ return json({ authenticated: false, email: null, name: null, sub: null });
125
+ }
126
+
127
+ export async function onRequestGet(context) {
128
+ const { request, env } = context;
129
+
130
+ const token = request.headers.get(IDENTITY_HEADER);
131
+ if (!token) return anonymous(); // no token → anonymous / public path
132
+
133
+ const issuer = env.GATEWAY_ISSUER; // gateway iss
134
+ const audience = env.GATEWAY_AUD; // this app's aud (pin to YOUR host)
135
+ const jwksUrl =
136
+ env.GATEWAY_JWKS_URL ||
137
+ (issuer ? `${issuer.replace(/\/$/, '')}/.well-known/jwks.json` : null);
138
+
139
+ // Misconfigured verify env → fail CLOSED. Never trust an unverifiable token.
140
+ if (!jwksUrl || !issuer || !audience) return anonymous();
141
+
142
+ try {
143
+ const user = await verifyAccessJwt(token, {
144
+ teamDomain: issuer, // iss
145
+ audience, // aud (this app) — the replay guard
146
+ jwks: jwksFor(jwksUrl),
147
+ });
148
+ return json({
149
+ authenticated: true,
150
+ sub: user.sub,
151
+ email: user.preferredUsername, // tenant-bound UPN (the verified identity)
152
+ name: user.name || null, // display name (absent → null; never gates)
153
+ });
154
+ } catch {
155
+ // Forged / expired / wrong-signature / wrong-aud / JWKS failure → anonymous,
156
+ // NEVER the (possibly forged) user. This is the load-bearing fail-closed.
157
+ return anonymous();
158
+ }
159
+ }
160
+ ```
161
+
162
+ What the verified `user` carries (from the contract doc):
163
+
164
+ | Field returned | From | Notes |
165
+ |---|---|---|
166
+ | `sub` | `user.sub` | Entra `oid` — stable identifier. |
167
+ | `email` | `user.preferredUsername` | Tenant-bound UPN — the stable email-equivalent. |
168
+ | `name` | `user.name` | Display-only (Entra `profile` scope). Fail-soft: `null` if absent — never gate on it. |
169
+
170
+ > **No groups, no authz here.** This mechanism forwards `sub`/`email`/`name`
171
+ > only — it is read-only identity for **attribution** (analytics, audit,
172
+ > personalisation), **not** an authorization input. Group membership is the
173
+ > gateway's **login-time snapshot**; never make an access decision from these
174
+ > values. Enforce authorization at the gateway (allowed groups) and
175
+ > server-side, not from `/api/me`.
176
+
177
+ ## Reading it from the client
178
+
179
+ Your front-end calls `/api/me` and personalises on the result. Keep the same
180
+ fail-closed posture: treat `authenticated: false` as anonymous.
181
+
182
+ ```js
183
+ const me = await fetch('/api/me').then((r) => r.json());
184
+ if (me.authenticated) {
185
+ // show who's logged in
186
+ document.querySelector('#who').textContent = `${me.name ?? me.email}`;
187
+ // e.g. analytics identify — attribution only, never authorization
188
+ // posthog.identify(me.sub, { email: me.email, name: me.name });
189
+ } else {
190
+ // anonymous / public view
191
+ }
192
+ ```
193
+
194
+ ## The two app shapes
195
+
196
+ ### (a) build-less `type: static` — vendored verifier (INTERIM)
197
+
198
+ A build-less static app has **no `package.json` and no install step at
199
+ deploy**, so its Pages Functions cannot `import` an npm package by name. The
200
+ verifier is **pre-bundled** into a committed file and imported by relative
201
+ path — that is the `import … from '../_lib/platform-auth.mjs'` in the example
202
+ above. To produce that file, esbuild `@m-kopa/platform-auth` into a single
203
+ ESM bundle; the recipe lives in
204
+ `repos/m-kopa-peoplemgr-hub/functions/_lib/README.md`.
205
+
206
+ > ⚠️ **Vendoring is INTERIM — a pinned copy goes stale.** A hand-vendored
207
+ > `platform-auth.mjs` is frozen at the version you bundled. **A security fix
208
+ > to the verifier does NOT reach it** until someone re-bundles and redeploys.
209
+ > Pin the version in a `_lib/README.md`, watch `@m-kopa/platform-auth`
210
+ > releases, and re-vendor on a security advisory. The maintained replacement
211
+ > for this whole pattern is tracked as **sp-pubvf3** (publish a maintained
212
+ > static/Pages verifier build so a fix propagates via a version bump). Prefer
213
+ > it once it lands; until then, vendoring is the documented reality — with
214
+ > this staleness caveat attached.
215
+
216
+ ### (b) `react+api` / any app with a build — import the package
217
+
218
+ If your app has a build step, drop the vendored file and import the package
219
+ directly:
220
+
221
+ ```js
222
+ import { verifyAccessJwt, createJwksCache } from '@m-kopa/platform-auth';
223
+ ```
224
+
225
+ Everything else — the header, the verify call, the fail-closed shape, the env
226
+ vars — is identical to the example above.
227
+
228
+ ## Required env vars — `secret_text`, MANDATORY
229
+
230
+ Verification needs three env vars on your app. **Set them as `secret_text`,
231
+ not plain text:**
232
+
233
+ | Var | Value | Notes |
234
+ |---|---|---|
235
+ | `GATEWAY_ISSUER` | `https://auth.launchpad.m-kopa.us` | the gateway token issuer (`iss`) |
236
+ | `GATEWAY_AUD` | `<your-slug>.launchpad.m-kopa.us` | **your app's** audience (`aud`) — the replay guard |
237
+ | `GATEWAY_JWKS_URL` | `https://auth.launchpad.m-kopa.us/.well-known/jwks.json` | optional — derived from `GATEWAY_ISSUER` if unset |
238
+
239
+ > ⚠️ **Why `secret_text` is mandatory — the wipe → fail-closed chain.** If you
240
+ > set these as **plain-text** env, the next `launchpad deploy` **wipes them**
241
+ > (only `secret_text` vars survive a deploy — the CF-Access-env-vars-must-ship
242
+ > runbook). With the env gone, `issuer`/`audience` are `undefined`, the
243
+ > `if (!jwksUrl || !issuer || !audience) return anonymous()` guard fires, and
244
+ > **every** user silently goes anonymous in production. The code is correct —
245
+ > it fails *closed*, never open — but identity is dark until you re-add the
246
+ > vars. Set them `secret_text` from the start and this never happens. Ship the
247
+ > env in the **same** deploy as the consumer, or verification is dark.
248
+
249
+ The canonical names are **`GATEWAY_ISSUER` / `GATEWAY_AUD` / `GATEWAY_JWKS_URL`**
250
+ — what the deployed app reads and what is live on Cloudflare. (You may see the
251
+ illustrative names `AUTH_ISS` / `APP_AUD` / `JWKS_URL` in older contract-doc
252
+ snippets — the deployed convention is the `GATEWAY_*` set; use it. Note: it is
253
+ `GATEWAY_AUD`, **not** `GATEWAY_AUDIENCE`.)
254
+
255
+ ## Anti-pattern — do NOT do this
256
+
257
+ Some apps "read" the token by base64-decoding its payload without verifying
258
+ the signature:
259
+
260
+ ```js
261
+ // ❌ WRONG — unsigned, attacker-controlled. Never do this.
262
+ // const payload = decodeJwtPayload(token); // no signature check!
263
+ // const email = payload.email; // trusting a forgeable claim
264
+ ```
265
+
266
+ An unverified payload can be forged by anyone who can reach your origin
267
+ (including via the `/.well-known/*` bypass). **Always** `verifyAccessJwt`
268
+ (signature + issuer + audience) and **fail closed** on any error. If you find
269
+ `decodeJwtPayload` on an auth path, it is a bug — replace it with verification.
270
+
271
+ ## Checklist before you ship
272
+
273
+ - [ ] App has a **Pages Function** (not bare static HTML).
274
+ - [ ] Reads `X-Launchpad-User-Assertion` and **verifies** with
275
+ `verifyAccessJwt` (signature + issuer + **audience pinned to your host**).
276
+ - [ ] **Fails closed** to anonymous on missing token, missing env, or any
277
+ verify error — never returns the unverified user.
278
+ - [ ] No unsigned `decodeJwtPayload` on the auth path.
279
+ - [ ] `GATEWAY_ISSUER` / `GATEWAY_AUD` / `GATEWAY_JWKS_URL` set as
280
+ **`secret_text`**, shipped in the **same** deploy.
281
+ - [ ] No authorization decision is made from `/api/me` — attribution only.
282
+
283
+ ## Don'ts
284
+
285
+ - Do **not** confuse `launchpad whoami` (your CLI session) with in-app
286
+ identity. This skill is about the **end-user signed into your deployed
287
+ app**, read server-side from the forwarded token.
288
+ - Do **not** trust the header without verifying the signature, and do **not**
289
+ omit the audience check — both open you to forgery / cross-app replay.
290
+ - Do **not** gate access on `/api/me` claims — they are attribution, not
291
+ authorization. Authorization is the gateway's allowed-groups + your
292
+ server-side checks.
293
+ - Do **not** invent new claim names — `sub` / `email` (`preferredUsername`) /
294
+ `name` are the only fields this channel carries. Directory attributes
295
+ (jobTitle, department, …) are a separate, forthcoming channel (sp-dirat9) —
296
+ not available here yet.
297
+
298
+ ## Related
299
+
300
+ - **`docs/identity-forwarding-to-confined-origins.md`** — the canonical
301
+ contract (claims, trust model, header). Source of truth.
302
+ - [Concepts → Auth & groups](https://get.launchpad.m-kopa.us/concepts/auth-groups)
303
+ — the gateway, the first-visit bounce, and `/api/me`.
304
+ - `repos/m-kopa-peoplemgr-hub/functions/api/me.js` — the deployed worked
305
+ example this skill is lifted from.
306
+ - **sp-pubvf3** — the maintained static verifier build that will retire
307
+ hand-vendoring.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-onboard
3
3
  description: One-time setup for the Launchpad CLI + Claude Code skill bundle. Verifies the `launchpad` CLI is installed and current, runs `launchpad whoami` to confirm the session is fresh, and checks the bundled skills are installed and in lock-step with the CLI. Idempotent — safe to re-run any time. Use when someone says "set me up for Launchpad", "I just got a new machine and want to use Launchpad", "/launchpad-onboard", or any of the other launchpad-* skills fails on a prereq check.
4
- version: 0.31.0
4
+ version: 0.32.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-status
3
3
  description: Show whether a Launchpad app's local launchpad.yaml matches what's deployed, and read the deployed manifest. Wraps `launchpad pull` (fetch deployed YAML) and `launchpad status` (drift report). Use when someone says "is my app in sync", "what's deployed", "show drift", "/launchpad-status", "/launchpad-pull", or after `launchpad deploy` to verify the change landed.
4
- version: 0.31.0
4
+ version: 0.32.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->