From 9f5a0e6938fdf6a070ef7844fd18e5a665ce71e9 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 2 Nov 2026 13:38:51 +0000 Subject: [PATCH] Bind link mode to its initiator and redeem to its target Link start needs an authenticated app and an initiatingAccountId, carried in the signed state. The callback refuses an identity held by another account (link_initiator_mismatch) and attaches an unlinked one to the initiator. auth_completions stores target_id; redeem compares it and answers completion_target_mismatch without consuming the code. Link completions are redeemed only by the authenticated app. Co-Authored-By: Claude Sonnet 5.5 --- docs/service-contract.md | 5 + src/app.ts | 107 ++++++++- src/contracts.ts | 5 + src/store/in-memory.ts | 6 + src/store/postgres.ts | 18 +- src/store/schema.ts | 4 + src/store/types.ts | 5 + tests/caller-identity-attack-soul-r21.test.ts | 2 +- tests/link-binding.test.ts | 210 ++++++++++++++++++ tests/managed-credential.test.ts | 2 +- tests/store.test.ts | 2 +- 11 files changed, 351 insertions(+), 15 deletions(-) create mode 100644 tests/link-binding.test.ts diff --git a/docs/service-contract.md b/docs/service-contract.md index 1567ae5..e356ae2 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -334,6 +334,11 @@ Request body: } ``` +Binding rules: + +- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed. +- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator. + Response: ```json diff --git a/src/app.ts b/src/app.ts index 40ceb20..3079694 100644 --- a/src/app.ts +++ b/src/app.ts @@ -91,6 +91,7 @@ function buildOAuthStateToken(options: { mode: OAuthMode; returnTo: string; connection: OAuthConnectionBinding | undefined; + initiatorAccountId: string | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; keys: ReturnType; @@ -111,6 +112,7 @@ function buildOAuthStateToken(options: { mode: options.mode, return_to: options.returnTo, connection: options.connection ?? null, + initiator_account_id: options.initiatorAccountId ?? null, handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, }; @@ -168,7 +170,8 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.nbf !== "number" || typeof payload.exp !== "number" || !handoffIsValid || - !entitlementPolicyIsValid + !entitlementPolicyIsValid || + !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") ) { return null; } @@ -177,6 +180,9 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.handoff) { normalizedPayload.handoff = { kind: "browser_completion" }; } + if (!normalizedPayload.initiator_account_id) { + normalizedPayload.initiator_account_id = null; + } if (!normalizedPayload.entitlement_policy) { normalizedPayload.entitlement_policy = null; } @@ -253,6 +259,16 @@ function relayedProviderText(value: string | undefined, bound: number): string | * code, each only when it is a short identifier. Never its message, which * carries whatever the thrower quoted (a host, a database, a provider's body). */ +/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */ +class LinkRefusal extends Error { + constructor( + readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown", + message: string + ) { + super(message); + } +} + export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } { const code = (error as { code?: unknown } | null)?.code; return { @@ -511,6 +527,24 @@ export async function startOAuthFlow( }; } + // Link mode binds an identity to an existing account, so the account must + // be named by someone Heimdall can believe: the authenticated app backend. + // The name rides in the signed state and the callback holds the provider's + // identity to it. No other mode carries an initiator. + if (input.mode === "link") { + if (!isAppCaller(caller) || caller.appSlug !== profile.slug) { + return { statusCode: 401, body: { error: "app_auth_required" } }; + } + if (!input.initiatingAccountId) { + return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } }; + } + if (!(await ctx.store.findAccountById(input.initiatingAccountId))) { + return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } }; + } + } else if (input.initiatingAccountId !== undefined) { + return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } }; + } + if (!isAllowedReturnOrigin(profile, input.returnTo)) { return { statusCode: 400, @@ -574,6 +608,7 @@ export async function startOAuthFlow( mode: input.mode, returnTo: input.returnTo, connection: input.connection, + initiatorAccountId: input.initiatingAccountId, handoff, entitlementPolicy, keys, @@ -822,6 +857,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, @@ -1307,12 +1364,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const nowIso = new Date().toISOString(); + // Sign-in and connect completions are redeemed by the browser flow, so + // the route stays open to it. A link completion was minted for an + // initiator the authenticated app named at start; only that app's + // backend redeems it. Redeem is also where the connection target is + // held to what the signed state named. Both refusals read the row + // without consuming it, so a wrong caller or target cannot burn the code. + const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode); + if (pending && !pending.consumedAt && pending.expiresAt > nowIso) { + if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) { + reply.code(401); + return { error: "app_auth_required" }; + } + if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) { + await store.createAuditEvent({ + accountId: pending.accountId, + sessionId: pending.sessionId, + appSlug: pending.appSlug, + eventType: "auth_completion_target_refused", + eventPayloadJson: { provider: pending.provider, mode: pending.mode }, + createdAt: nowIso, + }); + reply.code(403); + return { error: "completion_target_mismatch" }; + } + } const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso); if (!completion) { diff --git a/src/contracts.ts b/src/contracts.ts index d6cab2a..718523b 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -73,6 +73,8 @@ export interface OAuthStartRequest { mode: OAuthMode; returnTo: string; connection?: OAuthConnectionBinding; + /** Link mode only: the account the authenticated app says is linking. Carried in the signed state. */ + initiatingAccountId?: string; handoff?: OAuthHandoff; requestedScopes?: string[]; entitlementPolicy?: OAuthEntitlementPolicy; @@ -92,6 +94,7 @@ export interface OAuthStatePayload { mode: OAuthMode; return_to: string; connection: OAuthConnectionBinding | null; + initiator_account_id: string | null; handoff: OAuthHandoff | null; entitlement_policy: OAuthEntitlementPolicy | null; } @@ -105,6 +108,8 @@ export interface LinkedIdentityInput { export interface RedeemAuthCompletionRequest { completionCode: string; + /** Required when the completion was minted for a connection target; must equal it. */ + targetId?: string; } export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired"; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 6a53dad..b89e7eb 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -390,6 +390,7 @@ export class InMemoryStore implements HeimdallStore { accountId: input.accountId, sessionId: input.sessionId, returnTo: input.returnTo, + ...(input.targetId ? { targetId: input.targetId } : {}), createdAt: input.createdAt, expiresAt: input.expiresAt, payloadJson: clone(input.payloadJson), @@ -402,6 +403,11 @@ export class InMemoryStore implements HeimdallStore { return clone(completion); } + async findAuthCompletion(appSlug: AppSlug, code: string): Promise { + const completion = this.authCompletions.get(code); + return completion && completion.appSlug === appSlug ? clone(completion) : null; + } + async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise { const completion = this.authCompletions.get(code); if (!completion) { diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 487f375..a1224dd 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -82,6 +82,7 @@ interface AuthCompletionRow extends QueryResultRow { account_id: string; session_id: string; return_to: string; + target_id: string | null; payload_json: Record; created_at: string | Date; expires_at: string | Date; @@ -249,6 +250,10 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion { completion.attemptId = row.attempt_id; } + if (row.target_id) { + completion.targetId = row.target_id; + } + if (row.consumed_at) { completion.consumedAt = normalizeTimestamp(row.consumed_at); } @@ -1004,9 +1009,9 @@ export class PostgresStore implements HeimdallStore { ` INSERT INTO auth_completions ( code, attempt_id, app_slug, provider, mode, account_id, session_id, - return_to, payload_json, created_at, expires_at + return_to, target_id, payload_json, created_at, expires_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING * `, [ @@ -1018,6 +1023,7 @@ export class PostgresStore implements HeimdallStore { input.accountId, input.sessionId, input.returnTo, + input.targetId ?? null, JSON.stringify(input.payloadJson), input.createdAt, input.expiresAt, @@ -1027,6 +1033,14 @@ export class PostgresStore implements HeimdallStore { return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion")); } + async findAuthCompletion(appSlug: AppSlug, code: string): Promise { + const result = await this.pool.query( + "SELECT * FROM auth_completions WHERE code = $1 AND app_slug = $2", + [code, appSlug] + ); + return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "findAuthCompletion")) : null; + } + async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise { const result = await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 420f5e6..3433ef8 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -106,6 +106,10 @@ CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx -- redeem the completion it created without ever needing the code. ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT; +-- The connection target the signed state named (connection.targetId). Redeem +-- compares it against the target the redeeming app states. +ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT; + -- One attempt handle may bind at most one unconsumed completion (R21.1): a -- second completion for the same handle would let the by-attempt UPDATE in -- consumeAuthCompletionByAttempt match more than one row, silently consuming diff --git a/src/store/types.ts b/src/store/types.ts index 5307e1f..f5b9873 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -159,6 +159,8 @@ export interface StoredAuthCompletion { accountId: string; sessionId: string; returnTo: string; + /** The connection target the signed state named, if any; compared at redeem. */ + targetId?: string; createdAt: string; expiresAt: string; consumedAt?: string; @@ -171,6 +173,7 @@ export interface CreateAuthCompletionInput { * always mints `code` itself; a caller cannot choose it. */ attemptId?: string; + targetId?: string; appSlug: AppSlug; provider: Provider; mode: OAuthMode; @@ -263,6 +266,8 @@ export interface HeimdallStore { createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise; findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise; createAuthCompletion(input: CreateAuthCompletionInput): Promise; + /** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */ + findAuthCompletion(appSlug: AppSlug, code: string): Promise; consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise; /** * Consume a completion by its correlated attempt handle instead of its diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index de959a8..60cf9a6 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -363,7 +363,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => { if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)"); } } - expect(consumers).toEqual(["app.ts: reads=2 guards=2"]); + expect(consumers).toEqual(["app.ts: reads=3 guards=3"]); }); it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => { diff --git a/tests/link-binding.test.ts b/tests/link-binding.test.ts new file mode 100644 index 0000000..1d37e72 --- /dev/null +++ b/tests/link-binding.test.ts @@ -0,0 +1,210 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; +import { InMemoryStore } from "../src/store/index.js"; + +function createTestConfig(): HeimdallConfig { + return { + serviceName: "heimdall", + host: "127.0.0.1", + port: 4100, + workspaceRoot: "E:/Projects/Heimdall", + dataRoot: "E:/Projects/Heimdall/.heimdall-data", + cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc", + publicBaseUrl: "https://heimdall.gamecult.org", + issuer: "https://heimdall.gamecult.org", + daemonId: "yggdrasil-heimdall", + idunnRudpHealth: undefined, + idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", + sessionTtlSeconds: 3600, + refreshTtlSeconds: 60 * 60 * 24 * 30, + stateTtlSeconds: 600, + completionTtlSeconds: 300, + bootstrapSigningPrivateKeyOnMissing: false, + tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + appSharedSecrets: { + streampixels: "streampixels-secret", + repixelizer: "repixelizer-secret", + bifrost: "bifrost-secret", + }, + appBackendCallbacks: { + bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"], + repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"], + }, + storage: { + backend: "memory", + applySchemaOnStartup: true, + }, + providers: { + discord: { clientId: "discord-client", clientSecret: "discord-secret" }, + patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" }, + github: { clientId: "github-client", clientSecret: "github-secret" }, + twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, + youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, + spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, + joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" }, + }, + }; +} + + +// Link mode binds a provider identity to the account an authenticated app +// named at start. These tests pin the three places that binding is decided: +// the callback (initiator), the completion row (target), and redeem (caller, +// target, replay). + +const returnTo = "https://repixelizer.gamecult.org/app/"; +const appAuth = { "x-heimdall-app-secret": "repixelizer-secret" }; + +function discordRuntime(providerUserId: string): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode() { + return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["identify"], raw: {} }; + }, + async resolveIdentity() { + return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }; +} + +const apps: Awaited>[] = []; +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +async function rig(providerUserId: string, config: HeimdallConfig = createTestConfig()) { + const store = new InMemoryStore(); + const app = await buildApp({ config, store, oauthRuntimes: { discord: discordRuntime(providerUserId) } }); + apps.push(app); + const now = new Date().toISOString(); + const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now }); + return { app, store, initiator }; +} + +type Rig = Awaited>; + +function start(r: Rig, payload: Record, headers: Record = appAuth) { + return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } }); +} + +function callback(r: Rig, stateToken: string) { + return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` }); +} + +function redeem(r: Rig, payload: Record, headers: Record = {}) { + return r.app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", headers, payload }); +} + +describe("link start names its initiator", () => { + it("refuses an unauthenticated link start, a missing or unknown initiator, and an initiator outside link mode", async () => { + const r = await rig("d-1"); + const mode = { mode: "link", initiatingAccountId: r.initiator.id }; + expect((await start(r, mode, {})).statusCode).toBe(401); + expect((await start(r, mode, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401); + const missing = await start(r, { mode: "link" }); + expect([missing.statusCode, missing.json().error]).toEqual([400, "link_initiator_required"]); + const unknown = await start(r, { mode: "link", initiatingAccountId: "no-such-account" }); + expect([unknown.statusCode, unknown.json().error]).toEqual([400, "link_initiator_unknown"]); + const stray = await start(r, { mode: "sign_in", initiatingAccountId: r.initiator.id }, {}); + expect([stray.statusCode, stray.json().error]).toEqual([400, "initiator_only_in_link_mode"]); + }); +}); + +describe("link callback holds the provider identity to the initiator", () => { + it("attaches an unlinked identity to the initiating account, not a new one", async () => { + const r = await rig("d-new"); + const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id }); + expect(started.statusCode).toBe(201); + const done = await callback(r, started.json().stateToken); + expect(done.statusCode).toBe(201); + expect(done.json().account.id).toBe(r.initiator.id); + expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id); + }); + + it("refuses with link_initiator_mismatch when the identity already belongs to another account, and mints nothing", async () => { + const r = await rig("d-held"); + const now = new Date().toISOString(); + const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now }); + await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now }); + const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id }); + const done = await callback(r, started.json().stateToken); + expect(done.statusCode).toBe(403); + expect(done.json().error).toBe("link_initiator_mismatch"); + expect(done.json().completion).toBeUndefined(); + expect(done.body).not.toContain("accessToken"); + expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id); + }); + + it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => { + const dir = mkdtempSync(join(tmpdir(), "heimdall-link-")); + try { + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + const r = await rig("d-forged", config); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id }); + const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record }; + expect(verified.valid).toBe(true); + const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys); + const done = await callback(r, stripped); + expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]); + expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("redeem binds caller, target and replay", () => { + async function linked(r: Rig, targetId: string | undefined) { + const started = await start(r, { + mode: "link", + initiatingAccountId: r.initiator.id, + ...(targetId ? { connection: { kind: "account", targetId } } : {}), + }); + const done = await callback(r, started.json().stateToken); + expect(done.statusCode).toBe(201); + return done.json().completion.code as string; + } + + it("redeems a link completion only for the authenticated app, and a refused caller does not burn the code", async () => { + const r = await rig("d-r1"); + const completionCode = await linked(r, undefined); + const anonymous = await redeem(r, { completionCode }); + expect([anonymous.statusCode, anonymous.json().error]).toEqual([401, "app_auth_required"]); + expect((await redeem(r, { completionCode }, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401); + expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201); + }); + + it("refuses a wrong or missing target with completion_target_mismatch without consuming the code", async () => { + const r = await rig("d-r2"); + const completionCode = await linked(r, "target-a"); + for (const payload of [{ completionCode, targetId: "target-b" }, { completionCode }]) { + const refused = await redeem(r, payload, appAuth); + expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]); + expect(refused.json().accessToken).toBeUndefined(); + } + expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201); + }); + + it("refuses a replayed completion", async () => { + const r = await rig("d-r3"); + const completionCode = await linked(r, "target-a"); + expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201); + expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(410); + }); + + it("stores the target on the completion row", async () => { + const r = await rig("d-r4"); + const completionCode = await linked(r, "target-a"); + expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.targetId).toBe("target-a"); + }); +}); diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 50ca9ba..5238da2 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -589,7 +589,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag const redeem = await app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", - payload: { completionCode: callback.json().completion.code as string }, + payload: { completionCode: callback.json().completion.code as string, ...(mode === "connect" ? { targetId: "creator-1" } : {}) }, }); expect(redeem.statusCode).toBe(201); return { callback, redeem }; diff --git a/tests/store.test.ts b/tests/store.test.ts index c5f6445..7f892c8 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -237,7 +237,7 @@ describe("PostgresStore.checkSchema", () => { "entitlement_snapshots", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(85); + expect(REQUIRED_COLUMNS).toHaveLength(86); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false },