-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcohort.ts
More file actions
310 lines (284 loc) · 14.3 KB
/
Copy pathcohort.ts
File metadata and controls
310 lines (284 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// WS1-P1 — test-cohort identity for real-backend (LIVE) Playwright runs.
//
// Plan: docs/sessions/2026-06-04/OBSERVABILITY-AND-INTELLIGENCE-PLAN.md, WS1.
//
// Every entity a LIVE spec creates (team / account / resource / deploy) is
// tagged with a recognizable, machine-greppable prefix so it is:
// 1. identifiable for cleanup (the reaper sweeps by this prefix as a
// backstop, in addition to the per-run ledger), and
// 2. recognizable by the FOLLOW-UP backend skip-cohort guards (a separate
// api/worker PR) so quota/abuse/churn/billing-charge paths NO-OP for
// test-cohort teams — a LIVE run must never email a "we miss you", burn a
// real quota budget, or attempt a real charge.
//
// This is the instanode-web side ONLY. The backend `is_test_cohort` column +
// the guards that read it ship in the api/worker tree (PR #260): a minted team
// is `is_test_cohort=true`, and the live worker skip-guards neuter
// billing/churn/email/quota for it. With those guards + the mint endpoint
// (cohort-scoped) + the reaper all live, a SANCTIONED minted-account run MAY
// target prod (see assertSafeApiTarget below). An un-sanctioned/un-tokened run
// against prod is still REFUSED so a stray invocation can never hammer prod.
//
// The contract the backend guards will key on (kept here as the single source
// of truth for the string the two repos share):
// - cohort emails ⇒ local-part starts with `e2e-cohort+`
// - cohort names ⇒ resource/team names start with `e2e-cohort-`
// - cohort marker ⇒ the literal token COHORT_MARKER appears in the
// email local-part and in every created name, so a
// single `LIKE '%e2e-cohort%'` (or the future
// `is_test_cohort` backfill) catches all of it.
/**
* The literal token that brands every cohort entity. Backend skip-cohort
* guards (follow-up PR) match teams/resources whose email or name contains
* this substring. Keep in sync with the api/worker guard constant when that
* lands. Exported as a const (not an inline string) per the project's
* no-hardcoded-strings rule.
*/
export const COHORT_MARKER = 'e2e-cohort'
/** Email local-part prefix, e.g. `e2e-cohort+<run>-<rand>@instanode.dev`. */
export const COHORT_EMAIL_PREFIX = `${COHORT_MARKER}+`
/** Resource / team name prefix, e.g. `e2e-cohort-smoke-db-<rand>`. */
export const COHORT_NAME_PREFIX = `${COHORT_MARKER}-`
/** Domain used for cohort emails. instanode.dev keeps them on our own domain. */
export const COHORT_EMAIL_DOMAIN = 'instanode.dev'
/**
* A stable id for one Playwright run. Used to namespace the ledger file AND to
* stamp every created entity, so a failed run's leftovers are attributable to
* the run that made them. Honours CI's run id when present.
*/
export function runId(): string {
const ci = process.env.E2E_LIVE_RUN_ID || process.env.GITHUB_RUN_ID
if (ci) return String(ci)
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function rand(): string {
return Math.random().toString(36).slice(2, 10)
}
/**
* A cohort email. Uses the `+`-subaddress form so every cohort send routes to
* one mailbox (`e2e-cohort@instanode.dev`) yet stays unique per call — and so
* the backend guard can match on the `e2e-cohort+` local-part prefix.
*/
export function cohortEmail(label = 'smoke'): string {
return `${COHORT_EMAIL_PREFIX}${label}-${runId()}-${rand()}@${COHORT_EMAIL_DOMAIN}`
}
/** A cohort-branded resource/team name. */
export function cohortName(label = 'res'): string {
return `${COHORT_NAME_PREFIX}${label}-${rand()}`
}
/** Predicate the reaper uses to decide if a name/email belongs to the cohort. */
export function isCohortBranded(value: string | null | undefined): boolean {
return !!value && value.includes(COHORT_MARKER)
}
// ── Prod-target safety (item 3) ──────────────────────────────────────────────
// LIVE specs create REAL backend resources. Originally cohort.ts refused any
// prod E2E_API_URL outright (only STAGING was safe). Now that the backend
// `is_test_cohort` skip-guards (PR #260), the cohort-scoped mint endpoint, and
// the reaper are all live, a prod run is safe IFF it is a SANCTIONED
// minted-account run — i.e. it carries a mint token (E2E_ACCOUNT_TOKEN, used by
// the CI workflow to mint/reap the account) or an already-minted session JWT
// (E2E_SESSION_JWT). A prod target WITHOUT either is still refused, so a stray /
// mis-configured invocation can never provision-and-leak against prod.
/** The prod api host. A prod target is only allowed for a sanctioned minted run. */
export const PROD_API_HOST = 'api.instanode.dev'
/** True when the resolved api base points at the prod api host. */
export function isProdApiTarget(apiUrl: string): boolean {
if (!apiUrl) return false
try {
return new URL(apiUrl).host.toLowerCase() === PROD_API_HOST
} catch {
// Not a parseable URL — be conservative and substring-match the host so a
// malformed-but-prod-looking value can't slip past as "not prod".
return apiUrl.toLowerCase().includes(PROD_API_HOST)
}
}
/**
* True when this process is a SANCTIONED minted-account run: it holds a mint
* token (the workflow mints/reaps the account out-of-band) or an already-minted
* session JWT. Either proves the run is the cohort-scoped, reaped, skip-guarded
* path rather than a stray prod invocation.
*/
export function isSanctionedMintedRun(): boolean {
return !!(process.env.E2E_ACCOUNT_TOKEN || process.env.E2E_SESSION_JWT)
}
/**
* Guard a LIVE spec's resolved api target. Throws (failing the spec loudly,
* never silently passing) when E2E_API_URL points at prod WITHOUT a sanctioned
* minted-account run. Staging targets and sanctioned prod runs pass through.
* Specs call this once at module load (before any provision) via topGuard().
*/
export function assertSafeApiTarget(apiUrl: string): void {
if (isProdApiTarget(apiUrl) && !isSanctionedMintedRun()) {
throw new Error(
`Refusing to run LIVE E2E against prod (${PROD_API_HOST}) without a sanctioned ` +
`minted-account run. Set E2E_ACCOUNT_TOKEN (CI mints+reaps a cohort account) ` +
`or E2E_SESSION_JWT (a pre-minted cohort session), or point E2E_API_URL at staging. ` +
`This guard exists so a stray run can never provision-and-leak real prod resources.`,
)
}
}
// ── Workflow-minted account (item 2) ─────────────────────────────────────────
// The prod E2E workflow mints an ephemeral cohort account up front
// (POST /internal/e2e/account) and exports its session JWT + identity into the
// env. When E2E_SESSION_JWT is set, the authed legs use THAT account's bearer
// instead of self-minting from E2E_JWT_SECRET — so the authed flow runs against
// prod as a real, skip-guarded cohort team. Anon legs are unaffected.
/** The minted account's identity + bearer, surfaced from the workflow env. */
export interface MintedSession {
/** Bearer token for authed requests (the api session JWT). */
token: string
/** The minted team's id (the workflow reaps the account by this out-of-band). */
teamID: string
/** The minted user's email, when the workflow exported it. */
email: string
/** The minted tier (e.g. 'pro'), when the workflow exported it. */
tier: string
}
/**
* Returns the workflow-minted session when E2E_SESSION_JWT is set, else null.
* Authed legs prefer this over self-minting so a prod run uses a real cohort
* account. E2E_TEAM_ID / E2E_ACCOUNT_EMAIL / E2E_ACCOUNT_TIER are the companion
* fields the workflow exports from the mint response.
*/
export function mintedSession(): MintedSession | null {
const token = process.env.E2E_SESSION_JWT
if (!token) return null
return {
token,
teamID: process.env.E2E_TEAM_ID ?? '',
email: process.env.E2E_ACCOUNT_EMAIL ?? '',
tier: process.env.E2E_ACCOUNT_TIER ?? '',
}
}
// ── Per-fingerprint bypass for anonymous provisions (ISSUE 1) ────────────────
// Prod does NOT trust X-Forwarded-For (ingress-nginx overwrites it with the real
// client IP), so the CI runner's many anon provisions all collapse onto ONE real
// fingerprint (SHA256(/24 + ASN), rule 6). That single fingerprint trips the
// free-tier recycle gate → 402 free_tier_recycle_requires_claim the moment a
// previous run's anonymous resource has aged out (recycle_seen:<fp> set, no
// active resource — api internal/handlers/provision_helper.go).
//
// The api's REAL bypass (internal/middleware/fingerprint.go): a request bearing
// a valid X-E2E-Test-Token (matched against the cluster's E2E_TEST_TOKEN secret)
// may override the fingerprint source IP via the dedicated **X-E2E-Source-IP**
// header — NOT X-Forwarded-For (which the ingress overwrites). A unique source
// IP per call yields a fresh fingerprint per provision → no recycle_seen marker
// → no 402. Sending only X-Forwarded-For (the previous bug) did nothing on prod
// because the middleware never reads XFF for the override. We now send BOTH the
// token AND X-E2E-Source-IP so the override actually takes on prod; XFF is kept
// for staging/local where the proxy IS trusted.
/** The header name the api's fingerprint middleware honours to accept the bypass token. */
export const E2E_TEST_TOKEN_HEADER = 'X-E2E-Test-Token'
/** The header the api's fingerprint middleware reads for the override source IP. */
export const E2E_SOURCE_IP_HEADER = 'X-E2E-Source-IP'
/**
* Headers an anonymous provision should carry. On prod the load-bearing pair is
* X-E2E-Test-Token (the bypass token) + X-E2E-Source-IP (a unique override IP →
* a fresh fingerprint per call → no recycle gate). X-Forwarded-For is also set
* with the same unique IP for staging/local where the proxy is trusted. All are
* harmless together: on prod the override wins, elsewhere the XFF varies.
*/
export function anonProvisionHeaders(extra: Record<string, string> = {}): Record<string, string> {
const ip = uniqueIP()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Forwarded-For': ip,
...extra,
}
const token = process.env.E2E_TEST_TOKEN
if (token) {
headers[E2E_TEST_TOKEN_HEADER] = token
// The override source IP the middleware actually reads (XFF is ignored on
// prod). A unique value per call → a fresh fingerprint → no recycle gate.
headers[E2E_SOURCE_IP_HEADER] = ip
}
return headers
}
// ── Authed provision (workflow-minted pro account) ───────────────────────────
// On a SANCTIONED prod run the workflow mints an is_test_cohort=true PRO account
// and exports its session JWT (E2E_SESSION_JWT). Provisioning AS that account is
// the decisive fix for both prod anon failures:
// 1. No 402: authed provisioning skips the anonymous recycle gate entirely
// (the gate runs only on the anon path), and tier=pro has ample headroom.
// 2. No 401 on reap: the resource is OWNED by the minted team, so the reaper's
// authed DELETE /api/v1/resources/:id (with the same bearer) returns 200 —
// there is NO unauthed resource-delete on the api, so anon resources could
// only ever be TTL-cleaned (the 401 the last run hit).
// The minted account is reaped wholesale by the workflow teardown (cascade), so
// every resource it owns is double-covered.
/**
* Headers for an AUTHENTICATED provision as the given bearer (the minted pro
* account's session JWT). A unique X-Forwarded-For keeps per-call fingerprints
* distinct on trusted proxies, but the authed path is not fingerprint-gated.
*/
export function authedProvisionHeaders(
token: string,
extra: Record<string, string> = {},
): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Forwarded-For': uniqueIP(),
...extra,
}
}
/**
* The provision identity a LIVE spec should use for a resource it will reap via
* the authed DELETE surface. When a minted session exists (sanctioned prod run)
* we provision + reap AS that pro account (no 402, authed reap 200). Otherwise
* (staging/local) we use the anonymous path with the fingerprint bypass, and the
* resource is reaped by TTL / the un-authed staging delete path.
*
* - `headers` : the provision request headers (authed or anon-with-bypass).
* - `bearer` : the token to record on the ledger so the reaper's DELETE is
* authorized (undefined on the anon path).
* - `expectTier` : the tier the provision response will echo ('pro' authed,
* 'anonymous' otherwise) — specs assert against this.
* - `authed` : true when provisioning as the minted account.
*/
export interface ProvisionIdentity {
headers: Record<string, string>
bearer?: string
expectTier: string
authed: boolean
}
/** Tier echoed by an anonymous provision. */
export const ANON_TIER = 'anonymous'
/**
* Resolve the provision identity for a LIVE resource spec. Prefers the minted
* pro account (authed) when E2E_SESSION_JWT is set; else the anon bypass path.
*
* `forceAnon` pins the anonymous bypass path even when a minted session exists.
* Used for the QUEUE flow: the authed (pro) /queue/new path attempts isolated
* per-tenant NATS provisioning, which HANGS on prod until the operator seeds the
* NATS operator/sys NKeys (CLAUDE.md P1 known gap). The anonymous path returns
* fast with auth_mode=legacy_open. The resulting anon resource is reaped by its
* 24h TTL (no authed DELETE exists for anon resources); the spec treats a
* no-bearer resource's reap as best-effort (TTL-backed) rather than asserting a
* 200 it can never get.
*/
export function provisionIdentity(
extra: Record<string, string> = {},
forceAnon = false,
): ProvisionIdentity {
const minted = mintedSession()
if (minted?.token && !forceAnon) {
return {
headers: authedProvisionHeaders(minted.token, extra),
bearer: minted.token,
expectTier: minted.tier || 'pro',
authed: true,
}
}
return {
headers: anonProvisionHeaders(extra),
bearer: undefined,
expectTier: ANON_TIER,
authed: false,
}
}
// Unique source IP per call (varies the fingerprint where the proxy is trusted).
function uniqueIP(): string {
const b = () => Math.floor(Math.random() * 254) + 1
return `10.${b()}.${b()}.${b()}`
}