repoaccess-core 0.2.4 → 0.2.5

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/README.md CHANGED
@@ -57,6 +57,11 @@ org and team plus the privacy settings that keep your repos private, the Stripe
57
57
  your secrets, deploy, and a live end-to-end test. It edits the config files for you and guides the
58
58
  dashboard clicks (no digging through docs), and it never sees your secret values.
59
59
 
60
+ During the wizard, your coding agent runs setup commands (wrangler, npm) and edits two config files
61
+ (`src/repoaccess.config.ts` and `wrangler.jsonc`) for you. It will ask you to approve each step -
62
+ approve them to continue (you can pick 'yes, and don't ask again' for a repeated command). The agent
63
+ never reads your secret file (`.dev.vars`); you paste your secrets there yourself.
64
+
60
65
  **Other agents (Codex, OpenCode, Cursor, ...):** the wizard is agent-agnostic. Claude Code and
61
66
  [OpenCode](https://opencode.ai) both expose it as the `/repoaccess-setup` command; for any other coding
62
67
  agent, open the cloned repo and ask your agent to follow `docs/setup-wizard.md` - the same shared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repoaccess-core",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "exports": {
@@ -38,11 +38,18 @@ export type ClaimView =
38
38
  // Resolve-by-transaction (claim-link delivery, ADR-0042): the neutral "preparing" state shown when
39
39
  // the claim is not yet resolvable by transaction (the grant workflow runs async after the webhook
40
40
  // ack, so `claim_txn` may be absent at the instant of the post-checkout redirect). No token here.
41
- | { kind: 'pending' }
41
+ // Carries `pollScript`: core's central auto-poll JS (kept core-owned, like `form`'s submitScript) so
42
+ // the page self-refreshes across the KV eventual-consistency window and the loop self-terminates once
43
+ // a terminal view replaces it. A template MUST embed it as `<script>{raw(view.pollScript)}</script>`.
44
+ | { kind: 'pending'; pollScript: string }
42
45
  // Resolve-by-transaction (ADR-0042): the access has already been granted directly (grant_mode
43
46
  // `username` happy path, which produces no claim) - so one redirect URL serves both grant modes.
44
47
  // No handle/teams/grant detail echoed.
45
48
  | { kind: 'granted' }
49
+ // Resolve-by-transaction (0.2.5): a TERMINAL grant failure was recorded for this transaction (bad
50
+ // handle on a username grant / un-correctable GitHub error / exhausted retries). Neutral copy, no
51
+ // detail echoed - so the buyer gets a definite signal instead of looping on `pending` forever.
52
+ | { kind: 'failed' }
46
53
 
47
54
  /**
48
55
  * A claim-page template: pure `(brand, view) → HTML`. Injected via `createWorker({ claimTemplate })`.
@@ -174,13 +181,18 @@ const ClaimBusy = (props: { brand: Branding; token: string }) => (
174
181
  </Layout>
175
182
  )
176
183
 
177
- const ClaimPending = (props: { brand: Branding }) => (
184
+ const ClaimPending = (props: { brand: Branding; pollScript: string }) => (
178
185
  <Layout brand={props.brand} title="Setting up your access">
179
186
  <h2>Setting up your access</h2>
180
187
  <p>
181
- If you just completed payment, this can take a few seconds - refresh this
182
- page. If it persists, contact support.
188
+ If you just completed payment, this can take a few seconds - this page
189
+ refreshes itself automatically. If it persists, contact support.
183
190
  </p>
191
+ <p id="bytxn-slow" class="error" style="display:none">
192
+ This is taking longer than expected - refresh the page, or contact support
193
+ if it persists.
194
+ </p>
195
+ <script>{raw(props.pollScript)}</script>
184
196
  </Layout>
185
197
  )
186
198
 
@@ -194,6 +206,16 @@ const ClaimGranted = (props: { brand: Branding }) => (
194
206
  </Layout>
195
207
  )
196
208
 
209
+ const ClaimFailed = (props: { brand: Branding }) => (
210
+ <Layout brand={props.brand} title="Access setup failed">
211
+ <h2>Something went wrong setting up your access</h2>
212
+ <p>
213
+ We could not finish setting up your access. Please contact support with
214
+ your order details and we&apos;ll sort it out.
215
+ </p>
216
+ </Layout>
217
+ )
218
+
197
219
  const ClaimInvalid = (props: { brand: Branding }) => (
198
220
  <Layout brand={props.brand} title="Claim unavailable">
199
221
  <h2>This claim link is invalid or no longer active</h2>
@@ -232,9 +254,11 @@ export const defaultClaimTemplate: ClaimTemplate = ({ brand, view }) => {
232
254
  case 'busy':
233
255
  return <ClaimBusy brand={brand} token={view.token} />
234
256
  case 'pending':
235
- return <ClaimPending brand={brand} />
257
+ return <ClaimPending brand={brand} pollScript={view.pollScript} />
236
258
  case 'granted':
237
259
  return <ClaimGranted brand={brand} />
260
+ case 'failed':
261
+ return <ClaimFailed brand={brand} />
238
262
  case 'invalid':
239
263
  return <ClaimInvalid brand={brand} />
240
264
  }
package/src/claim.tsx CHANGED
@@ -12,7 +12,13 @@ import type {
12
12
  } from './types'
13
13
  import { workflowInstanceId } from './workflow-id'
14
14
  import { isValidGithubUsername } from './username'
15
- import { claimKey, claimIndexKey, grantKey, sessionTxnKey } from './kv-keys'
15
+ import {
16
+ claimKey,
17
+ claimIndexKey,
18
+ grantKey,
19
+ sessionTxnKey,
20
+ failKey,
21
+ } from './kv-keys'
16
22
  import { claimGuard } from './claim-guard'
17
23
  import type { ClaimTemplate } from './claim-template'
18
24
 
@@ -107,6 +113,36 @@ const SUBMIT_JS = `
107
113
  })()
108
114
  `
109
115
 
116
+ // Auto-poll for the resolve-by-transaction `pending` view: the grant workflow runs async AFTER the
117
+ // <100ms webhook ack, and a KV key-miss is edge-cached for ~60s (cacheTtl floor, cannot go lower), so
118
+ // even a SUCCESSFUL grant can read as `pending` for up to ~60s until the miss-cache expires. This
119
+ // reloads the page every ~4s so the buyer lands on the terminal view (302 / granted / failed) without
120
+ // manually refreshing. The loop SELF-TERMINATES because only the `pending` view carries this script -
121
+ // a terminal view has no script. Capped at ~15 reloads (~60-75s) via a per-path sessionStorage counter
122
+ // so a genuinely stuck case stops looping and reveals a quiet "taking longer" line (#bytxn-slow)
123
+ // instead of refreshing forever. Core-owned + central (passed into the `pending` view as `pollScript`)
124
+ // like SUBMIT_JS, so the behaviour is uniform across templates. Served inline with no nonce for the
125
+ // same reason as SUBMIT_JS (these responses set no CSP); if a strict script-src CSP is ever added,
126
+ // nonce this <script> (do NOT add 'unsafe-inline').
127
+ const POLL_JS = `
128
+ ;(function () {
129
+ try {
130
+ var KEY = 'repoaccess_bytxn:' + location.pathname
131
+ var MAX = 15
132
+ var n = 0
133
+ try { n = parseInt(sessionStorage.getItem(KEY) || '0', 10) || 0 } catch (e) {}
134
+ if (n >= MAX) {
135
+ try { sessionStorage.removeItem(KEY) } catch (e) {}
136
+ var el = document.getElementById('bytxn-slow')
137
+ if (el) el.style.display = ''
138
+ return
139
+ }
140
+ try { sessionStorage.setItem(KEY, String(n + 1)) } catch (e) {}
141
+ setTimeout(function () { location.reload() }, 4000)
142
+ } catch (e) {}
143
+ })()
144
+ `
145
+
110
146
  // The token lives in the URL path → keep it out of Referer (a seller-set brand image host would
111
147
  // otherwise receive the full claim URL) and out of shared caches.
112
148
  function harden(c: Ctx): void {
@@ -253,21 +289,25 @@ export function makeClaimPost(
253
289
  * Build the resolve-by-transaction handler (claim-link delivery, DEC-11 / ADR-0042) bound to a claim
254
290
  * template.
255
291
  *
256
- * GET /claim/by-txn/:adapter/:txn → three grant-aware landing states, so ONE redirect URL serves
257
- * BOTH grant modes:
292
+ * GET /claim/by-txn/:adapter/:txn → four grant-aware landing states, so ONE redirect URL serves
293
+ * BOTH grant modes and reflects a terminal failure:
258
294
  * a. `claim_txn:{adapter}:{txn}` present → 302 to the single-use `/claim/:token` flow
259
295
  * (grant_mode `claim`, OR a username typo-fallback that produced a claim).
260
296
  * b. else `grant:{adapter}:{txn}` present → a neutral `granted` view (200) - the access was
261
297
  * granted directly (grant_mode `username` happy path produces no claim).
262
- * c. else → a neutral `pending` view (200).
298
+ * c. else `fail:{adapter}:{txn}` present → a neutral `failed` view (200) - the grant failed
299
+ * terminally (the workflow wrote the marker), so the buyer gets a definite signal.
300
+ * d. else → a neutral `pending` view (200) that auto-polls across the KV consistency window.
263
301
  *
264
302
  * This is the re-queryable delivery channel: it survives a dropped or closed post-checkout redirect
265
303
  * (the deployer can resolve the same txn again). The grant workflow runs async AFTER the <100ms webhook
266
- * ack, so neither key may exist yet at the instant of the redirect - the (c) case renders a neutral
304
+ * ack, so no key may exist yet at the instant of the redirect - the (d) case renders a neutral
267
305
  * "preparing" page rather than 404, leaking no distinction between not-yet-written and never-existed and
268
- * exposing no token. Read-only: KV existence checks + a redirect, no grant, no workflow enqueue, no side
269
- * effects. The `granted` branch checks ONLY for the record's existence - it never parses or echoes the
270
- * grant detail (handle / teams / org), so no purchase data is exposed by the txn-as-bearer-credential.
306
+ * exposing no token; its client poll (POLL_JS) reloads every ~4s (capped ~15x) so a successful grant
307
+ * surfaces without a manual refresh once KV's ~60s key-miss cache expires. Read-only: KV existence
308
+ * checks + a redirect, no grant, no workflow enqueue, no side effects. The `granted` and `failed`
309
+ * branches check ONLY for the record's existence - they never parse or echo the grant detail (handle /
310
+ * teams / org) or the failure detail, so no purchase data is exposed by the txn-as-bearer-credential.
271
311
  *
272
312
  * The `:txn` segment is alias-resolved first (see `sessionTxnKey`): a merchant redirect that carries
273
313
  * an id differing from the transaction_id (Stripe `cs_...` session id vs the `pi_...` claim key) is
@@ -311,7 +351,19 @@ export function makeClaimByTxn(
311
351
  return c.html(template({ brand, view: { kind: 'granted' } }))
312
352
  }
313
353
 
314
- // (c) Eventual consistency: neither key (yet) presentneutral preparing view, no token exposed.
315
- return c.html(template({ brand, view: { kind: 'pending' } }))
354
+ // (c) No claim and no grant, but a terminal-failure marker exists the grant failed for good
355
+ // (bad handle on a username grant / un-correctable GitHub error / exhausted retries). Show a
356
+ // terminal `failed` view so the buyer gets a definite signal instead of looping on `pending`.
357
+ // Existence check only - the marker value is the coarse code and is never echoed.
358
+ const failed = await c.env.ENTITLEMENTS.get(failKey(adapter, realTxn))
359
+ if (failed !== null) {
360
+ return c.html(template({ brand, view: { kind: 'failed' } }))
361
+ }
362
+
363
+ // (d) Eventual consistency: no key (yet) present → neutral preparing view (no token exposed) that
364
+ // auto-polls (POLL_JS) across the ~60s KV miss-cache window until a terminal view replaces it.
365
+ return c.html(
366
+ template({ brand, view: { kind: 'pending', pollScript: POLL_JS } }),
367
+ )
316
368
  }
317
369
  }
package/src/kv-keys.ts CHANGED
@@ -10,6 +10,11 @@ export const CLAIM_TTL_SEC = 30 * 24 * 60 * 60 // 30 days
10
10
  // so records don't grow unbounded.
11
11
  export const GRANT_TTL_SEC = 180 * 24 * 60 * 60
12
12
 
13
+ // 24 hours - a terminal grant-failure marker so a buyer returning to /claim/by-txn within a day sees
14
+ // the `failed` state instead of perpetual `pending`. Deliberately short: this is a transient,
15
+ // best-effort UX signal; the authoritative failure record is the emitted access.failed event.
16
+ export const FAIL_TTL_SEC = 24 * 60 * 60
17
+
13
18
  /** Grant correlation record - 180d TTL, also deleted on revoke. */
14
19
  export const grantKey = (adapter: string, txn: string) =>
15
20
  `grant:${adapter}:${txn}`
@@ -21,6 +26,14 @@ export const claimKey = (token: string) => `claim:${token}`
21
26
  export const claimIndexKey = (adapter: string, txn: string) =>
22
27
  `claim_txn:${adapter}:${txn}`
23
28
 
29
+ /**
30
+ * Terminal grant-failure marker, keyed by transaction so /claim/by-txn can show a `failed` state for a
31
+ * doomed transaction instead of looping on `pending`. Value is the coarse, non-sensitive failure code
32
+ * only (never handle/teams/secret). FAIL_TTL_SEC; written by the workflow's terminal-failure paths.
33
+ */
34
+ export const failKey = (adapter: string, txn: string) =>
35
+ `fail:${adapter}:${txn}`
36
+
24
37
  /**
25
38
  * Alias index: a merchant redirect id (e.g. Stripe checkout session id cs_...) -> transaction_id, so
26
39
  * /claim/by-txn resolves a redirect whose id differs from the claim/grant key. GRANT_TTL_SEC so it
package/src/workflow.ts CHANGED
@@ -33,10 +33,12 @@ import {
33
33
  import {
34
34
  CLAIM_TTL_SEC,
35
35
  GRANT_TTL_SEC,
36
+ FAIL_TTL_SEC,
36
37
  grantKey,
37
38
  claimKey,
38
39
  claimIndexKey,
39
40
  sessionTxnKey,
41
+ failKey,
40
42
  } from './kv-keys'
41
43
  import { claimGuard } from './claim-guard'
42
44
 
@@ -478,10 +480,17 @@ async function terminalFailure(
478
480
  fromClaim: boolean,
479
481
  ): Promise<void> {
480
482
  await fail(step, org, event, sink, teams, username, reason, detail)
481
- if (!fromClaim) return
483
+ if (!fromClaim) {
484
+ // Non-claim grant, terminally failed (e.g. a bad up-front handle that 404s mid-grant, or a 403/
485
+ // auth error): no claim and no grant record will be written, so mark it failed → /claim/by-txn
486
+ // shows `failed` rather than perpetual `pending`.
487
+ await writeFailMarker(step, env, adapter, event.transaction_id, reason)
488
+ return
489
+ }
482
490
  if (userNotFound) {
483
491
  // Buyer-correctable → retain the token AND release the single-flight lock so a later sequential
484
- // resubmit with a corrected handle can acquire.
492
+ // resubmit with a corrected handle can acquire. NOT terminal: write NO fail marker (and
493
+ // /claim/by-txn 302s to the still-present claim regardless).
485
494
  await recordClaimError(
486
495
  step,
487
496
  env,
@@ -491,9 +500,11 @@ async function terminalFailure(
491
500
  )
492
501
  await guardRelease(step, env, adapter, event.transaction_id)
493
502
  } else {
494
- // Not correctable here → consume the token and lock the claim for good.
503
+ // Not correctable here → consume the token, lock the claim for good, and mark it failed so
504
+ // /claim/by-txn (the claim is now consumed) shows `failed` instead of `pending`.
495
505
  await consumeClaim(step, env, adapter, event.transaction_id)
496
506
  await guardFinalize(step, env, adapter, event.transaction_id)
507
+ await writeFailMarker(step, env, adapter, event.transaction_id, reason)
497
508
  }
498
509
  }
499
510
 
@@ -540,6 +551,29 @@ async function consumeClaim(
540
551
  })
541
552
  }
542
553
 
554
+ /**
555
+ * Write a terminal grant-failure marker so /claim/by-txn shows a definite `failed` state for a doomed
556
+ * transaction instead of looping on `pending`. Stores ONLY the coarse, non-sensitive
557
+ * `AccessFailedReason` code (the same value already carried on the access.failed wire envelope) - never
558
+ * raw detail, secrets, the handle, or team slugs. FAIL_TTL_SEC is the backstop; a later revoke cleanup
559
+ * for the same txn may remove it sooner, and the TTL guarantees it never lingers. Deterministic step
560
+ * id → idempotent across durable retries.
561
+ */
562
+ async function writeFailMarker(
563
+ step: WorkflowStep,
564
+ env: CloudflareBindings,
565
+ adapter: string,
566
+ txn: string,
567
+ reason: AccessFailedReason,
568
+ ): Promise<void> {
569
+ await step.do(`fail-marker:${adapter}:${txn}`, async () => {
570
+ await env.ENTITLEMENTS.put(failKey(adapter, txn), reason, {
571
+ expirationTtl: FAIL_TTL_SEC,
572
+ })
573
+ return true
574
+ })
575
+ }
576
+
543
577
  /** Stamp `last_error` on the retained claim record so the buyer sees it on GET and can retry. */
544
578
  async function recordClaimError(
545
579
  step: WorkflowStep,
@@ -904,9 +938,20 @@ export async function executeAccessWorkflow(
904
938
  reason: 'grant_error',
905
939
  })
906
940
  // A claim-originated grant that died on a transient/unexpected error must release its
907
- // single-flight lock so the buyer can resubmit (the token was retained).
941
+ // single-flight lock so the buyer can resubmit (the token was retained) - /claim/by-txn 302s to
942
+ // that retained claim, so no fail marker. A NON-claim grant (rate-limit / 5xx exhausted, or an
943
+ // unexpected throw) leaves no claim and no grant record, so mark it failed → /claim/by-txn shows
944
+ // `failed` rather than perpetual `pending`.
908
945
  if (params.from_claim) {
909
946
  await guardRelease(step, env, adapter, event.transaction_id)
947
+ } else {
948
+ await writeFailMarker(
949
+ step,
950
+ env,
951
+ adapter,
952
+ event.transaction_id,
953
+ 'grant_error',
954
+ )
910
955
  }
911
956
  }
912
957
  throw err // mark the Workflow instance failed for observability (the event already fired)