@optima-chat/dev-skills 0.7.33 → 0.7.35

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 (30) hide show
  1. package/AGENTS.md +2 -0
  2. package/bin/helpers/billing-http.ts +162 -0
  3. package/bin/helpers/confirm-prompt.ts +23 -0
  4. package/bin/helpers/entitlement/grant.ts +70 -0
  5. package/bin/helpers/entitlement/list.ts +77 -0
  6. package/bin/helpers/entitlement/revoke.ts +116 -0
  7. package/bin/helpers/entitlement.ts +32 -0
  8. package/bin/helpers/infisical-secrets.ts +41 -0
  9. package/bin/helpers/product/add-channel.ts +78 -0
  10. package/bin/helpers/product/create.ts +96 -0
  11. package/bin/helpers/product/show.ts +43 -0
  12. package/bin/helpers/product/toggle-channel.ts +56 -0
  13. package/bin/helpers/product/update.ts +72 -0
  14. package/bin/helpers/product.ts +46 -0
  15. package/dist/bin/helpers/billing-http.js +139 -0
  16. package/dist/bin/helpers/confirm-prompt.js +54 -0
  17. package/dist/bin/helpers/entitlement/grant.js +73 -0
  18. package/dist/bin/helpers/entitlement/list.js +62 -0
  19. package/dist/bin/helpers/entitlement/revoke.js +105 -0
  20. package/dist/bin/helpers/entitlement.js +39 -0
  21. package/dist/bin/helpers/infisical-secrets.js +33 -0
  22. package/dist/bin/helpers/product/add-channel.js +95 -0
  23. package/dist/bin/helpers/product/create.js +124 -0
  24. package/dist/bin/helpers/product/show.js +46 -0
  25. package/dist/bin/helpers/product/toggle-channel.js +61 -0
  26. package/dist/bin/helpers/product/update.js +92 -0
  27. package/dist/bin/helpers/product.js +53 -0
  28. package/docs/superpowers/plans/2026-05-24-marketplace-admin-cli-impl.md +1973 -0
  29. package/docs/superpowers/specs/2026-05-24-marketplace-admin-cli-design.md +324 -0
  30. package/package.json +7 -5
@@ -0,0 +1,324 @@
1
+ # Marketplace Admin CLI — Design Spec
2
+
3
+ **Status**: draft for review
4
+ **Date**: 2026-05-24
5
+ **Author**: Jerry (via Claude collaborative spec)
6
+ **Tracking**: Optima-Chat/optima-dev-skills (to be filed)
7
+
8
+ ## 1. Purpose
9
+
10
+ Give the Optima team a CLI surface to manage the paid-plugin marketplace (Wave 1.5) **before any admin UI exists**:
11
+
12
+ 1. Create a `Product` bundling 1+ plugin slugs and set its refund policy.
13
+ 2. Attach a payment `Channel` to a `Product` so it becomes self-purchaseable.
14
+ 3. Toggle a channel on/off.
15
+ 4. Inspect a `Product`'s current state (channels + plugins).
16
+ 5. Grant a `Product` entitlement to a user (admin-grant, no payment).
17
+ 6. Revoke an admin-granted entitlement.
18
+ 7. List a user's entitlements (used by `revoke` internally and exposed standalone).
19
+
20
+ CLI ships inside the existing `@optima-chat/dev-skills` npm package as two new bin entries: `optima-product` and `optima-entitlement`, each with subcommands.
21
+
22
+ ## 2. Background
23
+
24
+ Wave 1.5 (deployed to stage 2026-05-21, prod pending — see [optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43)) shipped a full set of billing admin HTTP endpoints under `/api/billing/admin/*` plus the `Product` / `ProductPlugin` / `ProductChannel` / `Entitlement` data model. The service layer (`product.service.ts`, `entitlement.service.ts`) already encodes:
25
+
26
+ - N:1 Plugin↔Product mapping with `pluginSlugs` ≥ 1 invariant
27
+ - Concurrent grant race handling (partial unique index `entitlements_one_active`)
28
+ - Outbox event creation (`entitlement.granted` / `entitlement.revoked`) with set-difference semantics on revoke (a plugin only appears in `lostPluginSlugs` if no other ACTIVE entitlement covers it)
29
+ - Stripe refund cascade (PAYMENT source) vs no-op for ADMIN_GRANT / PARTNER sources (`EntitlementSource` enum is `PAYMENT | ADMIN_GRANT | PARTNER`)
30
+ - Bundled subscription cascade per spec §6.2/§6.3
31
+
32
+ **Implication for this CLI**: the heavy logic is already centralized in billing. The CLI must NOT reimplement any of it. It is a thin HTTP client that collects arguments, calls the admin endpoint, and surfaces the response/error verbatim.
33
+
34
+ ## 3. Non-goals
35
+
36
+ - **Stripe Dashboard automation**: operator creates the Stripe `Product`+`Price` in the Stripe Dashboard by hand, then passes the resulting `price_xxx` ID to `optima-product add-channel`. No Stripe SDK dependency in this CLI.
37
+ - **prod usage**: prod billing main is still on pre-Wave-1.5 schema ([optima-billing#48](https://github.com/Optima-Chat/optima-billing/pull/48) revert). The CLI accepts `--env prod` but will get 404/error responses until Wave 1.5 lands on prod. That's a billing rollout concern, not a CLI concern.
38
+ - **Revoking PAYMENT or PARTNER-source entitlements**: only `ADMIN_GRANT`-source entitlements may be revoked via this CLI. `PAYMENT` revokes belong to the customer-facing Stripe refund flow (Stripe API + accounting); `PARTNER` revokes (3rd-party-issued grants) need their own out-of-band reversal — neither belongs in an admin convenience CLI.
39
+ - **Listing all products**: no `optima-product list` subcommand. The required endpoint `GET /api/billing/admin/products` does not exist — see [optima-billing#58](https://github.com/Optima-Chat/optima-billing/issues/58). When that endpoint ships, add the subcommand in a follow-up.
40
+ - **Admin UI**: this CLI is the bridge until a web admin UI exists. It is not a replacement.
41
+
42
+ ## 4. Architecture
43
+
44
+ ```
45
+ ┌──── Infisical (BILLING_URL + client_secret)
46
+
47
+ operator's shell ──────┼──── user-auth /api/v1/oauth/token (grant_type=client_credentials)
48
+ $ optima-product │ → service JWT (type=service, clientId=dev-skills-ubd3qz6n)
49
+ $ optima-entitlement │
50
+
51
+ ┌─────────────────────────────────────┐ SQL ┌─────────────┐
52
+ │ optima-billing (stage) │ ────────► │ billing DB │
53
+ │ POST /api/billing/admin/products │ │ │
54
+ │ POST /api/billing/admin/ │ └─────────────┘
55
+ │ grant-entitlement │
56
+ │ POST /api/billing/admin/ │ same tx
57
+ │ refund-entitlement ▼
58
+ │ GET /api/billing/admin/entitlements outbox_events
59
+ │ GET /api/internal/products/:key │
60
+ └─────────────────────────────────────┘ │
61
+ │ async dispatch
62
+
63
+ optima-skills (existing)
64
+ entitlement.granted → UserPlugin upsert
65
+ entitlement.revoked → UserPlugin deleteMany
66
+ ```
67
+
68
+ Three external dependencies per invocation: Infisical (config + secret lookup), user-auth (M2M token mint), optima-billing (admin endpoint).
69
+
70
+ Components:
71
+
72
+ - **CLI bin** (`bin/cli.js`): top-level dispatcher for `product` / `entitlement` subcommands. Mirrors how existing bins route flags.
73
+ - **Subcommand handlers** (`bin/helpers/product/*.ts`, `bin/helpers/entitlement/*.ts`): one file per subcommand, each does arg parsing → token acquisition → HTTP call → JSON pretty-print or `BillingError` surface.
74
+ - **Shared HTTP module** (`bin/helpers/billing-http.ts`, new): wraps `fetch` with: auth header injection, base URL resolution per env, response envelope unwrap, error formatting. Mirrors `db-utils.ts`'s role for DB.
75
+ - **Auth module** (`bin/helpers/m2m-auth.ts`, new): `getServiceToken(env)` → Infisical fetch of OAuth credentials → `POST /api/v1/oauth/token` with `grant_type=client_credentials` → cache token in memory for the process lifetime.
76
+
77
+ Dependencies on existing infra:
78
+
79
+ - `db-utils.getInfisicalConfig` / `getInfisicalToken` — reused as-is to authenticate to Infisical.
80
+ - No new npm dependencies. `fetch` is Node 18+ native; `package.json` `engines.node` is currently `">=14.0.0"`. Implementation plan T1 bumps it to `">=18.0.0"` (current install confirmed on Node 22 per `/home/jerry/.nvm/versions/node/v22.13.0/lib/node_modules/@optima-chat/dev-skills`; `>=18` is a safety floor, not a tightening of the runtime).
81
+
82
+ ## 5. Command surface
83
+
84
+ All commands accept `--env stage|prod` (default `stage`) and `-h`/`--help`.
85
+
86
+ ### 5.1 `optima-product`
87
+
88
+ ```
89
+ optima-product create
90
+ --key <productKey> required, unique slug; matches Wave 1.5 productKey rules
91
+ --plugins <slug1,slug2,...> required, comma-separated, ≥1 plugin slug; CLI passes through verbatim (duplicates rejected server-side as INVALID_PLUGIN_SLUGS)
92
+ --type <ProductType> required. Schema enum (`prisma/schema.prisma`) supports `SUBSCRIPTION_PLAN | ONE_SHOT_SKILL`. **v1 CLI policy: accept only ONE_SHOT_SKILL** because the grant path (`entitlement.service.ts` grant() check) hard-rejects other types with "Only ONE_SHOT_SKILL grants supported", and there's no roadmap to introduce SUBSCRIPTION_PLAN products via CLI before an admin UI lands. If that changes, widen this flag — server `createProduct` itself accepts both.
93
+ [--name "..."] optional; convenience flag — folded into product.metadata.name by billing service (Product schema has no name column)
94
+ [--description "..."] optional; convenience flag — folded into product.metadata.description
95
+ [--refund-window-days N] optional
96
+ [--refund-prorate-max-days N] optional
97
+ [--bundled-plan-id <planId>] optional; for products that also grant a subscription
98
+ [--bundled-duration-days N] optional; required iff --bundled-plan-id given
99
+ [--revoke-bundled-on-refund true|false] optional, defaults to billing's default (true)
100
+ [--metadata <json-string>] optional, JSON object stored in product.metadata; merged with --name/--description if both supplied (--name/--description take precedence per service:79-82)
101
+ [--env stage|prod]
102
+
103
+ POST /api/billing/admin/products
104
+ Body: { productKey, type, pluginSlugs: [...], name?, description?, refundWindowDays?, refundProrateMaxDays?,
105
+ bundledPlanId?, bundledDurationDays?, revokeBundledOnRefund?, metadata? }
106
+ ```
107
+
108
+ Output (success): the created `Product` row pretty-printed as JSON, plus a one-line summary.
109
+
110
+ ```
111
+ optima-product update
112
+ --key <productKey> required
113
+ [--refund-window-days N] optional
114
+ [--refund-prorate-max-days N] optional
115
+ [--bundled-plan-id <planId>] optional (joint constraint with --bundled-duration-days)
116
+ [--bundled-duration-days N] optional
117
+ [--revoke-bundled-on-refund true|false] optional
118
+ [--metadata <json-string>] optional (full-replace; PATCH endpoint does not merge nested keys)
119
+ [--env stage|prod]
120
+
121
+ PATCH /api/billing/admin/products/:key
122
+ ```
123
+
124
+ Note: `productKey`, `type`, and `pluginSlugs` are immutable post-create (not in `UpdateProductInput`). To change plugin membership, create a new Product with a new key.
125
+
126
+ ```
127
+ optima-product add-channel
128
+ --key <productKey> required
129
+ --provider STRIPE required; today only STRIPE supported by this CLI
130
+ (ALIPAY/WECHAT_PAY/AIRWALLEX exist in schema but operator flow differs — no auto-create endpoint per provider)
131
+ --stripe-price-id <price_xxx> required; pre-created in Stripe Dashboard. Wire-mapped to body field `externalProductId` (provider-neutral name in billing schema)
132
+ --price-cents N required; MUST be > 0 (server rejects 0/negative with INVALID_PRICE). Should match the Stripe Price's unit_amount — operator's responsibility; billing does NOT call Stripe to verify
133
+ --currency USD required; should match the Stripe Price's currency (also not server-verified against Stripe)
134
+ [--enabled true|false] optional; defaults to true (channel goes immediately live). Use `--enabled false` to create a disabled channel (saves a `toggle-channel` round-trip when staging a future launch)
135
+ [--metadata <json-string>] optional
136
+ [--env stage|prod]
137
+
138
+ POST /api/billing/admin/products/:key/channels
139
+ Body: { provider, externalProductId, priceCents, currency, enabled?, metadata? }
140
+ ```
141
+
142
+ ```
143
+ optima-product toggle-channel
144
+ --key <productKey> required
145
+ --provider STRIPE required
146
+ --enabled true|false required
147
+ [--env stage|prod]
148
+
149
+ PATCH /api/billing/admin/products/:key/channels/:provider
150
+ Body: { enabled }
151
+ ```
152
+
153
+ ```
154
+ optima-product show
155
+ --key <productKey> required
156
+ [--env stage|prod]
157
+
158
+ GET /api/internal/products/:key
159
+ ```
160
+
161
+ Note on auth: despite the `/api/internal/` path prefix (vs `/api/billing/admin/`), this endpoint still requires a valid service-client M2M token (`extractAnyServiceAuth`, admin-products.ts:138). Same auth flow as the admin routes — no special handling.
162
+
163
+ Output: bare `Product` row (productKey, type, refund fields, metadata, timestamps). **Does NOT include `productPlugins` or `channels` arrays** — `getProductByKey` in `product.service.ts:234` is a plain `findUnique` with no `include`. For the full picture, operator must also call `optima-entitlement list` (for grants) or query the DB directly for channels/plugins. A follow-up will request billing to add `?include=plugins,channels` or expose a richer admin endpoint — see §7.
164
+
165
+ ### 5.2 `optima-entitlement`
166
+
167
+ ```
168
+ optima-entitlement grant
169
+ --email <user-email> required; CLI resolves to userId via user-auth (see §6.2)
170
+ --product-key <productKey> required
171
+ --justification "..." required; billing returns 400 without it; stored on entitlement.justification (free-text audit)
172
+ [--yes] skip the prod confirmation prompt (see §6.6); no-op on --env stage
173
+ [--env stage|prod]
174
+
175
+ POST /api/billing/admin/grant-entitlement
176
+ ```
177
+
178
+ Body: `{ userId, productKey, justification }`. The endpoint hardcodes `source=ADMIN_GRANT`, `priceCents=0`, `currency=USD`, and `grantedBy=<clientId from auth>`. CLI only forwards the three user-supplied fields.
179
+
180
+ ```
181
+ optima-entitlement revoke
182
+ --email <user-email> required
183
+ --product-key <productKey> required
184
+ --reason "..." required; billing returns 400 without it; stored on entitlement.refundReason
185
+ [--yes] skip the prod confirmation prompt (see §6.6); no-op on --env stage
186
+ [--env stage|prod]
187
+
188
+ Internal flow:
189
+ 1. **Fetch** GET /api/billing/admin/entitlements?userId=<resolved> → response shape `{ entitlements: [...] }`. Endpoint returns all statuses (ACTIVE, REFUNDED) — CLI filters client-side.
190
+ 2. **Filter** for status=ACTIVE AND productKey=<arg>.
191
+ 3. **Validate count** — expect exactly 0 or 1 match (DB partial unique `entitlements_one_active` enforces ≤1 ACTIVE per (user, productKey)).
192
+ 0 → exit 1 with "no active entitlement for (user, product)"
193
+ 1 → take entitlementId
194
+ 4. **Validate source** — refuse if source ≠ ADMIN_GRANT.
195
+ Exit 1 with source-specific message:
196
+ source=PAYMENT → "refusing to revoke a PAYMENT-source entitlement via CLI; this would leave the customer charged but unentitled. Use the Stripe refund flow which calls Stripe refund API + records refundedAmountCents + emits webhook. Manual psql is the escape hatch if absolutely necessary."
197
+ source=PARTNER → "refusing to revoke a PARTNER-source entitlement via CLI; PARTNER grants are issued out-of-band and must be reversed via the partner contract / process that issued them. Manual psql is the escape hatch if absolutely necessary."
198
+ 5. **Refund** POST /api/billing/admin/refund-entitlement with `{ entitlementId, refundReason }`.
199
+
200
+ Race window note: between step 1 and step 5 two scenarios are possible:
201
+ - **PAYMENT lands concurrently**: blocked by partial unique index `entitlements_one_active` while the ADMIN_GRANT row is still ACTIVE; can only land after our refund flips status to REFUNDED. No corruption.
202
+ - **Two operators racing concurrent revokes**: both list the same ACTIVE entitlementId in step 1. First refund wins (status → REFUNDED). Second refund hits billing with a stale id; billing's `entSvc.revoke` either no-ops (idempotent on REFUNDED) or returns an error — either way the CLI surfaces it as a clear failure and exits 1.
203
+
204
+ A future server-side `POST /admin/refund-by-product` (atomic lookup + refund under `FOR UPDATE`) could close both windows, but neither is a real risk for an infrequent admin CLI.
205
+ ```
206
+
207
+ ```
208
+ optima-entitlement list
209
+ --email <user-email> required
210
+ [--env stage|prod]
211
+
212
+ GET /api/billing/admin/entitlements?userId=<resolved>
213
+ ```
214
+
215
+ Response shape: `{ entitlements: Entitlement[] }`. Output: table of entitlements, newest first, columns: `id | productKey | status | source | purchasedAt | refundedAt`.
216
+
217
+ ## 6. Implementation notes
218
+
219
+ ### 6.1 Environment resolution
220
+
221
+ - **`BILLING_URL`**: read from Infisical at `secretPath=/shared-secrets/domain-urls`. Indirect evidence the secret exists on **stage**: billing's `BILLING_PUBLIC_URL` env is configured as `${staging.shared-secrets.domain-urls.BILLING_URL}` (visible in `optima-show-env billing stage` output) — Infisical resolves the substitution at injection time, so the underlying secret must exist. Direct verification (Universal-Auth fetch of the path itself) and prod-side existence are deferred to T1 — see §10. No existing dev-skills helper reads from `/shared-secrets/domain-urls`, so implementation extends `db-utils` (or adds a sibling helper). Note the env-name mismatch: billing's runtime env var is `BILLING_PUBLIC_URL`, but the Infisical secret name is just `BILLING_URL` — CLI uses the Infisical secret name.
222
+ - **OAuth credentials**: dev-skills client is `clientId=dev-skills-ubd3qz6n` (existing OAuth client, already on billing's `ADMIN_SERVICE_ALLOWLIST` via the `dev-skills-<suffix>` prefix rule). The `client_secret` location in Infisical is **the one blocking open question** (see §10) — T1 of the implementation plan must locate or add it.
223
+
224
+ ### 6.2 Email → userId resolution
225
+
226
+ Reuse `db-utils.resolveUserId(email, env, infisicalConfig, token)` (4-arg signature, see `bin/helpers/db-utils.ts:169`). Caller must already have `infisicalConfig` + Infisical access token in hand from §6.1's lookup — these are passed through to `resolveUserId`. No new helper code needed.
227
+
228
+ ### 6.3 M2M token acquisition
229
+
230
+ ```ts
231
+ POST {USER_AUTH_URL}/api/v1/oauth/token
232
+ Content-Type: application/x-www-form-urlencoded
233
+ Body: grant_type=client_credentials
234
+ &client_id=dev-skills-ubd3qz6n
235
+ &client_secret=<secret>
236
+ ```
237
+
238
+ Response: `{ access_token, token_type, expires_in }`. The JWT must contain `type: "service"` claim — billing's `extractAnyServiceAuth` (auth.ts ~340) hard-rejects tokens without it with 403 FORBIDDEN. T1 verifies via a smoke `curl` that user-auth's `client_credentials` grant actually sets this claim (the billing comment at auth.ts ~322 implies it does, but the verify-response shape doesn't carry it back, so the JWT itself must be decoded to confirm).
239
+
240
+ **Token lifecycle**: the M2M auth module exposes `getServiceToken(env)` which memoizes within the process. Each CLI invocation calls it once at startup, and every HTTP call thereafter reuses the same token — important for multi-call flows like `revoke` (list + refund = 2 calls) or compound smoke tests. **No cross-invocation cache** (every fresh CLI process re-mints) — adequate since token TTLs (typically ≥1 hour) far exceed any single CLI invocation.
241
+
242
+ ### 6.4 Error handling
243
+
244
+ Billing's dominant envelope is the flat shape `{ error: "CODE_STRING", message: "..." }` (emitted by the global error handler at `src/app.ts:99-118` for all BillingError throws + validation + internal errors — verified during plan R1). A nested shape `{ error: { code, message } }` appears on a few inline returns (e.g. admin-products.ts toggle-channel 400/404). CLI handles both; printed format collapses to:
245
+
246
+ ```
247
+ ❌ Error [<HTTP status>] <code>: <message>
248
+ ```
249
+
250
+ and **always exits 1** on any error (auth failure, validation failure, server error, network failure — all collapse to exit 1; matches existing `grant-subscription` etc. precedent). No retries on 4xx; one retry on 5xx (billing already has its own idempotency story for grant via `idempotencyKey`).
251
+
252
+ **Non-envelope fallback**: if a response is non-2xx but the body isn't valid JSON or lacks the `{error: {code, message}}` shape (e.g. raw 502 from an upstream load balancer, plain-text 500 from a crashed handler before the error middleware runs), CLI prints:
253
+
254
+ ```
255
+ ❌ Error [<HTTP status>] <status text>
256
+ Response body (first 500 bytes): <truncated body>
257
+ ```
258
+
259
+ So operators always see SOMETHING actionable on failure.
260
+
261
+ ### 6.5 Output format
262
+
263
+ Default: human-readable summary lines + the response object pretty-printed.
264
+ Future: `--json` flag for machine-readable output. **Not in initial scope** — add when first consumer requires it.
265
+
266
+ ### 6.6 Safety rails
267
+
268
+ - Default `--env stage`. To run against prod, operator must type `--env prod` explicitly.
269
+ - `optima-entitlement revoke` MUST refuse non-`ADMIN_GRANT` sources (`PAYMENT`, `PARTNER`) with a source-specific message (see §5.2). **No `--force` escape** — operator drops to psql if they truly need to bypass (logged + auditable that way). Rationale: PAYMENT revoke via CLI without Stripe-side refund would leave the customer charged but unentitled; PARTNER revoke without partner-process reversal violates the issuance contract — both strictly worse than the manual escape hatch.
270
+ - **prod safety prompt**: when `--env prod`, `grant` and `revoke` print the resolved action (`userId + productKey + source + new status`) and require typing `yes` to confirm. `--yes` flag bypasses the prompt (for scripted ops with prior verification). Stage stays no-prompt for ergonomic iteration. Rationale: typo'd `--email` that happens to match a different real user would silently affect the wrong account; one-time human-in-the-loop is cheap insurance for prod-only operations.
271
+ - `optima-product create` does NOT auto-add a channel. A product without a channel is admin-grant-only; making it self-purchaseable is a deliberate second step.
272
+
273
+ ## 7. Out-of-scope follow-ups
274
+
275
+ | Item | Why deferred |
276
+ |---|---|
277
+ | `optima-product list` subcommand | Endpoint missing — see [optima-billing#58](https://github.com/Optima-Chat/optima-billing/issues/58). Add CLI side once endpoint ships. |
278
+ | `optima-product show` returning plugins + channels | Endpoint `getProductByKey` is bare `findUnique` with no `include`. File billing issue to add `?include=plugins,channels` or expose `/api/billing/admin/products/:key` (admin-richer view). Until then, `show` returns the bare Product row only. |
279
+ | `optima-entitlement show <id>` | Billing has no fetch-by-id endpoint; list-by-userId is the only path. File billing issue to add `GET /api/billing/admin/entitlements/:id`. Until then, use `optima-entitlement list` and grep by id. |
280
+ | Auto-create Stripe Product+Price | Requires Stripe SDK + per-env Stripe key wiring. Manual Dashboard creation is fine for the small initial paid-plugin catalog. |
281
+ | Non-STRIPE channel providers (ALIPAY, WECHAT_PAY, AIRWALLEX) | Each has its own out-of-band registration flow (or none at all). Add per-provider subcommand only when a real product needs it. |
282
+ | `--json` flag for machine-readable output | Add when first scripted consumer requires it. |
283
+ | Rotation of `dev-skills-ubd3qz6n` OAuth secret | Tracked by Wave 1.5 plan T10 step 8 ([optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43)). Independent. |
284
+ | `optima-product delete` subcommand | No `DELETE /api/billing/admin/products/:key` endpoint exists (verified). Wave 1.5 spec §2.7 is append-only — products are never deleted, only channels disabled via `toggle-channel`. Listed here for completeness; not anticipated. |
285
+ | `--verify-sync` flag on grant/revoke (poll skills UserPlugin after) | Nice-to-have closing the cross-service async loop without operator dropping to `optima-query-db`. Low cost (one query). Add when first operator hits the gap. |
286
+ | `optima-product show --with-db-verify` (parallel ProductPlugin + ProductChannel DB queries to fill the gap from bare `getProductByKey`) | Reasonable workaround for the §7 enrichment gap, but adds SSH tunnel + SQL overhead per show. Wait until billing endpoint enriches OR the gap actually bites in practice. |
287
+ | Bulk grant / revoke (e.g. CSV of emails) | YAGNI until a real campaign needs it. |
288
+ | prod billing deploy of Wave 1.5 | Out of this repo's control. CLI is forward-compatible. Prod main reverted via [optima-billing#48](https://github.com/Optima-Chat/optima-billing/pull/48); integration→main PR gated on Wave 1.5 tracker [optima-billing#43](https://github.com/Optima-Chat/optima-billing/issues/43). |
289
+
290
+ ## 8. Testing approach
291
+
292
+ - **No unit tests** for the HTTP wrapper / arg parsing. Pattern matches existing `grant-subscription` etc.
293
+ - **Stage smoke** is the primary verification:
294
+ 1. `optima-product create --key smoke-cli-1 --plugins skillify --type ONE_SHOT_SKILL --env stage` → 201
295
+ 2. `optima-product show --key smoke-cli-1 --env stage` → returns Product with 1 plugin, 0 channels
296
+ 3. `optima-entitlement grant --email pro.xu.optima@gmail.com --product-key smoke-cli-1 --env stage` → 201
297
+ 4. `optima-entitlement list --email pro.xu.optima@gmail.com --env stage` → includes the new entitlement, status=ACTIVE, source=ADMIN_GRANT
298
+ 5. Verify outbox + skills sync:
299
+ a. `optima-query-db billing "SELECT id, event_type, status, payload FROM outbox_events WHERE event_type='entitlement.granted' ORDER BY created_at DESC LIMIT 1" stage` → DELIVERED row. (Verify column case at smoke time — billing tables in this area are snake_case `outbox_events.event_type` per Wave 1.5 migrations; column quoting may need adjustment if Prisma generated camelCase columns. Outbox `payload` column may carry a billing-internal envelope rather than the verbatim wire shape; implementer pins the exact field path during T-final smoke.)
300
+ b. `optima-query-db skills "SELECT up.\"userId\", p.slug FROM \"UserPlugin\" up JOIN \"Plugin\" p ON p.id = up.\"pluginId\" WHERE up.\"userId\"='<resolved>' AND p.slug='skillify'" stage` → exactly 1 row. Skills schema: `UserPlugin` is `(userId, pluginId, installedAt)` with **no status column** (`optima-skills/prisma/schema.prisma:153`); grants `upsert` a row, revokes `deleteMany` it. Post-revoke step 7 asserts the row is absent, not that any status flipped.
301
+ 6. `optima-entitlement revoke --email pro.xu.optima@gmail.com --product-key smoke-cli-1 --env stage` → 200
302
+ 7. `optima-entitlement list --email pro.xu.optima@gmail.com --env stage` → status=REFUNDED, refundedAt set. Also re-run step 5b query → 0 rows (UserPlugin row deleted by skills revoke handler).
303
+ 8. (Optional) `optima-product add-channel --key smoke-cli-1 --provider STRIPE --stripe-price-id <fresh test Price> --price-cents 100 --currency USD --env stage` → 201, then `optima-product show` reflects the channel
304
+ - **Clean up**: leave stage Product rows in place (Wave 1.5 spec §2.7 append-only convention) but disable the test channel via `toggle-channel --enabled false`.
305
+
306
+ ## 9. Risks & mitigations
307
+
308
+ | Risk | Mitigation |
309
+ |---|---|
310
+ | Stage Infisical `ADMIN_SERVICE_ALLOWLIST` overridden to not include `dev-skills` | Verified 2026-05-24: not overridden; falls back to default `"sales-page,dev-skills"`. If broken later, the 403 message names the offending clientId for fast diagnosis. |
311
+ | `dev-skills-ubd3qz6n` client secret location in Infisical not yet known | Implementation plan first task is to locate or set up the secret path; spec proceeds. |
312
+ | Operator confuses ADMIN_GRANT for free trial / runs revoke on a PAYMENT or PARTNER entitlement | Revoke refuses non-ADMIN_GRANT source with a source-specific message (§5.2 step 4); grant is explicit (no implicit "free trial" mode). |
313
+ | Typo'd `--email` on prod silently affects wrong real user | §6.6 prod-only confirmation prompt prints resolved userId before action; `--yes` bypass exists for scripted ops with prior verification. |
314
+ | `dev-skills-ubd3qz6n` OAuth client is shared with other test infra (test-token minting, smoke scripts) — concurrent CLI ops + ambient test traffic share the same client's token-mint quota and rate limits | Single-action CLI runs are infrequent enough that contention is unlikely in practice. If hit, error surfaces as user-auth 429/5xx with a clear retry path. Real fix (if it ever bites): mint a dedicated `dev-skills-admin-cli` OAuth client at impl time. Listed here so the trade-off is explicit. |
315
+ | Billing service layer changes break wire contract | CLI calls public admin endpoints; any breaking change to those would be a billing release concern surfaced at call time as a clear HTTP error. |
316
+ | prod usage attempt before Wave 1.5 lands prod | Billing returns 404/500 with clear error; CLI passes it through. Documented in §3 non-goals. |
317
+
318
+ ## 10. Open questions
319
+
320
+ 1. **[BLOCKING for implementation T1]** Where in Infisical does `dev-skills-ubd3qz6n`'s `client_secret` live? Candidates: `/services/dev-skills/CLIENT_SECRET`, `/shared-secrets/oauth/dev-skills/CLIENT_SECRET`, or it may need to be added fresh. T1 of the implementation plan must locate or create this path on both stage AND prod Infisical envs before any HTTP code is written.
321
+
322
+ 2. **[BLOCKING for implementation T1]** Does Infisical's `/shared-secrets/domain-urls/BILLING_URL` exist on **prod**? Verified to exist on stage (billing's own `BILLING_PUBLIC_URL` references it). If prod is missing, T1 also adds it (CLI uses `--env prod` would otherwise fail at URL lookup before reaching billing).
323
+
324
+ 3. **[VERIFY in T1, not blocking design]** Does user-auth's `client_credentials` grant actually mint a JWT with `type: "service"` claim? Inferred from billing's `extractAnyServiceAuth` comments but never observed directly. A 30-second `curl` smoke will confirm.
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@optima-chat/dev-skills",
3
- "version": "0.7.33",
3
+ "version": "0.7.35",
4
4
  "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "optima-dev-skills": "bin/cli.js",
8
- "optima-query-db": "dist/bin/helpers/query-db.js",
8
+ "optima-entitlement": "dist/bin/helpers/entitlement.js",
9
9
  "optima-generate-test-token": "dist/bin/helpers/generate-test-token.js",
10
- "optima-show-env": "dist/bin/helpers/show-env.js",
10
+ "optima-grant-balance": "dist/bin/helpers/grant-balance.js",
11
11
  "optima-grant-subscription": "dist/bin/helpers/grant-subscription.js",
12
- "optima-grant-balance": "dist/bin/helpers/grant-balance.js"
12
+ "optima-product": "dist/bin/helpers/product.js",
13
+ "optima-query-db": "dist/bin/helpers/query-db.js",
14
+ "optima-show-env": "dist/bin/helpers/show-env.js"
13
15
  },
14
16
  "scripts": {
15
17
  "postinstall": "node scripts/install.js",
@@ -36,7 +38,7 @@
36
38
  },
37
39
  "homepage": "https://github.com/Optima-Chat/optima-dev-skills#readme",
38
40
  "engines": {
39
- "node": ">=14.0.0"
41
+ "node": ">=18.0.0"
40
42
  },
41
43
  "files": [
42
44
  ".claude",