Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/verify-pr.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions docs/api/tangle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/codemap.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
}
]
},
Expand Down
4 changes: 2 additions & 2 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 22 additions & 37 deletions src/tangle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string>
/** 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<string> | null = null

async function mint(): Promise<string> {
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.
},
}
}
28 changes: 28 additions & 0 deletions tests/tangle-token-concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -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<BrokerToken>((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<string>()
for (const token of issued) {
expect(consumed.has(token)).toBe(false)
consumed.add(token)
}
expect(issued).toEqual(['first', 'second'])
})
})
129 changes: 84 additions & 45 deletions tests/tangle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrokerToken> = {}): { 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<BrokerToken>((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<BrokerToken>((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<string>()
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<Parameters<BrokerTokenMinter['mintBrokerToken']>[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')
Expand Down