@m-kopa/launchpad-cli 0.40.0 → 0.41.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,85 @@ 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.41.1 — 2026-06-30
10
+
11
+ Docs & skills correctness fix: **any Entra group in the tenant can gate an
12
+ app — it does not need to be assigned to the Launchpad enterprise
13
+ application** (PS-1733, live 2026-06-25). The docs and bundled skills still
14
+ described the retired "assigned-only" rule, which led to users being wrongly
15
+ told to ask IT to assign a group (e.g. `G_information_security`) before they
16
+ could use it. Corrected across the auth concept, manifest reference,
17
+ `first-app`/`auth` guides, and the `validate` / `groups` / `deploy` command
18
+ pages, and in the `launchpad-deploy` / `launchpad-content-pr` skills. Key
19
+ clarification: `launchpad groups` and `launchpad validate --strict-groups`
20
+ resolve only against the **assigned** set and will **falsely reject** a valid
21
+ unassigned group — use **`launchpad deploy --dry-run`** to pre-check any
22
+ tenant group (it resolves against the whole tenant, exactly as the real
23
+ submit). A reference is rejected only when the group is unknown, ineligible (a
24
+ distribution list / hidden-membership group), or an ambiguous display name.
25
+
26
+ ## 0.41.0 — 2026-06-28
27
+
28
+ Docs & skills: documented the **app catalogue** — a new
29
+ [`concepts/catalogue`](./docs/concepts/catalogue.md) page (listed by default,
30
+ opt out with `catalogue: { listed: false }`, and why **listing is not access**),
31
+ plus a **runtime-vs-administrative access** section (owner / co-owner / editor)
32
+ in the auth concept. Bundled skills refreshed (synced in place by
33
+ `launchpad update`): the `launchpad-status` skill now points access-drift
34
+ reconciliation at `launchpad deploy --apply`, `launchpad-deploy` notes the
35
+ catalogue opt-out at scaffold time, and `launchpad-content-pr` surfaces
36
+ `redeploy` as a general verb (activate a pushed secret on a Pages tier / force a
37
+ clean rebuild).
38
+
39
+ Feature (PS-1755 / PS-1762, T3): **`launchpad deploy` now refuses to ship
40
+ content when a gateway app's declared access groups don't match what the gateway
41
+ enforces** — the bot checks this server-side (unbypassable) on every deploy to
42
+ an existing gateway app and rejects with a clear message naming both directions
43
+ (over-grant + declared-not-enforced). `launchpad deploy` ships content only and
44
+ can't reconcile access; change it via the gated Terraform route, then redeploy.
45
+ The refuse is gated by a **default-OFF** server-side kill-switch, so the
46
+ operational rollout controls go-live and benign pre-existing drift can't break
47
+ deploys on day one; while OFF, drift is surfaced as a warning instead. See
48
+ [`deploy`](./docs/commands/deploy.md).
49
+
50
+ Feature (PS-1755 / PS-1763, T4): **`launchpad status` now reports access
51
+ drift** for gateway apps — whether the gateway actually enforces the Entra
52
+ groups your manifest declares. Because changing `access.allowed_entra_groups`
53
+ and re-running `launchpad deploy` ships content only (it does not re-apply the
54
+ gateway policy), the declared and enforced allow-lists can silently diverge.
55
+
56
+ - Surfaces both directions: **enforced but not declared** (an over-grant — still
57
+ reachable) and **declared but not enforced** (locked out despite the manifest).
58
+ Group names and UUIDs for the same group are treated as equal.
59
+ - `--strict` now exits `1` on access drift as well as manifest drift; `--json`
60
+ carries an `accessDrift` object. Non-gateway (Cloudflare Access) apps and
61
+ older bots show no access-drift line.
62
+ - A fleet-wide audit across all gateway apps lives at
63
+ `scripts/fleet/access-drift-audit.mjs` (`--list` for the offline app
64
+ enumeration). See [`status`](./docs/commands/status.md).
65
+
66
+ Feature (sp-bx7q2m, PS-1572 / PS-1584): the platform now runs a **server-side
67
+ anonymous-reachability check** at deploy time and blocks a deploy whose
68
+ `pages.dev` origin serves content/data to unauthenticated callers — closing the
69
+ class behind the dsr-photo-watch leak at the deploy layer.
70
+
71
+ - New optional manifest key **`verify.protected_path`** (`react`/`react+api`):
72
+ declare a representative protected route (e.g. `/api/health`) the deploy gate
73
+ probes anonymously and requires to return `401`/`403`, not `2xx`. `static`
74
+ apps need no declaration — their whole origin is probed. See the
75
+ [`verify`](./docs/manifest/index.md#verify-deploy-gate-checks) reference.
76
+ - `launchpad validate` accepts and shape-checks the new `verify` block
77
+ (root-relative path required). Backward-compatible — omitting it is valid.
78
+ - Enforcement is server-side in portal-bot; this CLI release only adds the
79
+ manifest field + validation. Version bump deferred to merge.
80
+ - New optional manifest key **`confine_origin`** (boolean, gateway pages apps):
81
+ opt a `react`/`react+api` app's `<id>.pages.dev` apex into service-token
82
+ confinement, once it verifies the gateway's `X-Launchpad-User-Assertion`
83
+ header. Default unchanged (verifier apps unconfined, static always confined);
84
+ `false` is rejected on static apps. See the
85
+ [`confine_origin`](./docs/manifest/index.md#confine_origin-apex-confinement)
86
+ reference (PS-1572 / ADR 0031).
87
+
9
88
  ## 0.40.0 — 2026-06-21
10
89
 
11
90
  Feature (sp-rptndg, PS-1601): `launchpad` now nudges you toward `launchpad bug`
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.40.0";
22
+ var CLI_VERSION = "0.41.1";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -2206,6 +2206,9 @@ var AppBoundarySchema = z.object({
2206
2206
  exclude: z.array(z.string().min(1)).optional()
2207
2207
  }).strict();
2208
2208
  var ProductionEnvSchema = z.record(z.string().regex(ENV_NAME_REGEX, "production_env keys must be UPPER_SNAKE_CASE"), z.string());
2209
+ var VerifySchema = z.object({
2210
+ protected_path: z.string().min(1).regex(/^\//, "verify.protected_path must be a root-relative path beginning with '/'")
2211
+ }).strict();
2209
2212
  var ManifestSchema = z.object({
2210
2213
  apiVersion: z.literal("launchpad.m-kopa.us/v1alpha1"),
2211
2214
  kind: z.literal("App"),
@@ -2219,9 +2222,33 @@ var ManifestSchema = z.object({
2219
2222
  targets: z.array(TargetSchema).optional(),
2220
2223
  build: BuildSchema.optional(),
2221
2224
  production_env: ProductionEnvSchema.optional(),
2222
- app: AppBoundarySchema.optional()
2225
+ app: AppBoundarySchema.optional(),
2226
+ verify: VerifySchema.optional(),
2227
+ confine_origin: z.boolean().optional(),
2228
+ catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional()
2223
2229
  }).strict().superRefine((m, ctx) => {
2224
2230
  const isContainer = m.deployment.type === "container";
2231
+ if (m.confine_origin !== undefined) {
2232
+ if (isContainer) {
2233
+ ctx.addIssue({
2234
+ code: z.ZodIssueCode.custom,
2235
+ path: ["confine_origin"],
2236
+ message: "confine_origin is only valid for gateway pages apps (static/react/react+api); container apps have no pages.dev surface."
2237
+ });
2238
+ } else if (m.auth === "access") {
2239
+ ctx.addIssue({
2240
+ code: z.ZodIssueCode.custom,
2241
+ path: ["confine_origin"],
2242
+ message: "confine_origin applies to gateway-fronted apps (auth: gateway); it has no meaning under Cloudflare Access."
2243
+ });
2244
+ } else if (m.deployment.type === "static" && m.confine_origin === false) {
2245
+ ctx.addIssue({
2246
+ code: z.ZodIssueCode.custom,
2247
+ path: ["confine_origin"],
2248
+ message: "confine_origin: false is not allowed for static apps — they have no verifier, so an unconfined apex would be world-readable (ADR 0031)."
2249
+ });
2250
+ }
2251
+ }
2225
2252
  if (isContainer && m.auth === "gateway") {
2226
2253
  ctx.addIssue({
2227
2254
  code: z.ZodIssueCode.custom,
@@ -2813,6 +2840,9 @@ var SpecSchema = z2.object({
2813
2840
  auth: z2.enum(AUTH_MODES).optional(),
2814
2841
  build: BuildSchema.optional(),
2815
2842
  app: AppBoundarySchema.optional(),
2843
+ verify: VerifySchema.optional(),
2844
+ confine_origin: z2.boolean().optional(),
2845
+ catalogue: z2.object({ listed: z2.boolean().default(true) }).strict().optional(),
2816
2846
  env_vars: z2.record(z2.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
2817
2847
  containers: z2.array(ContainerSchema).min(1).optional(),
2818
2848
  secrets: SecretsSchema.optional(),
@@ -2835,6 +2865,27 @@ var SpecSchema = z2.object({
2835
2865
  message: "containers[] is required when appType is 'container'"
2836
2866
  });
2837
2867
  }
2868
+ if (s.confine_origin !== undefined) {
2869
+ if (isContainer) {
2870
+ ctx.addIssue({
2871
+ code: z2.ZodIssueCode.custom,
2872
+ path: ["confine_origin"],
2873
+ message: "confine_origin is only valid for gateway pages apps; container apps have no pages.dev surface."
2874
+ });
2875
+ } else if (s.auth === "access") {
2876
+ ctx.addIssue({
2877
+ code: z2.ZodIssueCode.custom,
2878
+ path: ["confine_origin"],
2879
+ message: "confine_origin applies to gateway-fronted apps (auth: gateway); it has no meaning under Cloudflare Access."
2880
+ });
2881
+ } else if (s.appType === "static" && s.confine_origin === false) {
2882
+ ctx.addIssue({
2883
+ code: z2.ZodIssueCode.custom,
2884
+ path: ["confine_origin"],
2885
+ message: "confine_origin: false is not allowed for static apps — an unconfined apex with no verifier is world-readable (ADR 0031)."
2886
+ });
2887
+ }
2888
+ }
2838
2889
  if (!isContainer && s.containers !== undefined) {
2839
2890
  ctx.addIssue({
2840
2891
  code: z2.ZodIssueCode.custom,
@@ -3343,9 +3394,45 @@ function emitSecretStep(lines, binding) {
3343
3394
  // ../launchpad-engine/dist/per-app-workspace.js
3344
3395
  var CF_ACCOUNT_ID = "c07cb17c9f742e8144c4828b3693ca65";
3345
3396
  var R2_S3_ENDPOINT = `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
3397
+ // ../launchpad-engine/dist/access-drift.js
3398
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3399
+ function canonicalUuid(s) {
3400
+ const t = s.trim();
3401
+ return UUID_RE.test(t) ? t.toLowerCase() : null;
3402
+ }
3403
+ function normalise(tokens, side, resolve6) {
3404
+ const uuids = new Set;
3405
+ const unresolved = [];
3406
+ for (const raw of tokens) {
3407
+ const direct = canonicalUuid(raw);
3408
+ const uuid = direct ?? (() => {
3409
+ const resolved = resolve6(raw);
3410
+ return resolved === null ? null : canonicalUuid(resolved);
3411
+ })();
3412
+ if (uuid === null) {
3413
+ unresolved.push({ side, token: raw });
3414
+ } else {
3415
+ uuids.add(uuid);
3416
+ }
3417
+ }
3418
+ return { uuids, unresolved };
3419
+ }
3420
+ function detectAccessDrift(declaredTokens, enforcedTokens, resolve6) {
3421
+ const declared = normalise(declaredTokens, "manifest", resolve6);
3422
+ const enforced = normalise(enforcedTokens, "enforced", resolve6);
3423
+ const manifestOnly = [...declared.uuids].filter((u) => !enforced.uuids.has(u)).sort();
3424
+ const liveOnly = [...enforced.uuids].filter((u) => !declared.uuids.has(u)).sort();
3425
+ const unresolved = [...declared.unresolved, ...enforced.unresolved];
3426
+ return {
3427
+ manifestOnly,
3428
+ liveOnly,
3429
+ unresolved,
3430
+ inSync: manifestOnly.length === 0 && liveOnly.length === 0 && unresolved.length === 0
3431
+ };
3432
+ }
3346
3433
  // ../launchpad-engine/dist/fleet-manifest.js
3347
3434
  import { z as z3 } from "zod";
3348
- var UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
3435
+ var UUID_RE2 = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
3349
3436
  var TF_RESIDENCIES = ["per-app-workspace", "main-tf"];
3350
3437
  var APP_SHAPES = [
3351
3438
  "pages",
@@ -3372,7 +3459,7 @@ var FLEET_TIERS = [
3372
3459
  "out-of-scope"
3373
3460
  ];
3374
3461
  var DEVICE_POSTURE = ["gateway-eligible", "retained-access"];
3375
- var GroupId = z3.string().regex(UUID_RE, "Entra group ids are emitted as canonical UUIDs in the JWT groups claim — display names will not match policies.");
3462
+ var GroupId = z3.string().regex(UUID_RE2, "Entra group ids are emitted as canonical UUIDs in the JWT groups claim — display names will not match policies.");
3376
3463
  var FleetAppSchema = z3.object({
3377
3464
  slug: z3.string().regex(SLUG_REGEX),
3378
3465
  residency: z3.enum(TF_RESIDENCIES),
@@ -3644,8 +3731,8 @@ async function bundleAndDeploy(args) {
3644
3731
  }
3645
3732
 
3646
3733
  // src/deploy/verify-deploy.ts
3647
- import { readFileSync as readFileSync7, existsSync as existsSync4 } from "node:fs";
3648
- import { join as join8 } from "node:path";
3734
+ import { readFileSync as readFileSync8, existsSync as existsSync4 } from "node:fs";
3735
+ import { join as join9 } from "node:path";
3649
3736
 
3650
3737
  // src/deploy/deployment-status.ts
3651
3738
  async function fetchStandingExceptions(cfg, slug, fetcher = fetch) {
@@ -3860,7 +3947,7 @@ function redactDiagnostic(text, maxLen = MAX_DIAGNOSTIC_LEN) {
3860
3947
  }
3861
3948
 
3862
3949
  // src/commands/status.ts
3863
- import { readFileSync as readFileSync6 } from "node:fs";
3950
+ import { readFileSync as readFileSync7 } from "node:fs";
3864
3951
 
3865
3952
  // src/deploy/manifest-state.ts
3866
3953
  async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
@@ -3872,6 +3959,7 @@ async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
3872
3959
  lastAppliedManifestSha: raw.lastAppliedManifestSha,
3873
3960
  appRepoHeadSha: raw.appRepoHeadSha ?? null,
3874
3961
  openPr: raw.openPr,
3962
+ enforcedGroups: raw.enforcedGroups ?? null,
3875
3963
  ...raw.manifestYaml !== undefined ? { manifestYaml: raw.manifestYaml } : {}
3876
3964
  };
3877
3965
  }
@@ -3916,6 +4004,110 @@ function inferSlug(opts) {
3916
4004
  return fromManifest ?? fromDir;
3917
4005
  }
3918
4006
 
4007
+ // src/groups/client.ts
4008
+ import { mkdirSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
4009
+ import { dirname as dirname5, join as join8 } from "node:path";
4010
+ var CACHE_TTL_MS = 60 * 60 * 1000;
4011
+ var CACHE_FILENAME = "groups.json";
4012
+ async function fetchGroups(cfg, opts = {}) {
4013
+ const now = opts.now ?? Date.now;
4014
+ const cachePath = join8(cfg.cacheDir, CACHE_FILENAME);
4015
+ if (opts.forceRefresh !== true) {
4016
+ const cached = readCache(cachePath);
4017
+ if (cached !== null) {
4018
+ const ageMs = now() - Date.parse(cached.fetchedAt);
4019
+ if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < CACHE_TTL_MS) {
4020
+ return {
4021
+ kind: "ok",
4022
+ source: "cache",
4023
+ fetchedAt: cached.fetchedAt,
4024
+ groups: cached.groups
4025
+ };
4026
+ }
4027
+ }
4028
+ }
4029
+ let response;
4030
+ try {
4031
+ response = await apiJson(cfg, { path: "/groups" }, opts.fetcher);
4032
+ } catch (e) {
4033
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
4034
+ throw e;
4035
+ }
4036
+ return { kind: "error", message: describe11(e) };
4037
+ }
4038
+ if (typeof response !== "object" || response === null || !Array.isArray(response.groups) || !response.groups.every(isEntraGroup)) {
4039
+ return {
4040
+ kind: "error",
4041
+ message: "bot returned a malformed groups response"
4042
+ };
4043
+ }
4044
+ const groups = response.groups;
4045
+ const fetchedAt = new Date(now()).toISOString();
4046
+ writeCache(cachePath, { fetchedAt, groups });
4047
+ return { kind: "ok", source: "fresh", fetchedAt, groups };
4048
+ }
4049
+ function readCache(path8) {
4050
+ let raw;
4051
+ try {
4052
+ raw = readFileSync6(path8, "utf8");
4053
+ } catch {
4054
+ return null;
4055
+ }
4056
+ let parsed;
4057
+ try {
4058
+ parsed = JSON.parse(raw);
4059
+ } catch {
4060
+ return null;
4061
+ }
4062
+ if (typeof parsed !== "object" || parsed === null)
4063
+ return null;
4064
+ const p = parsed;
4065
+ if (typeof p.fetchedAt !== "string")
4066
+ return null;
4067
+ if (!Array.isArray(p.groups))
4068
+ return null;
4069
+ for (const g of p.groups)
4070
+ if (!isEntraGroup(g))
4071
+ return null;
4072
+ return p;
4073
+ }
4074
+ function isEntraGroup(value) {
4075
+ if (typeof value !== "object" || value === null)
4076
+ return false;
4077
+ const g = value;
4078
+ return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
4079
+ }
4080
+ function writeCache(path8, envelope) {
4081
+ try {
4082
+ mkdirSync(dirname5(path8), { recursive: true });
4083
+ writeFileSync(path8, JSON.stringify(envelope), "utf8");
4084
+ } catch {}
4085
+ }
4086
+ function describe11(e) {
4087
+ return e instanceof Error ? e.message : String(e);
4088
+ }
4089
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4090
+ function isUuid2(value) {
4091
+ return UUID_PATTERN.test(value);
4092
+ }
4093
+ function findGroup(groups, nameOrId) {
4094
+ if (isUuid2(nameOrId)) {
4095
+ const lower = nameOrId.toLowerCase();
4096
+ const hit = groups.find((g) => g.id.toLowerCase() === lower);
4097
+ return hit ? { kind: "found", group: hit } : { kind: "not-found" };
4098
+ }
4099
+ const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
4100
+ if (matches.length === 0)
4101
+ return { kind: "not-found" };
4102
+ if (matches.length === 1)
4103
+ return { kind: "found", group: matches[0] };
4104
+ return { kind: "ambiguous", matches };
4105
+ }
4106
+ function searchGroups(groups, query) {
4107
+ const q = query.toLowerCase();
4108
+ return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
4109
+ }
4110
+
3919
4111
  // src/watch/capabilities.ts
3920
4112
  function isUtf8(env) {
3921
4113
  const v = `${env.LC_ALL ?? ""}|${env.LC_CTYPE ?? ""}|${env.LANG ?? ""}`.toLowerCase();
@@ -4548,7 +4740,7 @@ function mapBotError(e, slug, io) {
4548
4740
  io.err(`launchpad status: ${e.message}`);
4549
4741
  return 2;
4550
4742
  }
4551
- io.err(`launchpad status failed: ${describe11(e)}`);
4743
+ io.err(`launchpad status failed: ${describe12(e)}`);
4552
4744
  return 2;
4553
4745
  }
4554
4746
  async function runStatus(args, io) {
@@ -4574,10 +4766,10 @@ async function runStatus(args, io) {
4574
4766
  }
4575
4767
  let localYaml = null;
4576
4768
  try {
4577
- localYaml = readFileSync6(parsed.file, "utf8");
4769
+ localYaml = readFileSync7(parsed.file, "utf8");
4578
4770
  } catch (e) {
4579
4771
  if (!isEnoent(e)) {
4580
- io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${describe11(e)}`);
4772
+ io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${describe12(e)}`);
4581
4773
  return 2;
4582
4774
  }
4583
4775
  }
@@ -4588,7 +4780,7 @@ async function runStatus(args, io) {
4588
4780
  const { parse: parseYaml4 } = await import("yaml");
4589
4781
  localObj = parseYaml4(localYaml);
4590
4782
  } catch (e) {
4591
- io.err(`launchpad status: ${parsed.file} is not valid YAML: ${describe11(e)}`);
4783
+ io.err(`launchpad status: ${parsed.file} is not valid YAML: ${describe12(e)}`);
4592
4784
  return 2;
4593
4785
  }
4594
4786
  const localParse = parseManifest(localObj);
@@ -4617,7 +4809,7 @@ async function runStatus(args, io) {
4617
4809
  if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
4618
4810
  return mapBotError(e, parsed.slug, io);
4619
4811
  }
4620
- io.err(`launchpad status: live deployment state unavailable (${describe11(e)}) — ` + `the report below is from the platform manifest view only.`);
4812
+ io.err(`launchpad status: live deployment state unavailable (${describe12(e)}) — ` + `the report below is from the platform manifest view only.`);
4621
4813
  }
4622
4814
  let standingExceptions = null;
4623
4815
  try {
@@ -4626,7 +4818,7 @@ async function runStatus(args, io) {
4626
4818
  if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
4627
4819
  return mapBotError(e, parsed.slug, io);
4628
4820
  }
4629
- io.err(`launchpad status: standing-exception inventory unavailable (${describe11(e)}).`);
4821
+ io.err(`launchpad status: standing-exception inventory unavailable (${describe12(e)}).`);
4630
4822
  }
4631
4823
  if (state.manifestYaml === null || state.manifestYaml === undefined) {
4632
4824
  const live = deployment?.liveDeployment ?? null;
@@ -4678,7 +4870,7 @@ async function runStatus(args, io) {
4678
4870
  const { parse: parseYaml4 } = await import("yaml");
4679
4871
  deployedObj = parseYaml4(state.manifestYaml);
4680
4872
  } catch (e) {
4681
- io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${describe11(e)}`);
4873
+ io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${describe12(e)}`);
4682
4874
  return 2;
4683
4875
  }
4684
4876
  const deployedParse = parseManifest(deployedObj);
@@ -4691,6 +4883,16 @@ async function runStatus(args, io) {
4691
4883
  }
4692
4884
  const deployed = deployedParse.manifest;
4693
4885
  const drift = computeDrift(local, deployed);
4886
+ let accessDrift;
4887
+ if (state.enforcedGroups !== null) {
4888
+ let resolver;
4889
+ try {
4890
+ resolver = await buildGroupResolver(cfg);
4891
+ } catch (e) {
4892
+ return mapBotError(e, parsed.slug, io);
4893
+ }
4894
+ accessDrift = detectAccessDrift(allowedEntraGroups(local.access), state.enforcedGroups, resolver);
4895
+ }
4694
4896
  const result = {
4695
4897
  state: drift.length === 0 ? "in_sync" : "drift",
4696
4898
  slug: parsed.slug,
@@ -4701,14 +4903,30 @@ async function runStatus(args, io) {
4701
4903
  driftFields: drift.map((d) => d.path),
4702
4904
  driftDetails: drift,
4703
4905
  ...deploymentKnown ? { deployment } : {},
4704
- ...standingExceptions !== null ? { standingExceptions } : {}
4906
+ ...standingExceptions !== null ? { standingExceptions } : {},
4907
+ ...accessDrift !== undefined ? { accessDrift } : {}
4705
4908
  };
4706
4909
  emit3(result, parsed.json, io);
4707
- if (result.state === "drift" && parsed.strict) {
4910
+ if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false)) {
4708
4911
  return 1;
4709
4912
  }
4710
4913
  return 0;
4711
4914
  }
4915
+ async function buildGroupResolver(cfg) {
4916
+ try {
4917
+ const res = await fetchGroups(cfg);
4918
+ if (res.kind !== "ok")
4919
+ return () => null;
4920
+ return (nameOrId) => {
4921
+ const hit = findGroup(res.groups, nameOrId);
4922
+ return hit.kind === "found" ? hit.group.id : null;
4923
+ };
4924
+ } catch (e) {
4925
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
4926
+ throw e;
4927
+ return () => null;
4928
+ }
4929
+ }
4712
4930
  function computeDrift(local, deployed) {
4713
4931
  const diffs = [];
4714
4932
  const cmp = (path8, l, d) => {
@@ -4820,6 +5038,7 @@ function emit3(out, asJson, io) {
4820
5038
  case "in_sync":
4821
5039
  io.out(`${out.slug}: live, in sync` + (out.deployedSha ? ` (content @ ${out.deployedSha.slice(0, 7)})` : ""));
4822
5040
  surfaceHeadVsDeployed(out, io);
5041
+ surfaceAccessDrift(out, io);
4823
5042
  surfaceDeployment(out, io);
4824
5043
  surfaceExceptions(out, io);
4825
5044
  return;
@@ -4831,11 +5050,28 @@ function emit3(out, asJson, io) {
4831
5050
  io.out(` deployed: ${formatValue(d.deployed)}`);
4832
5051
  }
4833
5052
  surfaceHeadVsDeployed(out, io);
5053
+ surfaceAccessDrift(out, io);
4834
5054
  surfaceDeployment(out, io);
4835
5055
  surfaceExceptions(out, io);
4836
5056
  return;
4837
5057
  }
4838
5058
  }
5059
+ function surfaceAccessDrift(out, io) {
5060
+ const ad = out.accessDrift;
5061
+ if (ad === undefined || ad.inSync)
5062
+ return;
5063
+ io.out(" ⚠ ACCESS DRIFT — your declared groups are not what the gateway enforces:");
5064
+ if (ad.liveOnly.length > 0) {
5065
+ io.out(` enforced but NOT declared (over-grant — still reachable): ${ad.liveOnly.join(", ")}`);
5066
+ }
5067
+ if (ad.manifestOnly.length > 0) {
5068
+ io.out(` declared but NOT enforced (locked out despite the manifest): ${ad.manifestOnly.join(", ")}`);
5069
+ }
5070
+ for (const u of ad.unresolved) {
5071
+ io.out(` unresolved group (${u.side}): ${u.token}`);
5072
+ }
5073
+ io.out(" `launchpad deploy` will NOT fix this — access changes go via the gated TF route.");
5074
+ }
4839
5075
  function surfaceNoLocalManifestNote(out, io) {
4840
5076
  if (out.drift !== null)
4841
5077
  return;
@@ -5028,7 +5264,7 @@ function printUsage2(io) {
5028
5264
  ].join(`
5029
5265
  `));
5030
5266
  }
5031
- function describe11(e) {
5267
+ function describe12(e) {
5032
5268
  return e instanceof Error ? e.message : String(e);
5033
5269
  }
5034
5270
  function isEnoent(e) {
@@ -5039,10 +5275,10 @@ function isEnoent(e) {
5039
5275
  var VERIFY_MISMATCH_EXIT = 65;
5040
5276
  var NO_DEPLOYMENT_EXIT = 69;
5041
5277
  function readLocalEntrypoint(cwd) {
5042
- for (const rel of ["index.html", join8("static", "index.html")]) {
5043
- const p = join8(cwd, rel);
5278
+ for (const rel of ["index.html", join9("static", "index.html")]) {
5279
+ const p = join9(cwd, rel);
5044
5280
  if (existsSync4(p))
5045
- return new Uint8Array(readFileSync7(p));
5281
+ return new Uint8Array(readFileSync8(p));
5046
5282
  }
5047
5283
  return null;
5048
5284
  }
@@ -5276,7 +5512,7 @@ async function verifyFirstDeployServed(args, liveUrl, io, deps) {
5276
5512
 
5277
5513
  // src/commands/deploy.ts
5278
5514
  import { parse as parseYaml5 } from "yaml";
5279
- import { readFileSync as readFileSync9 } from "node:fs";
5515
+ import { readFileSync as readFileSync10 } from "node:fs";
5280
5516
 
5281
5517
  // src/deploy/git-files.ts
5282
5518
  import { spawn as spawn3 } from "node:child_process";
@@ -5717,15 +5953,15 @@ function parseDeployFlags(args) {
5717
5953
 
5718
5954
  // src/deploy/apply.ts
5719
5955
  import { existsSync as existsSync5, rmSync } from "node:fs";
5720
- import { resolve as resolvePath, join as join9 } from "node:path";
5956
+ import { resolve as resolvePath, join as join10 } from "node:path";
5721
5957
 
5722
5958
  // src/manifest/load.ts
5723
- import { readFileSync as readFileSync8 } from "node:fs";
5959
+ import { readFileSync as readFileSync9 } from "node:fs";
5724
5960
  import { parse as parseYaml4, YAMLParseError } from "yaml";
5725
5961
  function loadManifest(path8) {
5726
5962
  let raw;
5727
5963
  try {
5728
- raw = readFileSync8(path8, "utf8");
5964
+ raw = readFileSync9(path8, "utf8");
5729
5965
  } catch (err) {
5730
5966
  const e = err;
5731
5967
  if (e.code === "ENOENT") {
@@ -5894,7 +6130,7 @@ async function pollUntilApplied(args) {
5894
6130
  path: `/apps/${args.slug}/manifest/state`
5895
6131
  }, args.fetcher);
5896
6132
  } catch (e) {
5897
- args.io.err(`! state fetch failed (will retry): ${describe12(e)}`);
6133
+ args.io.err(`! state fetch failed (will retry): ${describe13(e)}`);
5898
6134
  await sleep(args.pollIntervalSec * 1000);
5899
6135
  continue;
5900
6136
  }
@@ -5947,14 +6183,14 @@ function sleep(ms) {
5947
6183
  return new Promise((res) => setTimeout(res, ms));
5948
6184
  }
5949
6185
  function deletePinIfPresent(cfg, slug, io) {
5950
- const pinPath = join9(cfg.stateDir, slug, "group.json");
6186
+ const pinPath = join10(cfg.stateDir, slug, "group.json");
5951
6187
  if (!existsSync5(pinPath))
5952
6188
  return;
5953
6189
  try {
5954
6190
  rmSync(pinPath, { force: true });
5955
6191
  io.out(` (cleaned up obsolete pin file ${pinPath})`);
5956
6192
  } catch (e) {
5957
- io.err(`! warning: failed to delete obsolete pin file ${pinPath}: ${describe12(e)}`);
6193
+ io.err(`! warning: failed to delete obsolete pin file ${pinPath}: ${describe13(e)}`);
5958
6194
  }
5959
6195
  }
5960
6196
  async function loadSlugForResume(opts, io) {
@@ -5978,7 +6214,7 @@ async function defaultPrompt(question) {
5978
6214
  rl.close();
5979
6215
  }
5980
6216
  }
5981
- function describe12(e) {
6217
+ function describe13(e) {
5982
6218
  return e instanceof Error ? e.message : String(e);
5983
6219
  }
5984
6220
  function mapHttpError(e, slug, io) {
@@ -6002,7 +6238,7 @@ function mapHttpError(e, slug, io) {
6002
6238
  io.err(`launchpad deploy --apply: ${e.message}`);
6003
6239
  return 1;
6004
6240
  }
6005
- io.err(`launchpad deploy --apply failed: ${describe12(e)}`);
6241
+ io.err(`launchpad deploy --apply failed: ${describe13(e)}`);
6006
6242
  return 2;
6007
6243
  }
6008
6244
  function renderManifestError(loaded, io) {
@@ -6044,7 +6280,7 @@ async function runDeployDryRun(opts, io, deps = {}) {
6044
6280
  try {
6045
6281
  manifestSha = (deps.gitHeadSha ?? defaultGitHeadSha)(process.cwd());
6046
6282
  } catch (e) {
6047
- const msg = `failed to resolve git HEAD in ${process.cwd()}: ${describe13(e)}`;
6283
+ const msg = `failed to resolve git HEAD in ${process.cwd()}: ${describe14(e)}`;
6048
6284
  if (opts.json) {
6049
6285
  io.out(JSON.stringify({ ok: false, kind: "git-error", message: msg }));
6050
6286
  } else {
@@ -6093,7 +6329,7 @@ function defaultGitHeadSha(cwd) {
6093
6329
  });
6094
6330
  return out.trim();
6095
6331
  }
6096
- function describe13(e) {
6332
+ function describe14(e) {
6097
6333
  return e instanceof Error ? e.message : String(e);
6098
6334
  }
6099
6335
  function mapHttpError2(e, slug, json, io) {
@@ -6477,7 +6713,7 @@ async function runDeploy(args, io) {
6477
6713
  const contentManifestPath = path8.join(process.cwd(), "launchpad.yaml");
6478
6714
  if (existsSync6(contentManifestPath)) {
6479
6715
  try {
6480
- contentManifestYaml = readFileSync9(contentManifestPath, "utf8");
6716
+ contentManifestYaml = readFileSync10(contentManifestPath, "utf8");
6481
6717
  } catch {
6482
6718
  contentManifestYaml = null;
6483
6719
  }
@@ -6551,7 +6787,7 @@ async function runDeploy(args, io) {
6551
6787
  io.err(`launchpad deploy: ${e.message}`);
6552
6788
  return 1;
6553
6789
  }
6554
- io.err(`launchpad deploy failed: ${describe14(e)}`);
6790
+ io.err(`launchpad deploy failed: ${describe15(e)}`);
6555
6791
  return 1;
6556
6792
  }
6557
6793
  }
@@ -6623,7 +6859,7 @@ function formatBytes2(n) {
6623
6859
  return `${(n / 1024).toFixed(1)}KB`;
6624
6860
  return `${(n / (1024 * 1024)).toFixed(1)}MB`;
6625
6861
  }
6626
- function describe14(e) {
6862
+ function describe15(e) {
6627
6863
  return e instanceof Error ? e.message : String(e);
6628
6864
  }
6629
6865
  function surfaceDeployExtras(body, io, slug, allowStale = false) {
@@ -6656,6 +6892,17 @@ function surfaceDeployExtras(body, io, slug, allowStale = false) {
6656
6892
  }
6657
6893
  io.out(` Full list: \`launchpad status ${slug}\`.`);
6658
6894
  }
6895
+ const adw = body.access_drift_warning;
6896
+ if (adw !== undefined) {
6897
+ io.err("warning: ACCESS DRIFT — declared groups don't match what the gateway enforces:");
6898
+ if (adw.liveOnly !== undefined && adw.liveOnly.length > 0) {
6899
+ io.err(` enforced but NOT declared (over-grant): ${adw.liveOnly.join(", ")}`);
6900
+ }
6901
+ if (adw.manifestOnly !== undefined && adw.manifestOnly.length > 0) {
6902
+ io.err(` declared but NOT enforced: ${adw.manifestOnly.join(", ")}`);
6903
+ }
6904
+ io.err(` reconcile access via the gated TF route — \`launchpad deploy\` can't (it ships content only).`);
6905
+ }
6659
6906
  }
6660
6907
  function isStaleBlock(body) {
6661
6908
  const code = body?.error;
@@ -6688,7 +6935,7 @@ async function runModelADeploy(args) {
6688
6935
  let slug;
6689
6936
  let appType = "static";
6690
6937
  try {
6691
- const manifestObj = parseYaml5(readFileSync9(manifestPath, "utf8"));
6938
+ const manifestObj = parseYaml5(readFileSync10(manifestPath, "utf8"));
6692
6939
  const metaSlug = resolveManifestSlug(manifestObj);
6693
6940
  if (metaSlug === null) {
6694
6941
  io.err(`launchpad deploy: launchpad.yaml is missing metadata.slug (v2) / metadata.name (v1). ` + `Run \`launchpad init\` again to regenerate the manifest.`);
@@ -6700,7 +6947,7 @@ async function runModelADeploy(args) {
6700
6947
  appType = dtype;
6701
6948
  }
6702
6949
  } catch (e) {
6703
- io.err(`launchpad deploy: failed to read ${manifestPath}: ${describe14(e)}`);
6950
+ io.err(`launchpad deploy: failed to read ${manifestPath}: ${describe15(e)}`);
6704
6951
  return 1;
6705
6952
  }
6706
6953
  const noVerify = args.argv.includes("--no-verify");
@@ -6730,7 +6977,7 @@ async function runModelADeploy(args) {
6730
6977
  try {
6731
6978
  cfg = loadConfig();
6732
6979
  } catch (e) {
6733
- io.err(`launchpad deploy: ${describe14(e)}`);
6980
+ io.err(`launchpad deploy: ${describe15(e)}`);
6734
6981
  return 1;
6735
6982
  }
6736
6983
  const deployBaseline = noVerify ? null : await readDeploymentBaseline(realCommitWaitDeps(cfg), slug);
@@ -6759,7 +7006,7 @@ async function runModelADeploy(args) {
6759
7006
  io.err(" run `launchpad login` to refresh your session.");
6760
7007
  return 1;
6761
7008
  }
6762
- io.err(`launchpad deploy: unexpected error: ${describe14(e)}`);
7009
+ io.err(`launchpad deploy: unexpected error: ${describe15(e)}`);
6763
7010
  return 1;
6764
7011
  }
6765
7012
  switch (result.kind) {
@@ -6792,6 +7039,23 @@ async function runModelADeploy(args) {
6792
7039
  surfaceStaleBlock(body, io);
6793
7040
  return 1;
6794
7041
  }
7042
+ if (result.status === 409 && errorCode === "access_drift") {
7043
+ io.err(`launchpad deploy: refused — ACCESS DRIFT on "${slug}".`);
7044
+ const liveOnly = Array.isArray(body.live_only) ? body.live_only : [];
7045
+ const manifestOnly = Array.isArray(body.manifest_only) ? body.manifest_only : [];
7046
+ if (liveOnly.length > 0) {
7047
+ io.err(` enforced but NOT declared (over-grant): ${liveOnly.map(String).join(", ")}`);
7048
+ }
7049
+ if (manifestOnly.length > 0) {
7050
+ io.err(` declared but NOT enforced: ${manifestOnly.map(String).join(", ")}`);
7051
+ }
7052
+ io.err("");
7053
+ io.err(" Your manifest's access groups don't match what the gateway enforces.");
7054
+ io.err(" `launchpad deploy` ships content only and cannot change access — reconcile");
7055
+ io.err(" via the gated TF route (see the access-change runbook), then redeploy.");
7056
+ io.err(" Nothing was committed by this attempt.");
7057
+ return 1;
7058
+ }
6795
7059
  io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
6796
7060
  if (typeof body.message === "string") {
6797
7061
  io.err(` ${body.message}`);
@@ -6887,7 +7151,7 @@ async function runModelADeploy(args) {
6887
7151
  }
6888
7152
 
6889
7153
  // src/commands/redeploy.ts
6890
- import { existsSync as existsSync7, readFileSync as readFileSync10 } from "node:fs";
7154
+ import { existsSync as existsSync7, readFileSync as readFileSync11 } from "node:fs";
6891
7155
  import { resolve as resolvePath2 } from "node:path";
6892
7156
  import { parse as parseYaml6 } from "yaml";
6893
7157
  var SLUG_RE6 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
@@ -6968,7 +7232,7 @@ function readLocalAppType(cwd, file) {
6968
7232
  if (!existsSync7(manifestPath))
6969
7233
  return null;
6970
7234
  try {
6971
- const obj = parseYaml6(readFileSync10(manifestPath, "utf8"));
7235
+ const obj = parseYaml6(readFileSync11(manifestPath, "utf8"));
6972
7236
  return coerceAppType(obj?.deployment?.type);
6973
7237
  } catch {
6974
7238
  return null;
@@ -7150,7 +7414,7 @@ async function runEnvvars(args, io) {
7150
7414
  io.err(`launchpad envvars: ${e.message}`);
7151
7415
  return 1;
7152
7416
  }
7153
- io.err(`launchpad envvars failed: ${describe15(e)}`);
7417
+ io.err(`launchpad envvars failed: ${describe16(e)}`);
7154
7418
  return 1;
7155
7419
  }
7156
7420
  }
@@ -7247,13 +7511,13 @@ function renderList(envVars, io) {
7247
7511
  io.out(fmt(row));
7248
7512
  }
7249
7513
  }
7250
- function describe15(e) {
7514
+ function describe16(e) {
7251
7515
  return e instanceof Error ? e.message : String(e);
7252
7516
  }
7253
7517
 
7254
7518
  // src/commands/generate.ts
7255
- import { mkdirSync, readFileSync as readFileSync11, writeFileSync } from "node:fs";
7256
- import { dirname as dirname5, resolve as resolve8, relative as relative3 } from "node:path";
7519
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
7520
+ import { dirname as dirname6, resolve as resolve8, relative as relative3 } from "node:path";
7257
7521
  var generateCommand = {
7258
7522
  name: "generate",
7259
7523
  summary: "emit derived artefacts (wrangler.toml, deploy.yml) from launchpad.yaml",
@@ -7271,7 +7535,7 @@ async function runGenerate(args, io) {
7271
7535
  if (result.kind !== "ok") {
7272
7536
  return flags.json ? renderManifestErrorJson(result, io) : renderManifestErrorHuman(result, io);
7273
7537
  }
7274
- const appRoot = dirname5(manifestPath);
7538
+ const appRoot = dirname6(manifestPath);
7275
7539
  const wranglerPath = resolve8(appRoot, "container", "wrangler.toml");
7276
7540
  const workflowPath = resolve8(appRoot, ".github", "workflows", "deploy.yml");
7277
7541
  const wranglerOut = generateWranglerToml(result.manifest);
@@ -7315,8 +7579,8 @@ function applyOne(artefact, path10, out, force) {
7315
7579
  };
7316
7580
  }
7317
7581
  try {
7318
- mkdirSync(dirname5(path10), { recursive: true });
7319
- writeFileSync(path10, out.content, "utf8");
7582
+ mkdirSync2(dirname6(path10), { recursive: true });
7583
+ writeFileSync2(path10, out.content, "utf8");
7320
7584
  return {
7321
7585
  artefact,
7322
7586
  action: {
@@ -7340,7 +7604,7 @@ function applyOne(artefact, path10, out, force) {
7340
7604
  }
7341
7605
  function readIfExists(path10) {
7342
7606
  try {
7343
- return { kind: "ok", content: readFileSync11(path10, "utf8") };
7607
+ return { kind: "ok", content: readFileSync12(path10, "utf8") };
7344
7608
  } catch (err) {
7345
7609
  const e = err;
7346
7610
  if (e.code === "ENOENT")
@@ -7550,110 +7814,6 @@ function parseFlags(args) {
7550
7814
  return { kind: "ok", file, dryRun, force, json };
7551
7815
  }
7552
7816
 
7553
- // src/groups/client.ts
7554
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
7555
- import { dirname as dirname6, join as join11 } from "node:path";
7556
- var CACHE_TTL_MS = 60 * 60 * 1000;
7557
- var CACHE_FILENAME = "groups.json";
7558
- async function fetchGroups(cfg, opts = {}) {
7559
- const now = opts.now ?? Date.now;
7560
- const cachePath = join11(cfg.cacheDir, CACHE_FILENAME);
7561
- if (opts.forceRefresh !== true) {
7562
- const cached = readCache(cachePath);
7563
- if (cached !== null) {
7564
- const ageMs = now() - Date.parse(cached.fetchedAt);
7565
- if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < CACHE_TTL_MS) {
7566
- return {
7567
- kind: "ok",
7568
- source: "cache",
7569
- fetchedAt: cached.fetchedAt,
7570
- groups: cached.groups
7571
- };
7572
- }
7573
- }
7574
- }
7575
- let response;
7576
- try {
7577
- response = await apiJson(cfg, { path: "/groups" }, opts.fetcher);
7578
- } catch (e) {
7579
- if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
7580
- throw e;
7581
- }
7582
- return { kind: "error", message: describe16(e) };
7583
- }
7584
- if (typeof response !== "object" || response === null || !Array.isArray(response.groups) || !response.groups.every(isEntraGroup)) {
7585
- return {
7586
- kind: "error",
7587
- message: "bot returned a malformed groups response"
7588
- };
7589
- }
7590
- const groups = response.groups;
7591
- const fetchedAt = new Date(now()).toISOString();
7592
- writeCache(cachePath, { fetchedAt, groups });
7593
- return { kind: "ok", source: "fresh", fetchedAt, groups };
7594
- }
7595
- function readCache(path10) {
7596
- let raw;
7597
- try {
7598
- raw = readFileSync12(path10, "utf8");
7599
- } catch {
7600
- return null;
7601
- }
7602
- let parsed;
7603
- try {
7604
- parsed = JSON.parse(raw);
7605
- } catch {
7606
- return null;
7607
- }
7608
- if (typeof parsed !== "object" || parsed === null)
7609
- return null;
7610
- const p = parsed;
7611
- if (typeof p.fetchedAt !== "string")
7612
- return null;
7613
- if (!Array.isArray(p.groups))
7614
- return null;
7615
- for (const g of p.groups)
7616
- if (!isEntraGroup(g))
7617
- return null;
7618
- return p;
7619
- }
7620
- function isEntraGroup(value) {
7621
- if (typeof value !== "object" || value === null)
7622
- return false;
7623
- const g = value;
7624
- return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
7625
- }
7626
- function writeCache(path10, envelope) {
7627
- try {
7628
- mkdirSync2(dirname6(path10), { recursive: true });
7629
- writeFileSync2(path10, JSON.stringify(envelope), "utf8");
7630
- } catch {}
7631
- }
7632
- function describe16(e) {
7633
- return e instanceof Error ? e.message : String(e);
7634
- }
7635
- var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7636
- function isUuid(value) {
7637
- return UUID_PATTERN.test(value);
7638
- }
7639
- function findGroup(groups, nameOrId) {
7640
- if (isUuid(nameOrId)) {
7641
- const lower = nameOrId.toLowerCase();
7642
- const hit = groups.find((g) => g.id.toLowerCase() === lower);
7643
- return hit ? { kind: "found", group: hit } : { kind: "not-found" };
7644
- }
7645
- const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
7646
- if (matches.length === 0)
7647
- return { kind: "not-found" };
7648
- if (matches.length === 1)
7649
- return { kind: "found", group: matches[0] };
7650
- return { kind: "ambiguous", matches };
7651
- }
7652
- function searchGroups(groups, query) {
7653
- const q = query.toLowerCase();
7654
- return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
7655
- }
7656
-
7657
7817
  // src/auth/jwt.ts
7658
7818
  class JwtParseError extends Error {
7659
7819
  code = "jwt_parse_error";
@@ -8071,7 +8231,7 @@ async function runResolve(args, io) {
8071
8231
  return 1;
8072
8232
  }
8073
8233
  io.out(hit.group.id);
8074
- if (!isUuid(key)) {
8234
+ if (!isUuid2(key)) {
8075
8235
  io.err(`# resolved "${key}" → ${hit.group.displayName}`);
8076
8236
  }
8077
8237
  return 0;
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAmEA,OAAO,EAGL,4BAA4B,EAC7B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAa,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAKjE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,CAAC;AAEjD,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC;AAEF,UAAU,UAAU;IAClB,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAmQD;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAoBpC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,GAAG,IAAI,CAwBpE;AA+KD,2BAA2B;AAC3B,OAAO,EAAE,4BAA4B,EAAE,CAAC"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAmEA,OAAO,EAGL,4BAA4B,EAC7B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAa,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAKjE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,CAAC;AAEjD,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC;AAEF,UAAU,UAAU;IAClB,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAmQD;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAoBpC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,GAAG,IAAI,CAwBpE;AAkMD,2BAA2B;AAC3B,OAAO,EAAE,4BAA4B,EAAE,CAAC"}
@@ -1,6 +1,6 @@
1
1
  import { type CliConfig } from "../config.js";
2
2
  import { type DeploymentStatusView, type StandingExceptionView } from "../deploy/deployment-status.js";
3
- import { type Manifest } from "@m-kopa/launchpad-engine";
3
+ import { type Manifest, type AccessDrift } from "@m-kopa/launchpad-engine";
4
4
  import type { Command } from "../dispatcher.js";
5
5
  export declare const statusCommand: Command;
6
6
  interface StatusArgs {
@@ -78,6 +78,13 @@ export interface StatusJsonOutput {
78
78
  * policy violations in content already live on managed main.
79
79
  * Absent when the bot pre-dates the endpoint. */
80
80
  readonly standingExceptions?: readonly StandingExceptionView[];
81
+ /** PS-1755 / PS-1763 (T4): access drift between the LOCAL declared groups
82
+ * (`access.allowed_entra_groups`) and the ENFORCED gateway-KV allow-list
83
+ * the bot read from the app's TF. Distinct from the content-drift
84
+ * `access.allowed_entra_groups` row (which is local-vs-deployed *manifest*):
85
+ * this is "what I declare" vs "what the gateway actually enforces".
86
+ * Absent when not a gateway-KV app (`enforcedGroups` null / old bot). */
87
+ readonly accessDrift?: AccessDrift;
81
88
  }
82
89
  /** Fetch the app's provisioning lifecycle (sp-st5hw9). Returns null when the
83
90
  * bot doesn't recognise the slug OR predates the endpoint — BOTH surface as a
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,13 @@ export interface ManifestStateResponse {
9
9
  readonly url: string;
10
10
  readonly branch: string;
11
11
  } | null;
12
+ /** PS-1763 (T4): the ENFORCED gateway allow-list — the per-app
13
+ * `<slug>_policy` KV `groups` array, as raw tokens (UUIDs +
14
+ * `entra_groups.<Name>` refs) parsed from the app's TF source. `null` for
15
+ * an app with no `<slug>_policy` block (a CF Access app / the gateway —
16
+ * a different enforcement mechanism, out of scope for gateway-KV drift).
17
+ * Older bots (pre-PS-1763) omit it → normalised to `null`. */
18
+ readonly enforcedGroups: readonly string[] | null;
12
19
  readonly manifestYaml?: string | null;
13
20
  }
14
21
  /** Fetch `/apps/<slug>/manifest/state`. With `includeManifest: true`,
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-state.d.ts","sourceRoot":"","sources":["../../src/deploy/manifest-state.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,GAAG,IAAI,CAAC;IACT,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAcD;;gCAEgC;AAChC,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAO,EACxC,OAAO,GAAE,OAAO,KAAa,GAC5B,OAAO,CAAC,qBAAqB,CAAC,CAehC"}
1
+ {"version":3,"file":"manifest-state.d.ts","sourceRoot":"","sources":["../../src/deploy/manifest-state.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,GAAG,IAAI,CAAC;IACT;;;;;mEAK+D;IAC/D,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAClD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAeD;;gCAEgC;AAChC,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAO,EACxC,OAAO,GAAE,OAAO,KAAa,GAC5B,OAAO,CAAC,qBAAqB,CAAC,CAgBhC"}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.40.0";
1
+ export declare const CLI_VERSION = "0.41.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.40.0",
3
+ "version": "0.41.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": {
@@ -49,6 +49,8 @@
49
49
  "test:watch": "vitest",
50
50
  "lint": "eslint src tests",
51
51
  "typecheck": "tsc --noEmit",
52
+ "typecheck:catalogue": "tsc -p channel/catalogue/tsconfig.json --noEmit",
53
+ "build:catalogue": "esbuild channel/catalogue/src/main.ts --bundle --format=esm --minify --target=es2020 --legal-comments=none --outfile=channel/catalogue/dist/app.js",
52
54
  "check:version-sync": "bash scripts/check-version-sync.sh",
53
55
  "check:runtime-deps": "node scripts/check-runtime-deps.mjs",
54
56
  "check:installer-syntax": "bash scripts/check-installer-syntax.sh",
@@ -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.40.0
4
+ version: 0.41.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -185,7 +185,10 @@ launchpad plan
185
185
 
186
186
  Both are offline by default (no bot). `launchpad validate
187
187
  --strict-groups` opts in to an online check that resolves
188
- `access.allowed_entra_group` (needs a session).
188
+ `access.allowed_entra_group` (needs a session). Note it resolves only
189
+ against the groups **assigned** to the Launchpad enterprise app, so it
190
+ falsely fails a valid *unassigned* tenant group — pre-check those with
191
+ `launchpad deploy --dry-run` instead.
189
192
 
190
193
  ## Verify the deploy
191
194
 
@@ -248,6 +251,11 @@ Once you've shipped first content, the daily-use verbs are:
248
251
  currently-deployed `launchpad.yaml`.
249
252
  - **`launchpad deploy`** — bundle the working tree; the bot commits
250
253
  it directly to the app repo's `main`.
254
+ - **`launchpad redeploy <slug>`** — re-run a Pages build of the
255
+ current commit *without* changing content. Use it to **activate a
256
+ secret you just pushed** (Cloudflare binds env only on a new build,
257
+ so `secrets push` alone won't take effect on a Pages tier) or to
258
+ force a clean rebuild. See `/commands/redeploy`.
251
259
  - **`launchpad envvars`** — list / set / remove non-secret
252
260
  production env vars.
253
261
  - **`launchpad secrets template`** — emit `.env.example` from the
@@ -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.40.0
4
+ version: 0.41.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -130,6 +130,16 @@ This is interactive by default. It will ask for:
130
130
  layouts; the user overrides if needed.
131
131
  - **allowed_entra_groups** — one or more, comma-separated; see §A.3.
132
132
 
133
+ By default the app is **listed in the app catalogue**
134
+ (get.launchpad/catalogue — the employee-facing app directory). Listing
135
+ is discovery only and grants no one access. To keep an internal-only
136
+ app out of it, add to `launchpad.yaml`:
137
+
138
+ ```yaml
139
+ catalogue:
140
+ listed: false
141
+ ```
142
+
133
143
  Non-interactive form for scripting:
134
144
 
135
145
  ```bash
@@ -185,20 +195,45 @@ The CLI resolves Entra groups via the bot's `/groups` endpoint
185
195
  endpoint directly. Two helpers:
186
196
 
187
197
  ```bash
188
- launchpad groups list # every group assigned to the Launchpad app in Entra
198
+ launchpad groups list # groups ASSIGNED to the Launchpad app in Entra
189
199
  launchpad groups search <query> # fuzzy match by name / nickname / id
190
200
  launchpad groups show <name> # UUID + displayName + mailNickname
191
201
  launchpad groups resolve <name> # just the Entra Object-ID UUID (script-friendly)
192
202
  ```
193
203
 
194
- `groups list` is **not** the whole tenant and not caller-scoped: it
195
- lists the groups **assigned to the Launchpad enterprise application**
196
- in Entra the only groups that can actually grant sign-in to a
197
- deployed app.
204
+ **Important — `groups` only sees the *assigned* set.** All four
205
+ helpers (and `launchpad validate --strict-groups`, §A.4) list only the
206
+ groups **assigned to the Launchpad enterprise application** in Entra.
207
+ That is a discovery convenience, **not** the set of usable groups.
208
+ Since PS-1733 (live 2026-06-25) an app can be gated on **any group in
209
+ the tenant** — the group does **not** have to be assigned to the
210
+ Launchpad app first. So if the user wants a group that does not appear
211
+ in `groups list` (e.g. `G_information_security`), that is **fine**:
212
+ pass it anyway, by display name or UUID. Do **not** tell the user to
213
+ ask IT to assign the group to the enterprise app — that requirement
214
+ was retired.
198
215
 
199
216
  When the user picks a group, pass either the **displayName** or the
200
217
  **UUID** to `launchpad init --group <…>` — the CLI accepts both and
201
- the bot canonicalises to the UUID.
218
+ the bot canonicalises to the UUID. Prefer the **UUID** for a group
219
+ that is not in `groups list`, or whenever a display name could be
220
+ ambiguous.
221
+
222
+ A group reference is rejected at deploy only when it is **unknown** (no
223
+ such group anywhere in the tenant), **ineligible** (a pure distribution
224
+ list, or a hidden-membership group the platform cannot evaluate), or an
225
+ **ambiguous** display name (use the UUID). An unassigned-but-valid
226
+ group is accepted. Members who do not carry the group as a token claim
227
+ are authorised at runtime via a Microsoft Graph membership check
228
+ (employees only — guests are refused).
229
+
230
+ **Pre-checking a group reference.** Because `groups` and
231
+ `--strict-groups` are assigned-only, they **falsely reject** a valid
232
+ unassigned group. To pre-check any tenant group without mutating
233
+ anything, run `launchpad deploy --dry-run` — it resolves against the
234
+ whole tenant exactly as the real submit does. Treat `--dry-run` (not
235
+ `--strict-groups`) as the source of truth for "will this group
236
+ resolve?".
202
237
 
203
238
  If `launchpad groups list` fails with:
204
239
 
@@ -212,9 +247,10 @@ If `launchpad groups list` fails with:
212
247
  `appRoleAssignedTo` assignment list (missing admin consent on the
213
248
  Graph application permission), or Graph is unreachable. Surface
214
249
  the error body.
215
- - empty list → no groups are assigned to the Launchpad enterprise
216
- app in Entra. A group must be **assigned to the app** before it
217
- can gate anything membership alone is not enough.
250
+ - empty list → no groups are **assigned** to the Launchpad app. This
251
+ does **not** block you an unassigned tenant group can still be
252
+ used by name or UUID (above); the assigned set is just a discovery
253
+ shortlist.
218
254
 
219
255
  Use `launchpad groups whoami` to remind the user which groups
220
256
  **they** are currently a member of — handy when an app is gated and
@@ -234,7 +270,10 @@ Add `--strict-groups` to *additionally* resolve the manifest's
234
270
  allowed Entra group online against the bot's group list — it catches
235
271
  typo'd or renamed group names before deploy time. This mode needs a
236
272
  session and the network (exit 1 = group not found / ambiguous,
237
- 2 = network error, 3 = no valid session).
273
+ 2 = network error, 3 = no valid session). **Caveat:** it checks only
274
+ the **assigned** set, so it will falsely fail a valid *unassigned*
275
+ tenant group — for those, pre-check with `launchpad deploy --dry-run`
276
+ instead (see §A.3).
238
277
 
239
278
  ```bash
240
279
  launchpad plan
@@ -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), or watch provisioning live with `launchpad watch`. 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", "watch my deploy go live", "watch provisioning", "my app says failed but it's serving", or after `/launchpad-deploy` reports a non-`done` terminal stage.
4
- version: 0.40.0
4
+ version: 0.41.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.40.0
4
+ version: 0.41.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-identity
3
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.40.0
4
+ version: 0.41.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-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.40.0
4
+ version: 0.41.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-report
3
3
  description: File a bug report or feature request to the Launchpad team's tracker from the CLI. Use when someone reports something broken, hits an error in a launchpad command, or wishes a feature existed — e.g. "this is broken", "report a bug", "can you file that", "I wish launchpad could…", "/launchpad-bug", "/launchpad-feature". Always confirm and show exactly what you'll send before filing; never file silently.
4
- version: 0.40.0
4
+ version: 0.41.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.40.0
4
+ version: 0.41.1
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -336,6 +336,15 @@ Group binding changed in your local manifest. This is the
336
336
  `allowed-groups-change: true` label from a non-author human reviewer
337
337
  (AC-S4) before it can auto-merge.
338
338
 
339
+ Reconcile it with **`launchpad deploy --apply`** — a plain
340
+ `launchpad deploy` ships content only and does **not** re-apply the
341
+ gateway's access policy. On a gateway app where enforcement is on, the
342
+ bot **refuses** a content deploy while the manifest's groups diverge
343
+ from what the gateway actually enforces (so access changes can't be
344
+ silently dropped); `--apply` is the gated route that reconciles the
345
+ enforced KV allow-list to the manifest. `launchpad status --strict`
346
+ exits non-zero on any drift, so it can gate CI.
347
+
339
348
  ## Related skills
340
349
 
341
350
  - **`/launchpad-deploy`** — provision a new app (`launchpad init`