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 +5 -0
- package/package.json +1 -1
- package/src/claim-template.tsx +29 -5
- package/src/claim.tsx +62 -10
- package/src/kv-keys.ts +13 -0
- package/src/workflow.ts +49 -4
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
package/src/claim-template.tsx
CHANGED
|
@@ -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
|
-
|
|
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 -
|
|
182
|
-
|
|
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'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 {
|
|
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 →
|
|
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 `
|
|
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
|
|
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
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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)
|
|
315
|
-
|
|
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)
|
|
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
|
|
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)
|