diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml new file mode 100644 index 00000000..59e3241d --- /dev/null +++ b/.github/workflows/verify-pr.yml @@ -0,0 +1,51 @@ +name: Verify PR +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] +permissions: + contents: read +concurrency: + group: verify-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + NODE_OPTIONS: --max-old-space-size=12288 + VERIFICATION_DIR: /tmp/agent-app-verification-${{ github.run_id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Record exact source outside the audited tree + run: | + mkdir -p "$VERIFICATION_DIR" + git rev-parse HEAD > "$VERIFICATION_DIR/commit.txt" + git archive --format=tar.gz HEAD > "$VERIFICATION_DIR/source.tar.gz" + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version-file: .nvmrc + cache: pnpm + - name: Check release contract + run: node .github/scripts/test-publish-workflow.mjs + - run: pnpm install --frozen-lockfile --ignore-scripts=false > "$VERIFICATION_DIR/install.log" 2>&1 || { tail -60 "$VERIFICATION_DIR/install.log"; exit 1; } + - name: Verify generated documentation + run: | + pnpm docs:gen > "$VERIFICATION_DIR/docs.log" 2>&1 + git diff --exit-code -- docs + - run: pnpm run typecheck > "$VERIFICATION_DIR/typecheck.log" 2>&1 || { cat "$VERIFICATION_DIR/typecheck.log"; exit 1; } + - run: pnpm run test:gates > "$VERIFICATION_DIR/gates.log" 2>&1 || { tail -80 "$VERIFICATION_DIR/gates.log"; exit 1; } + - run: pnpm run build > "$VERIFICATION_DIR/build.log" 2>&1 || { tail -80 "$VERIFICATION_DIR/build.log"; exit 1; } + - name: Test all source + run: pnpm exec vitest run --reporter=json --outputFile="$VERIFICATION_DIR/tests.json" > "$VERIFICATION_DIR/tests.log" 2>&1 || { tail -100 "$VERIFICATION_DIR/tests.log"; exit 1; } + - run: pnpm run test:generated > "$VERIFICATION_DIR/generated.log" 2>&1 || { tail -100 "$VERIFICATION_DIR/generated.log"; exit 1; } + - run: pnpm run knip > "$VERIFICATION_DIR/knip.log" 2>&1 || { cat "$VERIFICATION_DIR/knip.log"; exit 1; } + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() + with: + name: pr-verification-${{ github.event.pull_request.number }} + path: ${{ env.VERIFICATION_DIR }}/ + retention-days: 7 diff --git a/docs/api/tangle.md b/docs/api/tangle.md index 376d7272..58d5ff55 100644 --- a/docs/api/tangle.md +++ b/docs/api/tangle.md @@ -24,7 +24,7 @@ interface BrokerTokenMinter ### `BrokerTokenProvider` -`interface` — Provide and refresh broker bearer tokens, allowing forced token invalidation +`interface` — Mint a separate single-use bearer for each execution attempt. ```ts interface BrokerTokenProvider @@ -56,7 +56,7 @@ interface ConsentUrlInput ### `createBrokerTokenProvider` -`function` — Cache + auto-refresh a broker token for one grant. +`function` — Mint a fresh broker token for every call, including concurrent calls. ```ts (opts: BrokerTokenProviderOptions) => BrokerTokenProvider diff --git a/docs/codemap.json b/docs/codemap.json index 874c05a9..8715bb8d 100644 --- a/docs/codemap.json +++ b/docs/codemap.json @@ -15330,7 +15330,7 @@ "name": "BrokerTokenProvider", "kind": "interface", "signature": "interface BrokerTokenProvider", - "doc": "Provide and refresh broker bearer tokens, allowing forced token invalidation" + "doc": "Mint a separate single-use bearer for each execution attempt." }, { "name": "BrokerTokenProviderOptions", @@ -15354,7 +15354,7 @@ "name": "createBrokerTokenProvider", "kind": "function", "signature": "(opts: BrokerTokenProviderOptions) => BrokerTokenProvider", - "doc": "Cache + auto-refresh a broker token for one grant." + "doc": "Mint a fresh broker token for every call, including concurrent calls." } ] }, diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 095a4f2c..37b7773f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -19861,7 +19861,7 @@ interface BrokerTokenMinter ### `BrokerTokenProvider` -`interface` — Provide and refresh broker bearer tokens, allowing forced token invalidation +`interface` — Mint a separate single-use bearer for each execution attempt. ```ts interface BrokerTokenProvider @@ -19893,7 +19893,7 @@ interface ConsentUrlInput ### `createBrokerTokenProvider` -`function` — Cache + auto-refresh a broker token for one grant. +`function` — Mint a fresh broker token for every call, including concurrent calls. ```ts (opts: BrokerTokenProviderOptions) => BrokerTokenProvider diff --git a/src/tangle/index.ts b/src/tangle/index.ts index 3092391f..baa5dbfe 100644 --- a/src/tangle/index.ts +++ b/src/tangle/index.ts @@ -16,10 +16,10 @@ * (their Tangle session authorizes the app for a connection + scopes). * 2. On the callback, the consumer's client `exchangeAuthCode`s the `agc_` * code into the first broker token + a durable grant. - * 3. {@link createBrokerTokenProvider} — the runtime path: a cached provider - * that re-mints a fresh single-use broker token per `/v1/hub/exec` from the - * durable grant using only the app credentials (no user session). Caches - * until just before expiry so a burst of hub calls shares one mint. + * 3. {@link createBrokerTokenProvider} — the runtime path: each request mints + * a fresh single-use broker token from the durable grant using only app + * credentials (no user session). Neither tokens nor in-flight mint + * promises may be shared between execution attempts. */ /** A single-use hub bearer minted from a durable grant — mirrors @@ -82,56 +82,41 @@ export interface BrokerTokenProviderOptions { grantId: string /** Requested token TTL (seconds). */ ttlSeconds?: number - /** Re-mint this many ms BEFORE expiry so an in-flight call never uses a - * just-expired token. Default 30s. */ + /** @deprecated Retained for source compatibility. Single-use tokens are + * never cached, so refresh skew is ignored. */ refreshSkewMs?: number - /** Injectable clock (ms). Default `Date.now`. */ + /** @deprecated Retained for source compatibility; no local expiry cache. */ now?: () => number } -/** Provide and refresh broker bearer tokens, allowing forced token invalidation */ +/** Mint a separate single-use bearer for each execution attempt. */ export interface BrokerTokenProvider { - /** A valid `sk-tan-broker-` bearer, minting/refreshing as needed. */ + /** Mint a fresh bearer for exactly one Hub execution; never cache or share it. */ getToken(): Promise - /** Force the next `getToken` to re-mint (e.g. after a 401 from the hub). */ + /** Compatibility no-op: no bearer is cached. Does not revoke Hub grants or + * already-issued tokens; revocation belongs to the authoritative Hub. */ invalidate(): void } /** - * Cache + auto-refresh a broker token for one grant. A burst of hub calls - * shares a single mint; the token is re-minted once it's within `refreshSkewMs` - * of expiry, or on demand via {@link BrokerTokenProvider.invalidate}. - * Concurrent `getToken` calls during a mint share the same in-flight promise - * (no thundering herd). + * Mint a fresh broker token for every call, including concurrent calls. + * A broker bearer is consumed by one Hub execution even when its TTL has not + * expired. Cache the durable grant, never the bearer or an in-flight mint. + * Mint failures propagate; this helper never retries an external action. */ export function createBrokerTokenProvider(opts: BrokerTokenProviderOptions): BrokerTokenProvider { - const now = opts.now ?? (() => Date.now()) - const skew = opts.refreshSkewMs ?? 30_000 - let cached: { token: string; expiresAt: number } | null = null - let inflight: Promise | null = null - - async function mint(): Promise { - const t = await opts.client.mintBrokerToken({ - clientId: opts.clientId, - clientSecret: opts.clientSecret, - grantId: opts.grantId, - ttlSeconds: opts.ttlSeconds, - }) - cached = { token: t.accessToken, expiresAt: now() + t.expiresIn * 1000 } - return t.accessToken - } - return { async getToken() { - if (cached && now() < cached.expiresAt - skew) return cached.token - if (inflight) return inflight - inflight = mint().finally(() => { - inflight = null + const token = await opts.client.mintBrokerToken({ + clientId: opts.clientId, + clientSecret: opts.clientSecret, + grantId: opts.grantId, + ttlSeconds: opts.ttlSeconds, }) - return inflight + return token.accessToken }, invalidate() { - cached = null + // No cached bearer to clear. Keep this method for existing callers. }, } } diff --git a/tests/tangle-token-concurrency.test.ts b/tests/tangle-token-concurrency.test.ts new file mode 100644 index 00000000..658bf5a2 --- /dev/null +++ b/tests/tangle-token-concurrency.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createBrokerTokenProvider, type BrokerToken, type BrokerTokenMinter } from '../src/tangle/index' + +// Broker tokens authorize one execution, not all requests within their TTL. +// In-flight promise sharing would give competing executions the same bearer. +describe('createBrokerTokenProvider concurrency', () => { + it('gives concurrent executions separate tokens, including out-of-order mints', async () => { + const pending: Array<(token: BrokerToken) => void> = [] + const minter: BrokerTokenMinter = { + mintBrokerToken: () => new Promise((resolve) => { pending.push(resolve) }), + } + const provider = createBrokerTokenProvider({ + client: minter, clientId: 'app', clientSecret: 'test-only', grantId: 'grant', + }) + const first = provider.getToken() + const second = provider.getToken() + expect(pending).toHaveLength(2) + pending[1]!({ accessToken: 'second', expiresIn: 120, scope: 'gmail.read' }) + pending[0]!({ accessToken: 'first', expiresIn: 120, scope: 'gmail.read' }) + const issued = await Promise.all([first, second]) + const consumed = new Set() + for (const token of issued) { + expect(consumed.has(token)).toBe(false) + consumed.add(token) + } + expect(issued).toEqual(['first', 'second']) + }) +}) diff --git a/tests/tangle.test.ts b/tests/tangle.test.ts index 674e21b4..409a596c 100644 --- a/tests/tangle.test.ts +++ b/tests/tangle.test.ts @@ -25,69 +25,108 @@ describe('buildConsentUrl', () => { }) }) -/** A fake minter recording calls + a controllable token, so the provider's - * caching/refresh is tested without the network. */ -function fakeMinter(token: Partial = {}): { minter: BrokerTokenMinter; mints: number } { +/** Each mint creates a distinct bearer, as the Hub issuer does. */ +function fakeMinter(): { minter: BrokerTokenMinter; mints: number } { let mints = 0 - const minter: BrokerTokenMinter = { - async mintBrokerToken() { - mints++ - return { accessToken: `sk-tan-broker-${mints}`, expiresIn: 3600, scope: 'gmail.read', ...token } - }, - } return { - minter, - get mints() { - return mints + minter: { + async mintBrokerToken() { + mints++ + return { accessToken: `sk-tan-broker-${mints}`, expiresIn: 3600, scope: 'gmail.read' } + }, }, + get mints() { return mints }, } } +function provider(client: BrokerTokenMinter) { + return createBrokerTokenProvider({ client, clientId: 'c', clientSecret: 's', grantId: 'g' }) +} + describe('createBrokerTokenProvider', () => { - it('mints once and caches across calls within the TTL', async () => { - let t = 1_000_000 + it('mints a new bearer for successive calls even within the TTL', async () => { const f = fakeMinter() - const p = createBrokerTokenProvider({ client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', now: () => t }) + const p = provider(f.minter) expect(await p.getToken()).toBe('sk-tan-broker-1') - expect(await p.getToken()).toBe('sk-tan-broker-1') - expect(f.mints).toBe(1) - }) - - it('re-mints once inside the refresh-skew window before expiry', async () => { - let t = 1_000_000 - const f = fakeMinter({ expiresIn: 100 }) // expires at +100s - const p = createBrokerTokenProvider({ client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', refreshSkewMs: 30_000, now: () => t }) - expect(await p.getToken()).toBe('sk-tan-broker-1') - t += 60_000 // 60s in: still >30s skew before the 100s expiry → cached - expect(await p.getToken()).toBe('sk-tan-broker-1') - expect(f.mints).toBe(1) - t += 20_000 // 80s in: within 30s of expiry → re-mint expect(await p.getToken()).toBe('sk-tan-broker-2') expect(f.mints).toBe(2) }) - it('shares one in-flight mint across concurrent getToken calls (no thundering herd)', async () => { - let resolveMint!: (v: BrokerToken) => void - let mints = 0 - const minter: BrokerTokenMinter = { - mintBrokerToken() { - mints++ - return new Promise((res) => { resolveMint = res }) - }, - } - const p = createBrokerTokenProvider({ client: minter, clientId: 'c', clientSecret: 's', grantId: 'g' }) + it('does not share an in-flight mint between concurrent callers', async () => { + const pending: Array<(token: BrokerToken) => void> = [] + const p = provider({ + mintBrokerToken: () => new Promise((resolve) => { pending.push(resolve) }), + }) const a = p.getToken() const b = p.getToken() - resolveMint({ accessToken: 'sk-tan-broker-x', expiresIn: 3600, scope: '' }) - expect(await a).toBe('sk-tan-broker-x') - expect(await b).toBe('sk-tan-broker-x') - expect(mints).toBe(1) + expect(pending).toHaveLength(2) + // Independent operations may resolve in either order. + pending[1]!({ accessToken: 'token-b', expiresIn: 3600, scope: '' }) + pending[0]!({ accessToken: 'token-a', expiresIn: 3600, scope: '' }) + expect(await a).toBe('token-a') + expect(await b).toBe('token-b') }) - it('invalidate() forces a fresh mint on the next call', async () => { - let t = 1_000_000 + it('supports a burst without reusing a consumed bearer', async () => { const f = fakeMinter() - const p = createBrokerTokenProvider({ client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', now: () => t }) + const p = provider(f.minter) + const tokens = await Promise.all(Array.from({ length: 8 }, () => p.getToken())) + const consumed = new Set() + for (const token of tokens) { + // A second execution with the same token is a replay, not a cache hit. + expect(consumed.has(token)).toBe(false) + consumed.add(token) + } + expect(f.mints).toBe(8) + }) + + it('legacy clock and skew options do not enable caching', async () => { + const f = fakeMinter() + const p = createBrokerTokenProvider({ + client: f.minter, clientId: 'c', clientSecret: 's', grantId: 'g', + now: () => 0, refreshSkewMs: 0, + }) + expect(await p.getToken()).not.toBe(await p.getToken()) + expect(f.mints).toBe(2) + }) + + it('propagates a failed mint without retrying it or poisoning later calls', async () => { + let attempts = 0 + const p = provider({ + async mintBrokerToken() { + if (++attempts === 1) throw new Error('issuer unavailable') + return { accessToken: 'fresh', expiresIn: 3600, scope: '' } + }, + }) + await expect(p.getToken()).rejects.toThrow('issuer unavailable') + expect(attempts).toBe(1) + expect(await p.getToken()).toBe('fresh') + }) + + it('forwards the exact grant and requested TTL for each mint', async () => { + const calls: Array[0]> = [] + const p = createBrokerTokenProvider({ + client: { + async mintBrokerToken(input) { + calls.push(input) + return { accessToken: `token-${calls.length}`, expiresIn: 60, scope: '' } + }, + }, + clientId: 'c', clientSecret: 's', grantId: 'g', ttlSeconds: 60, + }) + await p.getToken() + await p.getToken() + expect(calls).toEqual([ + { clientId: 'c', clientSecret: 's', grantId: 'g', ttlSeconds: 60 }, + { clientId: 'c', clientSecret: 's', grantId: 'g', ttlSeconds: 60 }, + ]) + }) + + it('retains invalidate as a compatibility no-op without minting', async () => { + const f = fakeMinter() + const p = provider(f.minter) + p.invalidate() + expect(f.mints).toBe(0) expect(await p.getToken()).toBe('sk-tan-broker-1') p.invalidate() expect(await p.getToken()).toBe('sk-tan-broker-2')