Compare commits

..
15 changed files with 9 additions and 1694 deletions

View file

@ -96,18 +96,3 @@ ticket would turn cookie theft into bearer theft and, with a passkey or email
code, into unlink power. And unlink accepts any method: a `bifrost-email-code`
proof can unlink an identity a Google re-authentication would have guarded.
Requiring a method per route is the later hardening (identity moves).
## Identity moves
Bifrost's profile carries `identityMoves: { accountProofMethods:
["bifrost-passkey"] }`, the only such grant (its methods are a subset of
`stepUpAssertion.methods`). With its secret, `POST /v1/apps/bifrost/identity-moves`
moves logins from one Heimdall account to another when it presents fresh proofs
of both: Bifrost's merge saga owns survivor choice, preview and undo timing;
Heimdall moves what the proofs cover. A proof through one login moves only that
login; an asserted `bifrost-passkey` proof covers the whole source account;
`bifrost-email-code` proves an inbox and does not. A multi-login source without a
passkey therefore needs one fresh sign-in per login. Until the Google client is
live, a member's strong proof is the asserted passkey only; Discord and Patreon
give weak (300 s) proofs. Contract, refusal order and idempotency are in
`docs/service-contract.md`.

View file

@ -348,31 +348,13 @@ for the account that holds it. The store role therefore holds DELETE on
`linked_identities`, checked at startup. The HTTP side (`/v1/me/identities`) is
in `service-contract.md`; an unlink needs a step-up proof inside 600 seconds.
`moveIdentities` is the only statement that sets `linked_identities.account_id`
to another account, reached only through `POST /v1/apps/{app}/identity-moves`.
One transaction locks both accounts in id order (opposite moves queue, they do
not deadlock), answers a repeated `moveId` from its `identity_moves` row,
re-checks the proof sessions, applies `decideMove` (one pure function both stores
call, so memory and Postgres cannot disagree), moves the rows with credentials
stripped, revokes the source's sessions, expires its pending links and unredeemed
completions, and appends the row. `identity_moves` is append-only (SELECT and
INSERT only, checked at startup). An alias (an account with no login and an
unreversed move from it) is derived inside the transaction to refuse moves into
or out of it, never stored; nothing resolves an alias to its destination, because
no consumer needs it and a chain of moves would make one hop wrong.
### Step-up proof has two sources
An access token's `step_up_at` is set in exactly two places: the `sign_in`
step-up callback (Heimdall asked the provider to re-authenticate; label
`provider:<name>`) and `assertStepUp`, the app-asserted route (the app says it
checked a factor; label from the profile's `stepUpAssertion.methods`). Both
feed the one reader, `proofTier` in `claims.ts` (unlink and identity moves both
read it: `strong` is a step-up inside 600 s, `weak` a sign-in inside 300 s via
`auth_at`), and a refresh sets neither. `proofCovers` says what a proof speaks
for: only a strong proof whose method the profile lists as an account proof
covers the whole account; any other proof covers the one login in
`auth_identity_id`. The
feed the one reader, the unlink check, and a refresh sets neither. The
asserted route trusts the calling app and is bounded by the app secret, a live
bearer for that app, the method allowlist, a nonce claimed once per session
through `claimOAuthStateUse` (phase `step_up_assert`, no new table or grant;

View file

@ -720,9 +720,8 @@ cookie ticket would turn cookie theft into bearer theft and, with
Bifrost keeps the bearer is its own decision and is not made here.
Unlink accepts any method today. A `bifrost-email-code` proof unlinks an
identity a Google re-authentication would have guarded. Identity moves
(below) are the first route to require a method for whole-account coverage;
unlink is unchanged.
identity a Google re-authentication would have guarded. Requiring a method per
route (identity moves first) is the later hardening and is not in this change.
What this does not prove: Heimdall cannot see the passkey or the code. It trusts
the calling app's word, exactly as it trusts `stepUpRequested` (asked, not
@ -733,71 +732,6 @@ live bearer it holds. `identity_unlinked` records the proof's `stepUpMethod`
(`provider:<name>`, an app label, or null), and unlink does not yet require any
particular method.
### `POST /v1/apps/{appSlug}/identity-moves`
The one sanctioned way a login changes account (`moveIdentities` is the only
writer of `linked_identities.account_id`). Bifrost owns the merge: survivor
choice, preview, grace and undo timing. Heimdall receives the choice as which
proofs fill which side, renders no preview and picks nothing. Only a profile with
`identityMoves` may call it (today bifrost).
Credential: the app's secret. Body (closed; unknown properties are ignored, and
no account is ever named by the body):
- `moveId` (`^[A-Za-z0-9_-]{16,64}$`): the caller's idempotency key.
- `fromProofs`: 1 to 8 live Heimdall access tokens for this app, all of the
source account.
- Forward move: `toProof` (a live token of the destination) and optionally
`identityIds` (1 to 32; default every login the source holds).
- Reversal: `reversesMoveId` instead of `toProof`/`identityIds`. It moves back
exactly the logins the forward move carried, once; `fromProofs` must be of the
forward move's destination. A reversal is not itself reversible.
Each proof must be fresh: a step-up inside 600 s (`strong`) or a sign-in inside
300 s (`weak`), by `proofTier`, the same freshness rule the unlink route reads.
A token with neither claim (a refresh, a link) is stale.
Coverage: a proof through login X (`auth_identity_id`) moves X and nothing else
from that account. Only a strong proof asserted with a method in the profile's
`identityMoves.accountProofMethods` (`bifrost-passkey`; not `bifrost-email-code`,
which proves an inbox) speaks for the whole account. Moving several logins takes
one proof per login, or the passkey.
Order of refusals, each a fixed code: `401 app_auth_required`; `403
not_allowed_for_app`; a replayed `moveId` is answered next (below); `409
not_reversible` (reversal names a missing, foreign or reversal move); `403
proof_invalid` `{proof: "from"|"to"}` (not a live bearer of this app, or
`fromProofs` naming two accounts); `409 same_account`; `403 proof_stale`
`{proof}`; `403 identity_not_proved`; then the store: `403 proof_invalid` (a
proof session lapsed under the lock), `409 already_moved` (either side is an
alias), `409 provider_conflict` `{provider}`, `409 not_reversible`, `409
move_id_conflict`, `404 identity_not_found`, `403 identity_not_proved`. A refusal
body is `{error, detail?, proof?, provider?}` and never names an account id, an
identity the caller did not send, an email or a name.
On success, in one transaction: the logins move to the destination with their
stored credentials stripped; every live session of the source is revoked and its
pending links and unredeemed completions expire; one append-only `identity_moves`
row is written. Afterwards, for each moved provider the source no longer holds,
its live grants get revocation receipts under the source, and `identity_moved`
is audited. Answer `200`, `Cache-Control: no-store`:
`{moveId, accountId: <destination>, movedIdentityIds, proof: {from, to}, reversesMoveId?}`,
with `proof.from` `strong` if any from-proof was strong and `to` null on a
reversal.
Idempotency: the same `moveId` with the same app, accounts and `reversesMoveId`
answers the stored receipt byte for byte, even though the first success revoked
the source's sessions: the proofs are then checked for a valid Heimdall
signature, issuer, type and audience, and account, not for liveness. The replay
re-runs the revocation receipts, so a crash between commit and receipts heals on
the caller's retry. Anything else under a used `moveId` is `409 move_id_conflict`.
What a move leaves: an account holding no login with an unreversed move from it
is an alias: derived at move time, never stored, and no route resolves it. A
move into or out of an alias is `already_moved`. Bearers of
the destination keep their old identity list until they refresh. A sign-in
through a moved login lands on the destination because the row moved.
### `POST /v1/apps/{appSlug}/managed-credentials/resolve`
Purpose:
@ -935,8 +869,6 @@ Current claim payload fields:
- `access_revision`
- `step_up_at` optional: epoch seconds of the proof; set only by a `sign_in` step-up callback or `POST /v1/apps/{appSlug}/step-up`, never by a refresh
- `step_up_method` optional: present exactly when `step_up_at` is; `provider:<name>` for a provider step-up sign-in, otherwise the method label the app asserted
- `auth_at` optional: epoch seconds of the sign-in this token was minted by; set only by a `sign_in` callback (never link, connect or refresh), always with `auth_identity_id`
- `auth_identity_id` optional: the `linked_identities.id` that sign-in came through; the only record of which login proved the session. An asserted step-up spreads the bearer's claims, so these pass through unchanged
- `display_name` optional
- `app.slug`
- `app.profile_version`

View file

@ -65,13 +65,6 @@ export interface AppProfile {
* Absent for every app that may not assert.
*/
stepUpAssertion?: { methods: readonly string[] };
/**
* The app may move linked identities between two accounts it holds proofs
* for (POST /v1/apps/{app}/identity-moves). `accountProofMethods` are the
* asserted step_up_method labels that prove a whole account rather than one
* login; each must be one of stepUpAssertion.methods. Absent: the app may not move.
*/
identityMoves?: { accountProofMethods: readonly string[] };
}
/** The membership signal shared by every app that gates on GameCult membership. */
@ -171,8 +164,6 @@ const bifrostProfile: AppProfile = {
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
stepUpAssertion: { methods: ["bifrost-passkey", "bifrost-email-code"] },
// A passkey proves the account; an email code proves an inbox, which is one login's worth at most.
identityMoves: { accountProofMethods: ["bifrost-passkey"] },
capabilities: [
{
key: "member_access",

View file

@ -24,7 +24,7 @@ import {
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { issueAccessClaim, proofCovers, proofTier, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js";
import { issueAccessClaim, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js";
import { FixedWindowBudget, type FixedWindowLimit } from "./fixed-window.js";
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.js";
import { renderBrowserHandoffPage } from "./browser-handoff.js";
@ -47,7 +47,6 @@ import {
OAUTH_STATE_SKEW_MS,
type CreateAccountInput,
type StoredCapabilityGrant,
type StoredIdentityMove,
type StoredLinkedIdentity,
type UpsertLinkedIdentityInput,
} from "./store/types.js";
@ -1065,184 +1064,6 @@ export async function assertStepUp(
};
}
export const MOVE_ID_PATTERN = "^[A-Za-z0-9_-]{16,64}$";
export interface MoveIdentitiesBody {
moveId: string;
fromProofs: string[];
toProof?: string;
identityIds?: string[];
reversesMoveId?: string;
}
/** The answer a move gives, derived from its stored row so a replay is byte-identical to the first answer. */
function moveReceipt(move: StoredIdentityMove) {
const tier = (proofs: { tier: "strong" | "weak" }[]) => (proofs.some((proof) => proof.tier === "strong") ? "strong" : "weak");
return {
moveId: move.id,
accountId: move.toAccountId,
movedIdentityIds: move.moved.map((moved) => moved.id),
proof: { from: tier(move.proof.from), to: move.proof.to ? move.proof.to.tier : null },
...(move.reversesMoveId !== undefined ? { reversesMoveId: move.reversesMoveId } : {}),
};
}
/**
* POST /v1/apps/{app}/identity-moves: the one sanctioned way a login changes
* account. The caller's secret and its profile's identityMoves grant open the
* route; the accounts come only from the proofs (live bearers of this app,
* fresh by proofTier), never from the body, an email or a name. A proof
* through login X moves X; only an asserted account proof (the profile's
* accountProofMethods) speaks for the whole account. The store decides and
* commits; this route verifies, maps refusals to fixed codes, then writes the
* revocation receipts the move owes (revokeLiveGrants stays their one writer)
* and the audit row. A replayed moveId is answered before the proofs are
* verified, because the first success revoked the source's sessions.
*/
export async function moveIdentitiesRoute(
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
revokeLiveGrants: (accountId: string, provider: Provider, receipts: Parameters<typeof grantViews>[0], at: string) => Promise<string[]>,
appSlug: AppSlug,
headers: Record<string, string | string[] | undefined>,
body: MoveIdentitiesBody
): Promise<HandlerResult> {
const { config, keys, store } = ctx;
const refuse = (statusCode: number, error: string, extra: Record<string, unknown> = {}): HandlerResult => ({ statusCode, body: { error, ...extra } });
if (!resolveAppCaller(config, appSlug, headers)) return refuse(401, "app_auth_required");
const grant = getAppProfile(appSlug)?.identityMoves;
if (!grant) return refuse(403, "not_allowed_for_app");
// The providers a move carried away from its source that the source no longer holds: their grants are taken back under the source.
const settleGrants = async (move: StoredIdentityMove) => {
const held = new Set((await store.listStoredLinkedIdentitiesForAccount(move.fromAccountId)).map((identity) => identity.provider));
const at = new Date().toISOString();
for (const provider of new Set(move.moved.map((moved) => moved.provider))) {
if (!held.has(provider)) await revokeLiveGrants(move.fromAccountId, provider, await store.listConsentReceipts(move.fromAccountId), at);
}
};
const existing = await store.findIdentityMove(body.moveId);
if (existing) {
const signedFor = (token: string | undefined, accountId: string) => {
const verification = token ? verifyJwt(token, keys.publicKey) : null;
return (
verification?.valid === true &&
verification.payload.typ === "heimdall_access" &&
verification.payload.iss === config.issuer &&
verification.payload.aud === appSlug &&
verification.payload.account_id === accountId
);
};
const same =
existing.appSlug === appSlug &&
existing.reversesMoveId === body.reversesMoveId &&
body.fromProofs.every((token) => signedFor(token, existing.fromAccountId)) &&
(existing.reversesMoveId !== undefined ? body.toProof === undefined : signedFor(body.toProof, existing.toAccountId));
if (!same) return refuse(409, "move_id_conflict");
await settleGrants(existing);
return { statusCode: 200, body: moveReceipt(existing) };
}
let forward: StoredIdentityMove | null = null;
if (body.reversesMoveId !== undefined) {
forward = await store.findIdentityMove(body.reversesMoveId);
if (!forward || forward.appSlug !== appSlug || forward.reversesMoveId !== undefined) return refuse(409, "not_reversible");
}
const nowS = nowEpochSeconds();
const resolve = (token: string) => resolveBearer(ctx, `Bearer ${token}`, appSlug);
const fromBearers = [];
for (const token of body.fromProofs) {
const bearer = await resolve(token);
if (!bearer) return refuse(403, "proof_invalid", { proof: "from" });
fromBearers.push(bearer);
}
const fromAccountId = fromBearers[0]!.accountId;
if (fromBearers.some((bearer) => bearer.accountId !== fromAccountId)) return refuse(403, "proof_invalid", { proof: "from" });
let toBearer: ResolvedBearer | null = null;
if (forward === null) {
toBearer = await resolve(body.toProof!);
if (!toBearer) return refuse(403, "proof_invalid", { proof: "to" });
} else if (fromAccountId !== forward.toAccountId) {
return refuse(409, "not_reversible");
}
const toAccountId = toBearer?.accountId ?? forward!.fromAccountId;
if (fromAccountId === toAccountId) return refuse(409, "same_account");
const proofOf = (bearer: ResolvedBearer, tier: "strong" | "weak") => ({
tier,
method: tier === "strong" && typeof bearer.claims.step_up_method === "string" ? bearer.claims.step_up_method : null,
identityId: typeof bearer.claims.auth_identity_id === "string" ? bearer.claims.auth_identity_id : null,
});
const fromProof = [];
for (const bearer of fromBearers) {
const tier = proofTier(bearer.claims, nowS);
if (!tier) return refuse(403, "proof_stale", { proof: "from" });
fromProof.push(proofOf(bearer, tier));
}
const toTier = toBearer ? proofTier(toBearer.claims, nowS) : null;
if (toBearer && !toTier) return refuse(403, "proof_stale", { proof: "to" });
const coverage = fromBearers.map((bearer) => proofCovers(bearer.claims, grant.accountProofMethods, nowS));
if (coverage.every((covered) => covered === null)) return refuse(403, "identity_not_proved");
const covers = coverage.includes("account") ? "account" : coverage.filter((covered): covered is string => covered !== null);
const at = new Date().toISOString();
const result = await store.moveIdentities({
moveId: body.moveId,
appSlug,
fromAccountId,
toAccountId,
identityIds: forward ? forward.moved.map((moved) => moved.id) : (body.identityIds ?? "all"),
covers,
proofSessions: [...fromBearers, ...(toBearer ? [toBearer] : [])].map((bearer) => ({
appSlug,
sessionId: bearer.claims.sid as string,
accountId: bearer.accountId,
accessRevision: bearer.claims.access_revision as number,
})),
...(body.reversesMoveId !== undefined ? { reversesMoveId: body.reversesMoveId } : {}),
proof: { from: fromProof, to: toBearer && toTier ? proofOf(toBearer, toTier) : null },
at,
});
switch (result.kind) {
case "proof_lapsed":
return refuse(403, "proof_invalid");
case "already_moved":
case "not_reversible":
case "move_id_conflict":
return refuse(409, result.kind);
case "provider_conflict":
return refuse(409, "provider_conflict", { provider: result.provider });
case "identity_not_found":
return refuse(404, "identity_not_found");
case "identity_not_proved":
return refuse(403, "identity_not_proved");
case "replayed":
await settleGrants(result.move);
return { statusCode: 200, body: moveReceipt(result.move) };
case "moved": {
const { move } = result;
await settleGrants(move);
await store.createAuditEvent({
accountId: move.fromAccountId,
appSlug,
eventType: "identity_moved",
eventPayloadJson: {
moveId: move.id,
toAccountId: move.toAccountId,
movedIdentityIds: move.moved.map((moved) => moved.id),
providers: move.moved.map((moved) => moved.provider),
tiers: { from: moveReceipt(move).proof.from, to: moveReceipt(move).proof.to },
reversesMoveId: move.reversesMoveId ?? null,
},
createdAt: at,
});
return { statusCode: 200, body: moveReceipt(move) };
}
}
}
const grantsPageTokenType = "heimdall_grants_page";
const grantsPageTokenTtlSeconds = 600;
@ -2155,9 +1976,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await store.touchAccount(account.id, nowIso, accountUpdates);
}
let signedInIdentity: StoredLinkedIdentity;
try {
signedInIdentity = await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
} catch (error) {
// The check above is the fast path; this is the write refusing a move another account won the race for.
if (error instanceof IdentityHeldElsewhereError) {
@ -2223,8 +2043,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
// Proof of a fresh re-authentication, from the signed state alone: only a sign_in the app started with stepUp.
...(statePayload.step_up && statePayload.mode === "sign_in" ? { stepUpAt: nowEpochSeconds(), stepUpMethod: `provider:${statePayload.provider}` } : {}),
// Which login proved this session, and when: only a sign_in, so a link or connect never counts as a sign-in.
...(statePayload.mode === "sign_in" ? { authAt: nowEpochSeconds(), authIdentityId: signedInIdentity.id } : {}),
};
if (account.displayName !== undefined) {
issueInput.displayName = account.displayName;
@ -3093,7 +2911,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
grantsReply(reply);
const { accountId, claims } = bearer;
const nowS = nowEpochSeconds();
if (proofTier(claims, nowS) !== "strong") {
const provedAt = claims.step_up_at;
if (typeof provedAt !== "number" || provedAt > nowS || nowS - provedAt > STEP_UP_PROOF_TTL_SECONDS) {
reply.code(403);
return {
error: "step_up_required",
@ -3130,44 +2949,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
// The sanctioned mover of logins between accounts: see moveIdentitiesRoute. Registered here because it
// needs revokeLiveGrants, which belongs to this scope.
app.post<{ Params: { appSlug: AppSlug }; Body: MoveIdentitiesBody }>(
"/v1/apps/:appSlug/identity-moves",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
},
body: {
type: "object",
required: ["moveId", "fromProofs"],
additionalProperties: false,
properties: {
moveId: { type: "string", pattern: MOVE_ID_PATTERN },
fromProofs: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 8192 } },
toProof: { type: "string", minLength: 1, maxLength: 8192 },
identityIds: { type: "array", minItems: 1, maxItems: 32, uniqueItems: true, items: { type: "string", minLength: 1 } },
reversesMoveId: { type: "string", pattern: MOVE_ID_PATTERN },
},
// A forward move names its destination proof; a reversal names the move and nothing else about who or what.
oneOf: [
{ required: ["toProof"], not: { anyOf: [{ required: ["reversesMoveId"] }] } },
{ required: ["reversesMoveId"], not: { anyOf: [{ required: ["toProof"] }, { required: ["identityIds"] }] } },
],
},
},
},
async (request, reply) => {
const result = await moveIdentitiesRoute({ config, keys, store }, revokeLiveGrants, request.params.appSlug, request.headers, request.body);
reply.code(result.statusCode).header("cache-control", "no-store");
return result.body;
}
);
// The signed-in surface for consent: every grant and receipt the account
// holds, and the one-click way to take each back.
app.get<{ Querystring: { t?: string } }>(

View file

@ -14,9 +14,6 @@ import { type CreateSessionInput, type HeimdallStore } from "./store/types.js";
/** How long a step-up sign-in's proof stays good for an action that demands it (identity unlink). */
export const STEP_UP_PROOF_TTL_SECONDS = 600;
/** How long a plain sign-in's proof stays good for an action that accepts a weak proof (an identity move). */
export const AUTH_PROOF_TTL_SECONDS = 300;
function nowEpochSeconds(): number {
return Math.floor(Date.now() / 1000);
}
@ -58,10 +55,6 @@ export interface IssueAccessClaimInput {
stepUpAt?: number;
/** Required with stepUpAt (`provider:<name>`); the claim never carries one without the other. */
stepUpMethod?: string;
/** Epoch seconds of the sign-in that minted this token, with authIdentityId (both or neither). Only a sign_in callback sets them; a refresh, link or connect never does. */
authAt?: number;
/** The linked_identities.id the sign-in came through. */
authIdentityId?: string;
}
export interface IssuedAccessClaimResult {
@ -151,11 +144,6 @@ export async function issueAccessClaim(options: {
}
}
if (options.input.authAt !== undefined && options.input.authIdentityId !== undefined) {
claimSet.auth_at = options.input.authAt;
claimSet.auth_identity_id = options.input.authIdentityId;
}
if (options.input.displayName) {
claimSet.display_name = options.input.displayName;
}
@ -241,38 +229,3 @@ export function signAssertedStepUpClaim(options: {
};
return { accessToken: signJwt(claimSet, options.keys), claimSet, stepUpAt };
}
/**
* How fresh a bearer's proof of the member is, from its claims alone:
* `strong` when a step-up (Heimdall's or the app's asserted one) happened in
* the last STEP_UP_PROOF_TTL_SECONDS, else `weak` when the sign-in did in the
* last AUTH_PROOF_TTL_SECONDS, else null. The one freshness rule: identity
* unlink and identity moves both read it.
*/
export function proofTier(claims: Record<string, unknown>, nowS: number): "strong" | "weak" | null {
const within = (at: unknown, window: number) => typeof at === "number" && at <= nowS && nowS - at <= window;
if (within(claims.step_up_at, STEP_UP_PROOF_TTL_SECONDS)) return "strong";
if (within(claims.auth_at, AUTH_PROOF_TTL_SECONDS)) return "weak";
return null;
}
/**
* What a fresh proof covers of its account: `account` (every login) only for a
* strong proof whose step_up_method the app's profile lists as an account
* proof; otherwise the one login the sign-in came through (its id); otherwise
* nothing. A proof through login X never speaks for the account's other logins.
*/
export function proofCovers(
claims: Record<string, unknown>,
accountProofMethods: readonly string[],
nowS: number
): "account" | string | null {
if (
proofTier(claims, nowS) === "strong" &&
typeof claims.step_up_method === "string" &&
accountProofMethods.includes(claims.step_up_method)
) {
return "account";
}
return typeof claims.auth_identity_id === "string" ? claims.auth_identity_id : null;
}

View file

@ -162,10 +162,6 @@ export interface AccessClaimPayload {
* exactly when step_up_at is; never carried by a refresh.
*/
step_up_method?: string;
/** Epoch seconds of the sign-in this access token was minted by, with auth_identity_id; absent unless a sign_in callback minted it, and never carried by a refresh. */
auth_at?: number;
/** The linked_identities.id the sign-in came through; present exactly when auth_at is. It is the only record of which login proved the session, and it describes the sign-in, not an asserted factor. */
auth_identity_id?: string;
display_name?: string;
app: {
slug: AppSlug;

View file

@ -1,88 +0,0 @@
import { type Provider } from "../contracts.js";
import { type MoveIdentitiesInput, type StoredIdentityMove, type StoredLinkedIdentity } from "./types.js";
/** Everything a store read under its locks that the move rules decide on. */
export interface MoveFacts {
/** The identity_moves row already holding input.moveId. */
existing: StoredIdentityMove | null;
/** The move input.reversesMoveId names, and whether a reversal of it already exists. */
forward: { move: StoredIdentityMove; reversed: boolean } | null;
/** Every proof session still carries its sid, app, account and access revision and has not expired. */
proofSessionsLive: boolean;
sourceIdentities: readonly StoredLinkedIdentity[];
destinationProviders: ReadonlySet<Provider>;
/** An account with no identity and an unreversed move from it. */
sourceIsAlias: boolean;
destinationIsAlias: boolean;
}
export type MoveDecision =
| { kind: "replayed"; move: StoredIdentityMove }
| { kind: "refused"; result: { kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible" } | { kind: "provider_conflict"; provider: Provider } }
| { kind: "move"; identities: StoredLinkedIdentity[] };
/**
* The one statement of the move rules, in the order they are decided. Both
* stores gather MoveFacts under their locks and apply the answer, so memory
* and Postgres cannot disagree about what a move refuses.
*/
export function decideMove(input: MoveIdentitiesInput, facts: MoveFacts): MoveDecision {
const refuse = (kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible"): MoveDecision => ({ kind: "refused", result: { kind } });
const { existing } = facts;
if (existing) {
return existing.appSlug === input.appSlug &&
existing.fromAccountId === input.fromAccountId &&
existing.toAccountId === input.toAccountId &&
existing.reversesMoveId === input.reversesMoveId
? { kind: "replayed", move: existing }
: refuse("move_id_conflict");
}
if (!facts.proofSessionsLive) return refuse("proof_lapsed");
const held = new Set(facts.sourceIdentities.map((identity) => identity.id));
if (input.reversesMoveId !== undefined) {
const forward = facts.forward;
if (
!forward ||
forward.reversed ||
forward.move.appSlug !== input.appSlug ||
forward.move.reversesMoveId !== undefined ||
forward.move.toAccountId !== input.fromAccountId ||
forward.move.fromAccountId !== input.toAccountId ||
!forward.move.moved.every((moved) => held.has(moved.id))
) {
return refuse("not_reversible");
}
} else if (facts.sourceIsAlias || facts.destinationIsAlias) {
return refuse("already_moved");
}
let chosen: StoredLinkedIdentity[];
if (input.identityIds === "all") {
chosen = [...facts.sourceIdentities];
} else {
if (!input.identityIds.every((id) => held.has(id))) return refuse("identity_not_found");
const wanted = new Set(input.identityIds);
chosen = facts.sourceIdentities.filter((identity) => wanted.has(identity.id));
}
if (chosen.length === 0) return refuse("identity_not_found");
if (input.covers !== "account") {
const covered = new Set(input.covers);
if (!chosen.every((identity) => covered.has(identity.id))) return refuse("identity_not_proved");
}
const clash = chosen.find((identity) => facts.destinationProviders.has(identity.provider));
if (clash) return { kind: "refused", result: { kind: "provider_conflict", provider: clash.provider } };
return { kind: "move", identities: chosen };
}
/** The history a move writes about what it carried. */
export function movedRecord(identities: readonly StoredLinkedIdentity[]): StoredIdentityMove["moved"] {
return identities.map((identity) => ({
id: identity.id,
provider: identity.provider,
providerUserId: identity.providerUserId,
credentialStripped: identity.accessTokenEncrypted !== undefined || identity.refreshTokenEncrypted !== undefined,
}));
}

View file

@ -26,13 +26,9 @@ import {
type StoredLinkedIdentity,
type StoredSession,
type StoredPrivateCommandReceipt,
type MoveIdentitiesInput,
type MoveResult,
type StoredIdentityMove,
type UnlinkResult,
type UpsertLinkedIdentityInput,
} from "./types.js";
import { decideMove, movedRecord } from "./identity-move.js";
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
@ -67,7 +63,6 @@ export class InMemoryStore implements HeimdallStore {
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
private readonly consentReceipts: StoredConsentReceipt[] = [];
private readonly identityMoves = new Map<string, StoredIdentityMove>();
async ensureSchema(): Promise<void> {
return;
@ -207,78 +202,6 @@ export class InMemoryStore implements HeimdallStore {
return { kind: "unlinked", identity: clone(target) };
}
/** No await between the reads and the writes: the event loop is the lock. */
async moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult> {
const at = Date.parse(input.at);
const forwardMove = input.reversesMoveId !== undefined ? this.identityMoves.get(input.reversesMoveId) : undefined;
const sourceIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.fromAccountId);
const destinationIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.toAccountId);
const decision = decideMove(input, {
existing: this.identityMoves.get(input.moveId) ?? null,
forward: forwardMove ? { move: forwardMove, reversed: this.isReversed(forwardMove.id) } : null,
proofSessionsLive: input.proofSessions.every((proof) => {
const session = this.sessions.get(proof.sessionId);
return (
session !== undefined &&
session.appSlug === proof.appSlug &&
session.accountId === proof.accountId &&
session.accessRevision === proof.accessRevision &&
Date.parse(session.expiresAt) > at
);
}),
sourceIdentities: clone(sourceIdentities),
destinationProviders: new Set(destinationIdentities.map((identity) => identity.provider)),
sourceIsAlias: sourceIdentities.length === 0 && this.hasUnreversedMoveFrom(input.fromAccountId),
destinationIsAlias: destinationIdentities.length === 0 && this.hasUnreversedMoveFrom(input.toAccountId),
});
if (decision.kind === "replayed") return { kind: "replayed", move: clone(decision.move) };
if (decision.kind === "refused") return decision.result;
for (const identity of decision.identities) {
const row = this.linkedIdentities.get(identity.id)!;
row.accountId = input.toAccountId;
row.updatedAt = input.at;
delete row.accessTokenEncrypted;
delete row.refreshTokenEncrypted;
delete row.tokenExpiresAt;
delete row.refreshFailedAt;
}
for (const session of this.sessions.values()) {
if (session.accountId === input.fromAccountId && Date.parse(session.expiresAt) > at) {
session.expiresAt = input.at;
session.lastSeenAt = input.at;
session.accessRevision += 1;
}
}
for (const row of [...this.pendingLinks.values(), ...this.authCompletions.values()]) {
if (row.accountId === input.fromAccountId && !row.consumedAt && Date.parse(row.expiresAt) > at) row.expiresAt = input.at;
}
const move: StoredIdentityMove = {
id: input.moveId,
appSlug: input.appSlug,
fromAccountId: input.fromAccountId,
toAccountId: input.toAccountId,
...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}),
moved: movedRecord(decision.identities),
proof: clone(input.proof),
createdAt: input.at,
};
this.identityMoves.set(move.id, clone(move));
return { kind: "moved", move };
}
async findIdentityMove(moveId: string): Promise<StoredIdentityMove | null> {
return clone(this.identityMoves.get(moveId) ?? null);
}
private isReversed(moveId: string): boolean {
return [...this.identityMoves.values()].some((move) => move.reversesMoveId === moveId);
}
private hasUnreversedMoveFrom(accountId: string): boolean {
return [...this.identityMoves.values()].some((move) => move.fromAccountId === accountId && !this.isReversed(move.id));
}
async updateLinkedIdentityCredential(
id: string,
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,

View file

@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { Pool, type PoolClient, type QueryResultRow } from "pg";
import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js";
import { decideMove, movedRecord, type MoveFacts } from "./identity-move.js";
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "./schema.js";
import {
type CreateAccountInput,
@ -20,9 +19,6 @@ import {
type CreatePrivateCommandReceiptInput,
type HeimdallStore,
IdentityHeldElsewhereError,
type MoveIdentitiesInput,
type MoveResult,
type StoredIdentityMove,
OAUTH_STATE_PRUNE_BATCH,
oauthStatePruneCutoffMs,
type StoredAccount,
@ -179,42 +175,6 @@ function mapAccountRow(row: AccountRow): StoredAccount {
return account;
}
interface IdentityMoveRow extends QueryResultRow {
id: string;
app_slug: AppSlug;
from_account_id: string;
to_account_id: string;
reverses_move_id: string | null;
moved_json: StoredIdentityMove["moved"];
proof_json: StoredIdentityMove["proof"];
created_at: string;
}
async function selectIdentityMove(db: Pick<Pool, "query">, moveId: string): Promise<StoredIdentityMove | null> {
const result = await db.query<IdentityMoveRow>("SELECT * FROM identity_moves WHERE id = $1", [moveId]);
const row = result.rows[0];
if (!row) return null;
return {
id: row.id,
appSlug: row.app_slug,
fromAccountId: row.from_account_id,
toAccountId: row.to_account_id,
...(row.reverses_move_id ? { reversesMoveId: row.reverses_move_id } : {}),
moved: row.moved_json,
proof: row.proof_json,
createdAt: new Date(row.created_at).toISOString(),
};
}
/** A move from this account that no reversal names: the account's identities went elsewhere. */
async function hasUnreversedMoveFrom(db: Pick<Pool, "query">, accountId: string): Promise<boolean> {
const result = await db.query(
"SELECT 1 FROM identity_moves m WHERE m.from_account_id = $1 AND NOT EXISTS (SELECT 1 FROM identity_moves r WHERE r.reverses_move_id = m.id) LIMIT 1",
[accountId]
);
return (result.rowCount ?? 0) > 0;
}
function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
const identity: StoredLinkedIdentity = {
id: row.id,
@ -497,9 +457,6 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri
* expired oauth_state_uses rows (which needs SELECT for its WHERE), and the one
* linked_identities row an unlink names (unlinkLinkedIdentity, the only
* delete there; it needs SELECT for its WHERE and for the account's count).
* identity_moves is append-only. moveIdentities is the statement that updates
* linked_identities.account_id (its one writer), sessions, pending_links and
* auth_completions, all of which already hold UPDATE.
* audit_events is written and never read back.
*/
export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE" | "DELETE")[]>> = {
@ -514,7 +471,6 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
consent_receipts: ["SELECT", "INSERT"],
identity_moves: ["SELECT", "INSERT"],
audit_events: ["INSERT"],
};
@ -897,108 +853,6 @@ export class PostgresStore implements HeimdallStore {
}
}
async moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
try {
// Both accounts, one statement each in id order, before anything is read: opposite moves queue here
// instead of deadlocking. The order is the code's own, not whatever order a plan locks a set in.
for (const id of [input.fromAccountId, input.toAccountId].sort()) {
await client.query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE", [id]);
}
const existing = await selectIdentityMove(client, input.moveId);
let forward: MoveFacts["forward"] = null;
if (input.reversesMoveId !== undefined) {
const forwardMove = await selectIdentityMove(client, input.reversesMoveId);
const reversal = forwardMove ? await client.query("SELECT 1 FROM identity_moves WHERE reverses_move_id = $1", [forwardMove.id]) : null;
forward = forwardMove ? { move: forwardMove, reversed: (reversal?.rowCount ?? 0) > 0 } : null;
}
let proofSessionsLive = true;
for (const proof of input.proofSessions) {
const live = await client.query(
"SELECT 1 FROM sessions WHERE id = $1 AND app_slug = $2 AND account_id = $3 AND access_revision = $4 AND expires_at > $5",
[proof.sessionId, proof.appSlug, proof.accountId, proof.accessRevision, input.at]
);
if (!live.rowCount) proofSessionsLive = false;
}
const source = await client.query<LinkedIdentityRow>(
"SELECT * FROM linked_identities WHERE account_id = $1 ORDER BY created_at ASC, id ASC FOR UPDATE",
[input.fromAccountId]
);
const destination = await client.query<{ provider: Provider }>("SELECT provider FROM linked_identities WHERE account_id = $1", [input.toAccountId]);
const sourceIdentities = source.rows.map(mapLinkedIdentityRow);
const decision = decideMove(input, {
existing,
forward,
proofSessionsLive,
sourceIdentities,
destinationProviders: new Set(destination.rows.map((row) => row.provider)),
sourceIsAlias: sourceIdentities.length === 0 && (await hasUnreversedMoveFrom(client, input.fromAccountId)),
destinationIsAlias: destination.rowCount === 0 && (await hasUnreversedMoveFrom(client, input.toAccountId)),
});
if (decision.kind !== "move") {
await client.query("ROLLBACK");
return decision.kind === "replayed" ? { kind: "replayed", move: decision.move } : decision.result;
}
const ids = decision.identities.map((identity) => identity.id);
await client.query(
`UPDATE linked_identities
SET account_id = $2, access_token_encrypted = NULL, refresh_token_encrypted = NULL,
token_expires_at = NULL, refresh_failed_at = NULL, updated_at = $3
WHERE id = ANY($1) AND account_id = $4`,
[ids, input.toAccountId, input.at, input.fromAccountId]
);
await client.query(
"UPDATE sessions SET expires_at = $2, last_seen_at = $2, access_revision = access_revision + 1 WHERE account_id = $1 AND expires_at > $2",
[input.fromAccountId, input.at]
);
for (const table of ["pending_links", "auth_completions"]) {
await client.query(
`UPDATE ${table} SET expires_at = $2 WHERE account_id = $1 AND consumed_at IS NULL AND expires_at > $2`,
[input.fromAccountId, input.at]
);
}
const move: StoredIdentityMove = {
id: input.moveId,
appSlug: input.appSlug,
fromAccountId: input.fromAccountId,
toAccountId: input.toAccountId,
...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}),
moved: movedRecord(decision.identities),
proof: input.proof,
createdAt: input.at,
};
try {
await client.query(
`INSERT INTO identity_moves (id, app_slug, from_account_id, to_account_id, reverses_move_id, moved_json, proof_json, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[move.id, move.appSlug, move.fromAccountId, move.toAccountId, nullable(move.reversesMoveId), JSON.stringify(move.moved), JSON.stringify(move.proof), move.createdAt]
);
} catch (error) {
// The row is the arbiter of a race the locks did not serialise (a reversal of the same move through another pool).
if ((error as { code?: string }).code !== "23505") throw error;
await client.query("ROLLBACK");
const winner = await this.findIdentityMove(input.moveId);
if (!winner) return { kind: "not_reversible" };
const again = decideMove(input, { existing: winner, forward: null, proofSessionsLive: true, sourceIdentities: [], destinationProviders: new Set(), sourceIsAlias: false, destinationIsAlias: false });
return again.kind === "replayed" ? { kind: "replayed", move: again.move } : { kind: "move_id_conflict" };
}
await client.query("COMMIT");
return { kind: "moved", move };
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
}
} finally {
client.release();
}
}
async findIdentityMove(moveId: string): Promise<StoredIdentityMove | null> {
return selectIdentityMove(this.pool, moveId);
}
async updateLinkedIdentityCredential(
id: string,
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,

View file

@ -216,29 +216,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx
CREATE INDEX IF NOT EXISTS consent_receipts_account_idx
ON consent_receipts(account_id, created_at);
-- One row per identity move, append-only: no UPDATE and no DELETE, and no
-- ON DELETE CASCADE, so the record outlives nothing it names. moved_json is
-- [{id, provider, providerUserId, credentialStripped}]; proof_json is
-- {from: [{tier, method, identityId}], to: {...} | null}. Never a token, sid,
-- email or name. A move is reversed once: the second row naming it conflicts.
CREATE TABLE IF NOT EXISTS identity_moves (
id TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
from_account_id TEXT NOT NULL REFERENCES accounts(id),
to_account_id TEXT NOT NULL REFERENCES accounts(id),
reverses_move_id TEXT REFERENCES identity_moves(id),
moved_json JSONB NOT NULL,
proof_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS identity_moves_reverses_unique_idx
ON identity_moves(reverses_move_id)
WHERE reverses_move_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS identity_moves_from_idx
ON identity_moves(from_account_id);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
account_id TEXT,

View file

@ -59,60 +59,6 @@ export type UnlinkResult =
| { kind: "last_identity" }
| { kind: "not_found" };
/** One login a move carried; `credentialStripped` is history written at the move (the row held a token the move dropped). */
export interface MovedIdentity {
id: string;
provider: Provider;
providerUserId: string;
credentialStripped: boolean;
}
/** What each side's proofs were, as tiers and methods only: never a token, sid, email or name. */
export interface IdentityMoveProof {
from: { tier: "strong" | "weak"; method: string | null; identityId: string | null }[];
to: { tier: "strong" | "weak"; method: string | null; identityId: string | null } | null;
}
/** The append-only record of one move. */
export interface StoredIdentityMove {
id: string;
appSlug: AppSlug;
fromAccountId: string;
toAccountId: string;
reversesMoveId?: string;
moved: MovedIdentity[];
proof: IdentityMoveProof;
createdAt: string;
}
export interface MoveIdentitiesInput {
moveId: string;
appSlug: AppSlug;
fromAccountId: string;
toAccountId: string;
/** The logins to carry, or every login the source holds. */
identityIds: readonly string[] | "all";
/** What the caller's from-proofs cover: the whole account, or exactly these logins. */
covers: "account" | readonly string[];
/** Sessions the proofs rested on; each must still be live when the transaction commits. */
proofSessions: readonly { appSlug: AppSlug; sessionId: string; accountId: string; accessRevision: number }[];
reversesMoveId?: string;
proof: IdentityMoveProof;
at: string;
}
/** What moveIdentities decided. Refusals write nothing. */
export type MoveResult =
| { kind: "moved"; move: StoredIdentityMove }
| { kind: "replayed"; move: StoredIdentityMove }
| { kind: "move_id_conflict" }
| { kind: "proof_lapsed" }
| { kind: "already_moved" }
| { kind: "identity_not_found" }
| { kind: "identity_not_proved" }
| { kind: "provider_conflict"; provider: Provider }
| { kind: "not_reversible" };
export interface UpsertLinkedIdentityInput {
id?: string;
accountId: string;
@ -393,17 +339,6 @@ export interface HeimdallStore {
* `not_found`).
*/
unlinkLinkedIdentity(accountId: string, identityId: string): Promise<UnlinkResult>;
/**
* The only writer that sets a linked identity's account to another account,
* in one transaction: lock both accounts (in id order), answer a repeated
* moveId from its row, re-check the proof sessions, refuse what the rules
* refuse, move the rows with their credentials stripped, revoke the source's
* sessions and expire its pending links and unredeemed completions, and
* append the identity_moves row. A reversal (`reversesMoveId`) moves back
* exactly what the forward move carried. The destination never loses an identity.
*/
moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult>;
findIdentityMove(moveId: string): Promise<StoredIdentityMove | null>;
/**
* Serialise decisions about one row's credential. `decide` receives the row
* as read under the lock (never a caller's snapshot) and the decision is

View file

@ -40,4 +40,3 @@
{"ts": "2026-11-12T23:10:15+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-12: sign_in start accepts stepUp (app-authenticated, sign_in only, off by default); signed state carries step_up; launch adds Twitch force_verify=true, YouTube prompt='login consent', Google prompt=login; any other provider is 400 step_up_not_supported; completion reports stepUpRequested. Not deployed. For joystick:follow_up:chat-s7055-7056 (StreamPixels link hardening, second factor via fresh sign-in)."}
{"ts": "2026-11-13T19:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: GET /v1/me/identities lists an account's identities (no credentials, profile or email); POST /v1/me/identities/:id/unlink deletes one with a step-up proof (access claim step_up_at, 600 s, never carried by refresh), refuses the last identity (409), writes revocation receipts when the provider's last identity goes. upsertLinkedIdentity no longer moves an identity between accounts (IdentityHeldElsewhereError; links/confirm 409 identity_linked_elsewhere, callback link_initiator_mismatch). The store role needs DELETE on linked_identities (startup refuses without it): owner-role GRANT at deploy."}
{"ts": "2026-11-13T20:01:42+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: POST /v1/apps/{app}/step-up (profile grant stepUpAssertion, bifrost only; methods bifrost-passkey, bifrost-email-code). App secret plus the member's live bearer for that app; mints a same-session access token with step_up_at now and new claim step_up_method (exp kept, no refresh, no session write); nonce single-use via oauth_state_uses phase step_up_assert (no schema change), retained to the session expiry as of the claim (a refresh may slide the session past it); in-process rate limits 10 per 600 s per account, 120 per 60 s per app; audit step_up_asserted and step_up_assertion_refused with a nonce digest (429s and not-permitted refusals audited once per window; not-permitted has a 5 per 60 s per-app budget); nonce is base64url 16-128. Provider step-up sign-ins now carry step_up_method provider:<name>; identity_unlinked records stepUpMethod. Heimdall cannot verify the factor: it trusts the calling app. Not deployed; no env, secret or migration. For heimdall-body:follow_up:chat-s7419-7419.", "branch": "heimdall/app-asserted-step-up"}
{"ts": "2026-11-14T01:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-14: POST /v1/apps/{app}/identity-moves (profile grant identityMoves, bifrost only; accountProofMethods bifrost-passkey) and the identity_moves table, the only writer of linked_identities.account_id (moveIdentities, one transaction, accounts locked in id order, shared decideMove on both stores). Access claims gain auth_at and auth_identity_id (sign_in callback only). proofTier is the one freshness rule (unlink reads it; strong 600 s step_up_at, weak 300 s auth_at); proofCovers: only an asserted account-proof method covers a whole account, otherwise the login in auth_identity_id (ruling move-proof-strict-passkey). Moves strip credentials, revoke the source's sessions, expire its pending links and unredeemed completions, write revocation receipts under the source, audit identity_moved; moveId is idempotent (replay answers the stored receipt without proof liveness); a move is reversible once. Not deployed: the owner must apply the identity_moves DDL and GRANT SELECT, INSERT ON identity_moves to the store role before the new build starts (startup refuses SCHEMA_MISSING / SCHEMA_PRIVILEGES otherwise).", "branch": "heimdall/identity-moves"}

View file

@ -1,632 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { builtInAppProfiles } from "../src/app-profiles.js";
import { AUTH_PROOF_TTL_SECONDS, issueAccessClaim, proofTier } from "../src/claims.js";
import { type AppSlug, type Provider } from "../src/contracts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { createRuntimeKeyMaterial } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js";
import { type CreateAuditEventInput } from "../src/store/types.js";
import { createTestConfig, launchFlow } from "./oauth-flow.js";
// POST /v1/apps/{app}/identity-moves, driven over HTTP the way Bifrost would
// call it: the proofs are real access tokens whose claims say how and through
// which login the member signed in; the answers are what a caller can see.
const now = "2026-11-14T12:00:00.000Z";
const appAuth = { "x-heimdall-app-secret": "bifrost-secret" };
const moveId = (tag: string) => `move-${tag}`.padEnd(20, "x");
const nowS = () => Math.floor(Date.now() / 1000);
const dir = mkdtempSync(join(tmpdir(), "heimdall-identity-moves-"));
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
await Promise.all(apps.splice(0).map((app) => app.close()));
});
function twitchRuntime(providerUserId: string): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} };
},
async resolveIdentity() {
return { provider: "twitch", providerUserId, username: providerUserId, profile: { id: providerUserId } };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
}
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
return { ...twitchRuntime(providerUserId), async resolveIdentity() { return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } }; } };
}
async function rig(twitchUser = "t-signin") {
const store = new InMemoryStore();
const audit: CreateAuditEventInput[] = [];
const realAudit = store.createAuditEvent.bind(store);
store.createAuditEvent = async (input) => {
audit.push(structuredClone(input));
return realAudit(input);
};
const app = await buildApp({ config, store, oauthRuntimes: { twitch: twitchRuntime(twitchUser), discord: discordRuntime("d-linked") } });
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
return { app, store, keys, audit };
}
type Rig = Awaited<ReturnType<typeof rig>>;
let seq = 0;
/** An account holding the given logins, each with a credential, and one live-session proof factory. */
async function acct(r: Rig, held: Array<[Provider, string?]>) {
seq += 1;
const account = await r.store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "DISPLAY-CANARY", primaryEmail: "canary@example.test" });
const rows = [];
for (const [provider, user] of held) {
rows.push(
await r.store.upsertLinkedIdentity({
accountId: account.id,
provider,
providerUserId: user ?? `${provider}-${seq}`,
scopes: ["identify"],
accessTokenEncrypted: "ACCESS-CIPHERTEXT",
refreshTokenEncrypted: "REFRESH-CIPHERTEXT",
profileJson: {},
createdAt: now,
updatedAt: now,
})
);
}
return { account, rows };
}
type Acct = Awaited<ReturnType<typeof acct>>;
interface ProofOptions {
app?: AppSlug;
/** Seconds since the sign-in; null mints a token with no sign-in claim (a refresh or link would). */
signedInAgo?: number | null;
via?: number;
stepUp?: { agoS?: number; method: string };
}
/** A live session for the account and the access token it would hold. */
async function proof(r: Rig, a: Acct, o: ProofOptions = {}) {
const ago = o.signedInAgo === undefined ? 0 : o.signedInAgo;
const issued = await issueAccessClaim({
config,
keys: r.keys,
store: r.store,
input: {
appSlug: o.app ?? "bifrost",
accountId: a.account.id,
linkedIdentities: a.rows.map((row) => ({ provider: row.provider, providerUserId: row.providerUserId })),
facts: [],
...(ago === null ? {} : { authAt: nowS() - ago, authIdentityId: a.rows[o.via ?? 0]!.id }),
...(o.stepUp ? { stepUpAt: nowS() - (o.stepUp.agoS ?? 0), stepUpMethod: o.stepUp.method } : {}),
},
});
return { token: issued.accessToken, sid: issued.session.sessionId };
}
const passkey = { method: "bifrost-passkey" };
const move = (r: Rig, body: object, headers: Record<string, string> = appAuth, app = "bifrost") =>
r.app.inject({ method: "POST", url: `/v1/apps/${app}/identity-moves`, headers, payload: body });
const held = async (r: Rig, a: Acct) => (await r.store.listStoredLinkedIdentitiesForAccount(a.account.id)).map((i) => i.id).sort();
const err = (res: { statusCode: number; json(): { error?: string } }) => [res.statusCode, res.json().error];
/** Source with discord+google, destination with patreon: a passkey on the source and any fresh proof on the destination. */
async function scene(r: Rig) {
const from = await acct(r, [["discord"], ["google"]]);
const to = await acct(r, [["patreon"]]);
return {
from,
to,
fromProof: await proof(r, from, { stepUp: passkey }),
toProof: await proof(r, to),
};
}
describe("a sanctioned move", () => {
it("two fresh proofs move every login of the source and answer the receipt, with no token, email or name", async () => {
const r = await rig();
const s = await scene(r);
const sourceIds = await held(r, s.from);
const res = await move(r, { moveId: moveId("a"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
expect(res.statusCode).toBe(200);
expect(res.headers["cache-control"]).toBe("no-store");
expect(res.json()).toEqual({
moveId: moveId("a"),
accountId: s.to.account.id,
movedIdentityIds: expect.arrayContaining(sourceIds),
proof: { from: "strong", to: "weak" },
});
expect(res.json().movedIdentityIds).toHaveLength(2);
expect(res.body).not.toMatch(/CIPHERTEXT|canary|DISPLAY|ey[A-Za-z0-9_-]{20,}\./);
expect(await held(r, s.from)).toEqual([]);
expect(await held(r, s.to)).toEqual(expect.arrayContaining(sourceIds));
expect(await held(r, s.to)).toHaveLength(3);
expect(r.audit.find((e) => e.eventType === "identity_moved")).toMatchObject({
accountId: s.from.account.id,
eventPayloadJson: { moveId: moveId("a"), toAccountId: s.to.account.id, providers: ["discord", "google"], reversesMoveId: null },
});
});
it("answers the identical body for the same moveId after the source's sessions are revoked, and changes nothing twice", async () => {
const r = await rig();
const s = await scene(r);
const body = { moveId: moveId("b"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
const first = await move(r, body);
expect(first.statusCode).toBe(200);
const session = await r.store.findSession("bifrost", s.fromProof.sid);
expect(Date.parse(session!.expiresAt), "the source's session is already dead").toBeLessThanOrEqual(Date.now());
const again = await move(r, body);
expect([again.statusCode, again.body]).toEqual([200, first.body]);
expect(r.audit.filter((e) => e.eventType === "identity_moved")).toHaveLength(1);
});
it("a moveId reused for another account pair, another proof owner or another reversesMoveId is 409 move_id_conflict and changes nothing", async () => {
const r = await rig();
const s = await scene(r);
const other = await acct(r, [["twitch"]]);
await move(r, { moveId: moveId("c"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
const otherProof = await proof(r, other);
const toStranger = await move(r, { moveId: moveId("c"), fromProofs: [s.fromProof.token], toProof: otherProof.token });
expect(err(toStranger)).toEqual([409, "move_id_conflict"]);
const fromStranger = await move(r, { moveId: moveId("c"), fromProofs: [otherProof.token], toProof: s.toProof.token });
expect(err(fromStranger)).toEqual([409, "move_id_conflict"]);
const asReversal = await move(r, { moveId: moveId("c"), fromProofs: [s.toProof.token], reversesMoveId: moveId("zz") });
expect(err(asReversal)).toEqual([409, "move_id_conflict"]);
expect(await held(r, other)).toHaveLength(1);
expect(await held(r, s.to)).toHaveLength(3);
});
it("a replay whose proofs are another app's tokens, or another account's, is 409 move_id_conflict, not the stored receipt", async () => {
const r = await rig();
const s = await scene(r);
const body = { moveId: moveId("rp"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
expect((await move(r, body)).statusCode).toBe(200);
const foreign = await proof(r, s.from, { app: "streampixels", stepUp: passkey });
expect(err(await move(r, { ...body, fromProofs: [foreign.token] }))).toEqual([409, "move_id_conflict"]);
const foreignTo = await proof(r, s.to, { app: "streampixels" });
expect(err(await move(r, { ...body, toProof: foreignTo.token }))).toEqual([409, "move_id_conflict"]);
expect((await move(r, body)).statusCode, "the genuine proofs still replay").toBe(200);
});
it("an app without the secret is 401; streampixels with its own secret is 403 not_allowed_for_app", async () => {
const r = await rig();
const s = await scene(r);
const body = { moveId: moveId("d"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
expect(err(await move(r, body, {}))).toEqual([401, "app_auth_required"]);
expect(err(await move(r, body, { "x-heimdall-app-secret": "nope" }))).toEqual([401, "app_auth_required"]);
expect(err(await move(r, body, { "x-heimdall-app-secret": "streampixels-secret" }, "streampixels"))).toEqual([403, "not_allowed_for_app"]);
expect(await held(r, s.from)).toHaveLength(2);
});
it("only bifrost is granted moves, and its account proofs are methods it may assert", () => {
const granted = Object.values(builtInAppProfiles).filter((p) => p.identityMoves !== undefined);
expect(granted.map((p) => p.slug)).toEqual(["bifrost"]);
const bifrost = granted[0]!;
expect(bifrost.identityMoves!.accountProofMethods.every((m) => bifrost.stepUpAssertion!.methods.includes(m))).toBe(true);
expect(bifrost.identityMoves!.accountProofMethods).not.toContain("bifrost-email-code");
});
});
describe("what a proof must be", () => {
it("another app's token, a revoked session and a forged signature are 403 proof_invalid and nothing moves", async () => {
const r = await rig();
const s = await scene(r);
const foreign = await proof(r, s.from, { app: "streampixels", stepUp: passkey });
const revoked = await proof(r, s.from, { stepUp: passkey });
await r.store.revokeSession("bifrost", revoked.sid, s.from.account.id, 1, new Date().toISOString());
const forged = s.fromProof.token.slice(0, -6) + "AAAAAA";
for (const [fromProofs, toProof, who] of [
[[foreign.token], s.toProof.token, "from"],
[[revoked.token], s.toProof.token, "from"],
[[forged], s.toProof.token, "from"],
[[s.fromProof.token], forged, "to"],
] as const) {
const res = await move(r, { moveId: moveId("e"), fromProofs, toProof });
expect([res.statusCode, res.json()]).toEqual([403, { error: "proof_invalid", proof: who }]);
}
expect(await held(r, s.from)).toHaveLength(2);
});
it("a sign-in 300 s old passes and 301 s is proof_stale; a token with no sign-in claim (refresh, link) is proof_stale; a step-up inside 600 s reports strong", async () => {
const r = await rig();
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(now));
const to = await acct(r, [["patreon"]]);
const toProof = await proof(r, to);
const providers: Provider[] = ["discord", "google", "twitch", "github", "spotify", "youtube"];
const attempt = async (tag: string, o: ProofOptions) => {
const from = await acct(r, [[providers.shift()!]]);
const p = await proof(r, from, o);
return move(r, { moveId: moveId(tag), fromProofs: [p.token], toProof: toProof.token });
};
const edge = await attempt("edge", { signedInAgo: AUTH_PROOF_TTL_SECONDS });
expect([edge.statusCode, edge.json().proof]).toEqual([200, { from: "weak", to: "weak" }]);
const late = await attempt("late", { signedInAgo: AUTH_PROOF_TTL_SECONDS + 1 });
expect([late.statusCode, late.json()]).toEqual([403, { error: "proof_stale", proof: "from" }]);
const none = await attempt("none", { signedInAgo: null });
expect(err(none)).toEqual([403, "proof_stale"]);
const strong = await attempt("strong", { signedInAgo: 5_000, stepUp: { agoS: 600, method: "provider:twitch" } });
expect([strong.statusCode, strong.json().proof.from]).toEqual([200, "strong"]);
const stale = await attempt("stale", { signedInAgo: 5_000, stepUp: { agoS: 601, method: "bifrost-passkey" } });
expect(err(stale)).toEqual([403, "proof_stale"]);
// The destination's proof is held to the same rule.
const from = await acct(r, [["google"]]);
const fresh = await proof(r, from);
const staleTo = await proof(r, to, { signedInAgo: AUTH_PROOF_TTL_SECONDS + 1 });
const res = await move(r, { moveId: moveId("sto"), fromProofs: [fresh.token], toProof: staleTo.token });
expect([res.statusCode, res.json()]).toEqual([403, { error: "proof_stale", proof: "to" }]);
});
it("fromProofs naming two accounts is 403 proof_invalid; one account on both sides is 409 same_account", async () => {
const r = await rig();
const s = await scene(r);
const split = await move(r, { moveId: moveId("f"), fromProofs: [s.fromProof.token, s.toProof.token], toProof: s.toProof.token });
expect([split.statusCode, split.json()]).toEqual([403, { error: "proof_invalid", proof: "from" }]);
const twice = await proof(r, s.from, { stepUp: passkey });
const same = await move(r, { moveId: moveId("g"), fromProofs: [s.fromProof.token], toProof: twice.token });
expect(err(same)).toEqual([409, "same_account"]);
expect(await held(r, s.from)).toHaveLength(2);
});
it("the body is closed: a forward move needs toProof, a reversal takes no toProof or identityIds, ids are patterned", async () => {
const r = await rig();
const s = await scene(r);
const t = s.fromProof.token;
for (const body of [
{ moveId: moveId("h"), fromProofs: [t] },
{ moveId: "short", fromProofs: [t], toProof: t },
{ moveId: moveId("h"), fromProofs: [], toProof: t },
{ moveId: moveId("h"), fromProofs: [t], toProof: t, reversesMoveId: moveId("i") },
{ moveId: moveId("h"), fromProofs: [t], reversesMoveId: moveId("i"), identityIds: ["x"] },
]) {
expect((await move(r, body)).statusCode, JSON.stringify(Object.keys(body))).toBe(400);
}
});
});
describe("accounts come only from proofs", () => {
it("a stray accountId in the body steers nothing", async () => {
const r = await rig();
const s = await scene(r);
const stranger = await acct(r, [["twitch"]]);
const res = await move(r, { moveId: moveId("y"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, accountId: stranger.account.id, toAccountId: stranger.account.id });
expect([res.statusCode, res.json().accountId]).toEqual([200, s.to.account.id]);
expect(await held(r, stranger)).toHaveLength(1);
});
});
describe("which logins a proof moves", () => {
async function multi(r: Rig) {
const from = await acct(r, [["discord"], ["google"], ["twitch"]]);
const to = await acct(r, [["patreon"]]);
return { from, to, toProof: await proof(r, to) };
}
it("a sign-in through login X moves X and cannot move the source's other logins", async () => {
const r = await rig();
const s = await multi(r);
const [discord, google] = s.from.rows;
const viaDiscord = await proof(r, s.from, { via: 0 });
const all = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token });
expect(err(all)).toEqual([403, "identity_not_proved"]);
const other = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token, identityIds: [google!.id] });
expect(err(other)).toEqual([403, "identity_not_proved"]);
expect(await held(r, s.from)).toHaveLength(3);
const only = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token, identityIds: [discord!.id] });
expect([only.statusCode, only.json().movedIdentityIds]).toEqual([200, [discord!.id]]);
expect((await held(r, s.from)).sort()).toEqual([google!.id, s.from.rows[2]!.id].sort());
});
it("one proof per login moves them all; an asserted passkey covers the whole account; an email code covers only its own login", async () => {
const r = await rig();
let s = await multi(r);
const perLogin = await Promise.all(s.from.rows.map((_, via) => proof(r, s.from, { via })));
const res = await move(r, { moveId: moveId("k"), fromProofs: perLogin.map((p) => p.token), toProof: s.toProof.token });
expect([res.statusCode, res.json().movedIdentityIds]).toHaveLength(2);
expect(res.json().proof.from).toBe("weak");
expect(await held(r, s.from)).toEqual([]);
s = await multi(r);
const byPasskey = await proof(r, s.from, { stepUp: passkey });
expect((await move(r, { moveId: moveId("l"), fromProofs: [byPasskey.token], toProof: s.toProof.token })).statusCode).toBe(200);
s = await multi(r);
const byEmail = await proof(r, s.from, { stepUp: { method: "bifrost-email-code" } });
const refused = await move(r, { moveId: moveId("m"), fromProofs: [byEmail.token], toProof: s.toProof.token });
expect(err(refused)).toEqual([403, "identity_not_proved"]);
expect(await held(r, s.from)).toHaveLength(3);
});
it("a provider held on both sides is 409 provider_conflict naming only the provider, and nothing moves", async () => {
const r = await rig();
const from = await acct(r, [["discord"], ["google"]]);
const to = await acct(r, [["discord"]]);
const res = await move(r, { moveId: moveId("n"), fromProofs: [(await proof(r, from, { stepUp: passkey })).token], toProof: (await proof(r, to)).token });
expect([res.statusCode, res.json()]).toEqual([409, { error: "provider_conflict", provider: "discord" }]);
expect(res.body).not.toContain(from.account.id);
expect(await held(r, from)).toHaveLength(2);
expect(await held(r, to)).toHaveLength(1);
});
it("naming an identity the source does not hold is 404 identity_not_found", async () => {
const r = await rig();
const s = await scene(r);
const res = await move(r, { moveId: moveId("o"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: [s.to.rows[0]!.id] });
expect(err(res)).toEqual([404, "identity_not_found"]);
});
});
describe("what a move leaves behind", () => {
it("strips the moved credentials, expires the source's sessions, pending links and unredeemed completions, and leaves the destination live", async () => {
const r = await rig();
const s = await scene(r);
const linkInput = { appSlug: "bifrost" as const, provider: "discord" as const, providerUserId: "d-x", identity: { accountId: "x", provider: "discord" as const, providerUserId: "d-x", scopes: [], profileJson: {}, createdAt: now, updatedAt: now }, returnTo: "https://bifrost.gamecult.org/x", createdAt: now, expiresAt: "2099-01-01T00:00:00.000Z" };
const srcLink = await r.store.createPendingLink({ ...linkInput, accountId: s.from.account.id });
const dstLink = await r.store.createPendingLink({ ...linkInput, accountId: s.to.account.id });
const completion = (accountId: string) =>
r.store.createAuthCompletion({ appSlug: "bifrost", provider: "discord", mode: "sign_in", accountId, sessionId: "sess", returnTo: "https://bifrost.gamecult.org/x", createdAt: now, expiresAt: "2099-01-01T00:00:00.000Z", payloadJson: {} });
const srcCompletion = await completion(s.from.account.id);
const dstCompletion = await completion(s.to.account.id);
const res = await move(r, { moveId: moveId("p"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
expect(res.statusCode).toBe(200);
const movedIds = new Set(s.from.rows.map((row) => row.id));
for (const row of (await r.store.listStoredLinkedIdentitiesForAccount(s.to.account.id)).filter((row) => movedIds.has(row.id))) {
expect(row.accessTokenEncrypted).toBeUndefined();
expect(row.refreshTokenEncrypted).toBeUndefined();
}
const record = await r.store.findIdentityMove(moveId("p"));
expect(record?.moved.map((m) => m.credentialStripped)).toEqual([true, true]);
expect((await r.store.findSession("bifrost", s.fromProof.sid))!.accessRevision).toBe(2);
expect((await r.store.findSession("bifrost", s.toProof.sid))!.accessRevision).toBe(1);
expect(Date.parse((await r.store.findSession("bifrost", s.toProof.sid))!.expiresAt)).toBeGreaterThan(Date.now());
expect(await r.store.consumePendingLink("bifrost", srcLink.id, s.from.account.id, new Date().toISOString())).toBeNull();
expect(await r.store.consumePendingLink("bifrost", dstLink.id, s.to.account.id, new Date().toISOString())).not.toBeNull();
expect(await r.store.consumeAuthCompletion("bifrost", srcCompletion.code, new Date().toISOString())).toBeNull();
expect(await r.store.consumeAuthCompletion("bifrost", dstCompletion.code, new Date().toISOString())).not.toBeNull();
});
it("revokes the source's live grants for the providers it no longer holds, leaves the destination's receipts, and a replay heals a crash after commit", async () => {
const r = await rig();
const s = await scene(r);
const grant = (accountId: string, provider: Provider) =>
r.store.createConsentReceipt({ kind: "grant", accountId, appSlug: "bifrost", provider, createdAt: now, scopesAsked: ["identify"], scopesGranted: ["identify"], consentPageDigest: "d", consentText: "t" });
await grant(s.from.account.id, "discord");
await grant(s.to.account.id, "patreon");
const receiptsOf = async (a: Acct) => (await r.store.listConsentReceipts(a.account.id)).map((x) => `${x.kind}:${x.provider}`);
const body = { moveId: moveId("q"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
vi.spyOn(r.store, "createConsentReceipt").mockRejectedValueOnce(new Error("crash after commit"));
expect((await move(r, body)).statusCode).toBe(500);
expect(await held(r, s.from), "the move itself committed").toEqual([]);
expect(await receiptsOf(s.from)).toEqual(["grant:discord"]);
const healed = await move(r, body);
expect(healed.statusCode).toBe(200);
expect(await receiptsOf(s.from)).toEqual(["grant:discord", "revocation:discord"]);
expect(await receiptsOf(s.to)).toEqual(["grant:patreon"]);
expect((await move(r, body)).body, "and it is idempotent").toBe(healed.body);
expect(await receiptsOf(s.from)).toHaveLength(2);
});
it("the destination never loses an identity, whatever is refused", async () => {
const r = await rig();
const s = await scene(r);
const before = (await held(r, s.to)).length;
const refusals = [
{ moveId: moveId("r1"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: ["nope"] },
{ moveId: moveId("r2"), fromProofs: [s.fromProof.token], toProof: s.fromProof.token },
{ moveId: moveId("r3"), fromProofs: [s.toProof.token], toProof: s.toProof.token },
];
for (const body of refusals) {
await move(r, body);
expect((await held(r, s.to)).length).toBeGreaterThanOrEqual(before);
}
expect((await move(r, { moveId: moveId("r4"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200);
expect((await held(r, s.to)).length).toBeGreaterThanOrEqual(before);
});
it("a sign-in through a moved login lands on the destination, with no alias lookup", async () => {
const r = await rig("t-moved");
const from = await acct(r, [["twitch", "t-moved"]]);
const to = await acct(r, [["patreon"]]);
const mv = await move(r, { moveId: moveId("s"), fromProofs: [(await proof(r, from, { stepUp: passkey })).token], toProof: (await proof(r, to)).token });
expect(mv.statusCode).toBe(200);
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/signin/return" } });
const flow = await launchFlow(r.app, started.json());
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
expect(called.statusCode).toBe(201);
expect(called.json().account.id).toBe(to.account.id);
});
});
describe("aliases and reversal", () => {
it("an emptied source is an alias; moving into or out of it is 409 already_moved", async () => {
const r = await rig();
const s = await scene(r);
expect((await move(r, { moveId: moveId("t"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200);
// A session can outlive its logins only by being minted for an account that holds none, which is how a stale caller would look.
const aliasProof = await proof(r, { account: s.from.account, rows: [s.to.rows[0]!] }, { stepUp: passkey });
const third = await acct(r, [["github"]]);
const out = await move(r, { moveId: moveId("u"), fromProofs: [aliasProof.token], toProof: (await proof(r, third)).token });
expect(err(out)).toEqual([409, "already_moved"]);
const aliasTo = await proof(r, { account: s.from.account, rows: [s.to.rows[0]!] });
const into = await move(r, { moveId: moveId("u2"), fromProofs: [(await proof(r, third, { stepUp: passkey })).token], toProof: aliasTo.token });
expect(err(into)).toEqual([409, "already_moved"]);
expect(await held(r, third)).toHaveLength(1);
});
it("a reversal through a moved login moves exactly those logins back; a second reversal is not_reversible; credentials stay stripped", async () => {
const r = await rig();
const s = await scene(r);
const sourceIds = await held(r, s.from);
await move(r, { moveId: moveId("v"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
const [discord, google] = s.from.rows;
// The destination now holds the moved logins: a sign-in through one of them is a proof of the destination.
const viaMoved = await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via: 0 });
const own = await proof(r, s.to, { via: 0 });
const denied = await move(r, { moveId: moveId("w"), fromProofs: [own.token], reversesMoveId: moveId("v") });
expect(err(denied), "the destination's own login does not speak for the moved ones").toEqual([403, "identity_not_proved"]);
const viaBoth = [viaMoved, await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via: 1 })];
const back = await move(r, { moveId: moveId("w"), fromProofs: viaBoth.map((p) => p.token), reversesMoveId: moveId("v") });
expect(back.statusCode, back.body).toBe(200);
expect(back.json()).toEqual({ moveId: moveId("w"), accountId: s.from.account.id, movedIdentityIds: expect.arrayContaining(sourceIds), proof: { from: "weak", to: null }, reversesMoveId: moveId("v") });
expect(await held(r, s.from)).toEqual(sourceIds);
expect(await held(r, s.to)).toHaveLength(1);
expect((await r.store.listStoredLinkedIdentitiesForAccount(s.from.account.id)).every((i) => i.accessTokenEncrypted === undefined)).toBe(true);
const own2 = await proof(r, s.to, { via: 0 });
const again = await move(r, { moveId: moveId("w2"), fromProofs: [own2.token], reversesMoveId: moveId("v") });
expect(err(again)).toEqual([409, "not_reversible"]);
const reverseTheReversal = await move(r, { moveId: moveId("w3"), fromProofs: [(await proof(r, s.from)).token], reversesMoveId: moveId("w") });
expect(err(reverseTheReversal)).toEqual([409, "not_reversible"]);
const unknown = await move(r, { moveId: moveId("w4"), fromProofs: [(await proof(r, s.from)).token], reversesMoveId: moveId("nothere") });
expect(err(unknown)).toEqual([409, "not_reversible"]);
});
});
describe("proofTier", () => {
it("a proof dated in the future proves nothing, whichever claim carries it", () => {
const n = nowS();
expect(proofTier({ auth_at: n + 5 }, n)).toBeNull();
expect(proofTier({ step_up_at: n + 5 }, n)).toBeNull();
expect(proofTier({ step_up_at: n + 5, auth_at: n }, n), "a future step-up does not hide a valid sign-in").toBe("weak");
expect(proofTier({ step_up_at: n, auth_at: n - 9_999 }, n)).toBe("strong");
});
});
describe("the reversal's source must be the forward move's destination", () => {
it("is refused as not_reversible before the proofs are judged, even for a stale proof", async () => {
const r = await rig();
const s = await scene(r);
expect((await move(r, { moveId: moveId("fw"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200);
const third = await acct(r, [["github"]]);
const onward = await proof(r, s.to, { stepUp: passkey });
expect((await move(r, { moveId: moveId("on"), fromProofs: [onward.token], toProof: (await proof(r, third)).token })).statusCode).toBe(200);
const stale = await proof(r, third, { signedInAgo: 9_999 });
const res = await move(r, { moveId: moveId("rv"), fromProofs: [stale.token], reversesMoveId: moveId("fw") });
expect(err(res)).toEqual([409, "not_reversible"]);
});
});
describe("idempotency is by moveId", () => {
it("a replayed reversal under another reversesMoveId is 409 move_id_conflict, not the stored receipt", async () => {
const r = await rig();
const s = await scene(r);
await move(r, { moveId: moveId("fw2"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
const [discord, google] = s.from.rows;
const viaMoved = async (via: number) => (await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via })).token;
const body = { moveId: moveId("rv2"), fromProofs: [await viaMoved(0), await viaMoved(1)], reversesMoveId: moveId("fw2") };
const back = await move(r, body);
expect(back.statusCode, back.body).toBe(200);
expect((await move(r, body)).body, "the same body replays").toBe(back.body);
expect(err(await move(r, { ...body, reversesMoveId: moveId("other") }))).toEqual([409, "move_id_conflict"]);
expect(await held(r, s.from)).toHaveLength(2);
});
it("a replay with the same moveId and different identityIds returns the first receipt (steward ruling: the key is the moveId, a retry carries the same body)", async () => {
const r = await rig();
const from = await acct(r, [["discord"], ["google"]]);
const to = await acct(r, [["patreon"]]);
const fromProof = await proof(r, from, { stepUp: passkey });
const toProof = await proof(r, to);
const first = await move(r, { moveId: moveId("ids"), fromProofs: [fromProof.token], toProof: toProof.token, identityIds: [from.rows[0]!.id] });
expect(first.json().movedIdentityIds).toEqual([from.rows[0]!.id]);
const again = await move(r, { moveId: moveId("ids"), fromProofs: [fromProof.token], toProof: toProof.token, identityIds: [from.rows[1]!.id] });
expect([again.statusCode, again.body]).toEqual([200, first.body]);
expect(await held(r, from), "and the second selection did not move").toEqual([from.rows[1]!.id]);
});
});
describe("what a refusal says", () => {
it("no refusal body names an account id, an email, a name or an identity the caller did not send", async () => {
const r = await rig();
const s = await scene(r);
const stranger = await acct(r, [["discord", "d-stranger"]]);
const bodies = [
await move(r, { moveId: moveId("x1"), fromProofs: [s.fromProof.token], toProof: "garbage" }),
await move(r, { moveId: moveId("x2"), fromProofs: [(await proof(r, s.from, { via: 0 })).token], toProof: s.toProof.token }),
await move(r, { moveId: moveId("x3"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: [stranger.rows[0]!.id] }),
await move(r, { moveId: moveId("x4"), fromProofs: [s.toProof.token], toProof: s.toProof.token }),
await move(r, { moveId: moveId("x5"), fromProofs: [s.fromProof.token], reversesMoveId: moveId("nothere") }),
await move(r, { moveId: moveId("x6"), fromProofs: [(await proof(r, s.from, { signedInAgo: 9_999 })).token], toProof: s.toProof.token }),
];
const ids = [s.from, s.to, stranger].flatMap((a) => [a.account.id, ...a.rows.map((row) => row.id), ...a.rows.map((row) => row.providerUserId)]);
for (const res of bodies) {
expect(res.statusCode).toBeGreaterThanOrEqual(400);
for (const id of ids) expect(res.body).not.toContain(id);
expect(res.body).not.toMatch(/canary|DISPLAY/);
expect(Object.keys(res.json()).every((k) => ["error", "detail", "proof", "provider"].includes(k))).toBe(true);
}
});
});
describe("claims: which sign-in a token came from", () => {
const returnTo = "https://streampixels.gamecult.org/signin/return";
async function twitchSignIn(r: Rig) {
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", payload: { appSlug: "streampixels", mode: "sign_in", returnTo } });
const flow = await launchFlow(r.app, started.json());
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
expect(called.statusCode).toBe(201);
const redeemed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", payload: { completionCode: called.json().completion.code } });
expect(redeemed.statusCode).toBe(201);
return redeemed.json() as { refreshToken: string; claimSet: Record<string, unknown> };
}
it("a sign_in stamps auth_at and the login it came through; a refresh does not renew them", async () => {
const r = await rig("t-claims");
const before = nowS();
const signed = await twitchSignIn(r);
const row = await r.store.findStoredLinkedIdentity("twitch", "t-claims");
expect(signed.claimSet.auth_identity_id).toBe(row!.id);
expect(signed.claimSet.auth_at).toBeGreaterThanOrEqual(before);
expect(signed.claimSet.auth_at).toBeLessThanOrEqual(nowS());
expect(signed.claimSet.step_up_at).toBeUndefined();
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: signed.refreshToken } });
expect(refreshed.statusCode).toBe(201);
expect(refreshed.json().claimSet.auth_at).toBeUndefined();
expect(refreshed.json().claimSet.auth_identity_id).toBeUndefined();
});
it("a link callback mints no sign-in proof: no auth_at, no auth_identity_id", async () => {
const r = await rig();
const holder = await acct(r, [["twitch"]]);
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers: appAuth, payload: { appSlug: "bifrost", mode: "link", initiatingAccountId: holder.account.id, returnTo: "https://bifrost.gamecult.org/x" } });
expect(started.statusCode).toBe(201);
const flow = await launchFlow(r.app, started.json());
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
expect(called.statusCode).toBe(201);
expect(called.json().claimSet.identities).toHaveLength(2);
expect(called.json().claimSet.auth_at).toBeUndefined();
expect(called.json().claimSet.auth_identity_id).toBeUndefined();
expect(called.body).not.toMatch(/auth_at|auth_identity_id/);
});
it("a connect callback mints no sign-in proof either", async () => {
const r = await rig("t-connect");
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", headers: { "x-heimdall-app-secret": "streampixels-secret" }, payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/signin/return", connection: { kind: "creator", targetId: "creator-1" } } });
expect(started.statusCode, started.body).toBe(201);
const flow = await launchFlow(r.app, started.json());
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
expect(called.statusCode, called.body).toBe(201);
expect(called.body).toContain("claimSet");
expect(called.body).not.toMatch(/auth_at|auth_identity_id/);
});
it("an asserted step-up carries the bearer's auth claims through unchanged", async () => {
const r = await rig();
const a = await acct(r, [["discord"], ["google"]]);
const p = await proof(r, a, { via: 1, signedInAgo: 30 });
const res = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/step-up", headers: { ...appAuth, authorization: `Bearer ${p.token}` }, payload: { method: "bifrost-passkey", nonce: "n".repeat(32) } });
expect(res.statusCode).toBe(201);
expect(res.json().claimSet).toMatchObject({ auth_identity_id: a.rows[1]!.id, auth_at: nowS() - 30, step_up_method: "bifrost-passkey" });
});
});

View file

@ -249,10 +249,6 @@ describe.each(unlinkStores)("linked_identities writes, %s", (_name, make) => {
expect(await store.listStoredLinkedIdentitiesForAccount(b.id)).toEqual([]);
});
it.skipIf(realPostgresOnly)("moveIdentities keeps one contract: moved, replayed, conflict, lapsed proof, coverage, reversal", async () => {
await exerciseMoveContract(await make());
});
it("upsert by the holder still refreshes its own row, keeping its id", async () => {
const { store, a } = await twoAccounts();
const first = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "one" }));
@ -481,10 +477,9 @@ describe("PostgresStore.checkSchema", () => {
"capability_grants",
"entitlement_snapshots",
"consent_receipts",
"identity_moves",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(121);
expect(REQUIRED_COLUMNS).toHaveLength(113);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -495,7 +490,7 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(19);
expect(REQUIRED_KEYS).toHaveLength(17);
expect(REQUIRED_KEYS).toEqual(
expect.arrayContaining([
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
@ -503,7 +498,6 @@ describe("PostgresStore.checkSchema", () => {
{ table: "oauth_state_uses", columns: "jti,phase", predicate: "" },
{ table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" },
{ table: "consent_receipts", columns: "account_id,id", predicate: "" },
{ table: "identity_moves", columns: "reverses_move_id", predicate: "reverses_move_id IS NOT NULL" },
{ table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" },
{ table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
])
@ -654,178 +648,9 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
expect(second.receipt.id).toBe(first.receipt.id);
expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]);
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
const target = await s.createAccount({ createdAt: now, lastSeenAt: now });
await s.upsertLinkedIdentity({ ...identity, accountId: target.id, provider: "patreon", providerUserId: "u-target" });
const moveId = `move-${randomUUID()}`;
expect((await s.moveIdentities({ moveId, appSlug: "bifrost", fromAccountId: account.id, toAccountId: target.id, identityIds: "all", covers: "account", proofSessions: [], proof: { from: [], to: null }, at: now })).kind).toBe("moved");
expect((await s.findIdentityMove(moveId))?.id).toBe(moveId);
return called;
}
/**
* The move contract both stores keep, once. Each case builds its own accounts,
* so the order of cases proves nothing and a store holding earlier moves is fine.
*/
async function exerciseMoveContract(store: HeimdallStore): Promise<void> {
const at = "2026-11-14T12:00:00.000Z";
const later = "2999-01-01T00:00:00.000Z";
const proofTiers = { from: [{ tier: "strong" as const, method: "bifrost-passkey", identityId: null }], to: { tier: "weak" as const, method: null, identityId: null } };
const side = async (providers: Array<"discord" | "google" | "patreon">) => {
const account = await store.createAccount({ createdAt: at, lastSeenAt: at });
const rows = [];
for (const provider of providers) {
rows.push(await store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId: `${provider}-${randomUUID()}`, accessTokenEncrypted: "A", refreshTokenEncrypted: "R", scopes: [], profileJson: {}, createdAt: at, updatedAt: at }));
}
const sessionId = randomUUID();
await store.createSession({ id: sessionId, accountId: account.id, appSlug: "bifrost", createdAt: at, lastSeenAt: at, expiresAt: later, accessRevision: 1, claimsJson: {} });
return { account, rows, proofSession: { appSlug: "bifrost" as const, sessionId, accountId: account.id, accessRevision: 1 } };
};
const input = (from: Awaited<ReturnType<typeof side>>, to: Awaited<ReturnType<typeof side>>, extra: Record<string, unknown> = {}) => ({
moveId: `move-${randomUUID()}`,
appSlug: "bifrost" as const,
fromAccountId: from.account.id,
toAccountId: to.account.id,
identityIds: "all" as const,
covers: "account" as const,
proofSessions: [from.proofSession, to.proofSession],
proof: proofTiers,
at,
...extra,
});
const held = async (a: { account: { id: string } }) => (await store.listStoredLinkedIdentitiesForAccount(a.account.id)).map((i) => i.id).sort();
// Moved: rows, credentials, sessions and the record.
const a = await side(["discord", "google"]);
const b = await side(["patreon"]);
const first = input(a, b);
const moved = await store.moveIdentities(first);
expect(moved.kind).toBe("moved");
expect(await held(a)).toEqual([]);
expect(await held(b)).toEqual([...a.rows.map((r) => r.id), ...b.rows.map((r) => r.id)].sort());
for (const row of await store.listStoredLinkedIdentitiesForAccount(b.account.id)) {
if (a.rows.some((r) => r.id === row.id)) expect([row.accessTokenEncrypted, row.refreshTokenEncrypted]).toEqual([undefined, undefined]);
else expect(row.accessTokenEncrypted, "the destination's own credential is untouched").toBe("A");
}
const record = await store.findIdentityMove(first.moveId);
expect(record).toMatchObject({ id: first.moveId, fromAccountId: a.account.id, toAccountId: b.account.id, proof: proofTiers, createdAt: at });
expect(record!.moved.map((m) => m.credentialStripped)).toEqual([true, true]);
expect((await store.findSession("bifrost", a.proofSession.sessionId))!.accessRevision).toBe(2);
expect((await store.findSession("bifrost", b.proofSession.sessionId))!.accessRevision).toBe(1);
// Replay answers from the row, even though the proof session is gone; a different pair is a conflict.
expect(await store.moveIdentities(first)).toEqual({ kind: "replayed", move: record });
const c = await side(["discord"]);
expect(await store.moveIdentities({ ...first, toAccountId: c.account.id })).toEqual({ kind: "move_id_conflict" });
expect(await store.moveIdentities({ ...first, reversesMoveId: "x" })).toEqual({ kind: "move_id_conflict" });
// Into or out of an alias.
expect((await store.moveIdentities(input(a, c, { proofSessions: [c.proofSession] }))).kind).toBe("already_moved");
expect((await store.moveIdentities(input(c, a, { proofSessions: [c.proofSession] }))).kind).toBe("already_moved");
// A proof session that was revoked, or has moved on a revision, has lapsed.
const d = await side(["google"]);
const e = await side(["patreon"]);
await store.revokeSession("bifrost", e.proofSession.sessionId, e.account.id, 1, at);
expect((await store.moveIdentities(input(d, e))).kind).toBe("proof_lapsed");
expect(await held(d)).toHaveLength(1);
// Coverage and conflict.
const f = await side(["discord", "google"]);
const g = await side(["patreon"]);
expect((await store.moveIdentities(input(f, g, { covers: [f.rows[0]!.id] }))).kind).toBe("identity_not_proved");
expect((await store.moveIdentities(input(f, g, { identityIds: ["nope"] }))).kind).toBe("identity_not_found");
const h = await side(["discord"]);
expect(await store.moveIdentities(input(f, h))).toEqual({ kind: "provider_conflict", provider: "discord" });
expect(await held(f)).toHaveLength(2);
const onlyDiscord = await store.moveIdentities(input(f, g, { identityIds: [f.rows[0]!.id], covers: [f.rows[0]!.id] }));
expect(onlyDiscord.kind).toBe("moved");
expect(await held(f)).toEqual([f.rows[1]!.id]);
// Reversal: moves exactly what the forward carried, once, and a reversal is not itself reversible.
const forward = (moved as { move: { id: string } }).move;
const back = input(b, a, { reversesMoveId: forward.id, proofSessions: [], identityIds: a.rows.map((r) => r.id), covers: a.rows.map((r) => r.id) });
const reversed = await store.moveIdentities(back);
expect(reversed.kind).toBe("moved");
expect(await held(a)).toEqual(a.rows.map((r) => r.id).sort());
expect(await held(b)).toEqual(b.rows.map((r) => r.id));
expect((await store.moveIdentities({ ...back, moveId: `move-${randomUUID()}` })).kind).toBe("not_reversible");
expect((await store.moveIdentities(input(a, b, { reversesMoveId: back.moveId, proofSessions: [], identityIds: "all" }))).kind).toBe("not_reversible");
// A moveId answers only the move that used it: each of app, source, destination and reversal must match.
const k = await side(["discord"]);
const l = await side(["patreon"]);
const m = await side(["google"]);
const once = input(k, l);
expect((await store.moveIdentities(once)).kind).toBe("moved");
expect((await store.moveIdentities({ ...once, fromAccountId: m.account.id })).kind).toBe("move_id_conflict");
expect((await store.moveIdentities({ ...once, toAccountId: m.account.id })).kind).toBe("move_id_conflict");
expect((await store.moveIdentities({ ...once, appSlug: "streampixels" })).kind).toBe("move_id_conflict");
expect((await store.moveIdentities(once)).kind).toBe("replayed");
// Every step of a move is on Postgres too: expiry of the source's pending links and unredeemed completions, and the stripped credential's expiry and failure mark.
const n = await side(["discord", "google"]);
const o = await side(["patreon"]);
await store.upsertLinkedIdentity({ accountId: n.account.id, provider: "discord", providerUserId: (await store.listStoredLinkedIdentitiesForAccount(n.account.id))[0]!.providerUserId, tokenExpiresAt: "2999-01-01T00:00:00.000Z", scopes: [], profileJson: {}, createdAt: at, updatedAt: at });
await store.updateLinkedIdentityCredential(n.rows[1]!.id, async () => ({ kind: "failed", failedAt: at }));
const linkOf = (accountId: string) => store.createPendingLink({ appSlug: "bifrost", accountId, provider: "discord", providerUserId: "p", identity: { accountId, provider: "discord", providerUserId: "p", scopes: [], profileJson: {}, createdAt: at, updatedAt: at }, returnTo: "https://x.test/", createdAt: at, expiresAt: later });
const completionOf = (accountId: string) => store.createAuthCompletion({ appSlug: "bifrost", provider: "discord", mode: "sign_in", accountId, sessionId: n.proofSession.sessionId, returnTo: "https://x.test/", createdAt: at, expiresAt: later, payloadJson: {} });
const sourceLink = await linkOf(n.account.id);
const destLink = await linkOf(o.account.id);
const sourceCompletion = await completionOf(n.account.id);
const destCompletion = await completionOf(o.account.id);
expect((await store.moveIdentities(input(n, o))).kind).toBe("moved");
const after = "2026-11-14T12:00:01.000Z";
expect(await store.consumePendingLink("bifrost", sourceLink.id, n.account.id, after)).toBeNull();
expect(await store.consumeAuthCompletion("bifrost", sourceCompletion.code, after)).toBeNull();
expect((await store.consumePendingLink("bifrost", destLink.id, o.account.id, after))?.id).toBe(destLink.id);
expect((await store.consumeAuthCompletion("bifrost", destCompletion.code, after))?.code).toBe(destCompletion.code);
for (const row of (await store.listStoredLinkedIdentitiesForAccount(o.account.id)).filter((r) => n.rows.some((x) => x.id === r.id))) {
expect([row.accessTokenEncrypted, row.refreshTokenEncrypted, row.tokenExpiresAt, row.refreshFailedAt], row.provider).toEqual([undefined, undefined, undefined, undefined]);
}
// Each reversal guard on its own: the refusal is not_reversible, never another code, and nothing moves.
const fresh = async () => ({ p: await side(["discord", "google"]), q: await side(["patreon"]), t: await side(["patreon"]) });
const reverse = (forwardId: string, from: { account: { id: string } }, to: { account: { id: string } }, ids: readonly string[]) => ({
moveId: `move-${randomUUID()}`, appSlug: "bifrost" as const, fromAccountId: from.account.id, toAccountId: to.account.id,
identityIds: ids, covers: ids, proofSessions: [], reversesMoveId: forwardId, proof: { from: [], to: null }, at,
});
// (a) already reversed, even though the logins are back on the destination by a later forward move
{
const { p, q } = await fresh();
const ids = p.rows.map((r) => r.id);
const m1 = input(p, q);
expect((await store.moveIdentities(m1)).kind).toBe("moved");
expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind).toBe("moved");
expect((await store.moveIdentities(input(p, q, { proofSessions: [] }))).kind).toBe("moved");
expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind, "reversed once").toBe("not_reversible");
expect(await held(p)).toEqual([]);
}
// (d) the logins are still on the forward's destination but the reversal targets another account
{
const { p, q } = await fresh();
const ids = p.rows.map((r) => r.id);
const m1 = input(p, q);
expect((await store.moveIdentities(m1)).kind).toBe("moved");
const elsewhere = await side(["patreon"]);
expect((await store.moveIdentities(reverse(m1.moveId, q, elsewhere, ids))).kind, "to is not the forward's source").toBe("not_reversible");
expect(await held(elsewhere)).toHaveLength(1);
}
// (b) the logins moved on from the destination: not held, so not_reversible (not identity_not_found)
// (c) the reversal's source is not the forward's destination, or its target is not the forward's source
{
const { p, q, t } = await fresh();
const ids = p.rows.map((r) => r.id);
const m1 = input(p, q);
expect((await store.moveIdentities(m1)).kind).toBe("moved");
expect((await store.moveIdentities(input(q, t, { identityIds: ids, covers: ids, proofSessions: [] }))).kind).toBe("moved");
expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind, "ids no longer held").toBe("not_reversible");
expect((await store.moveIdentities(reverse(m1.moveId, t, p, ids))).kind, "from is not the forward's destination").toBe("not_reversible");
const u = await side(["google"]);
expect((await store.moveIdentities(reverse(m1.moveId, t, u, ids))).kind, "to is not the forward's source").toBe("not_reversible");
expect(await held(t)).toEqual([...ids, ...t.rows.map((r) => r.id)].sort());
}
}
// Against a real Postgres, through the startup path itself: createStore, with
// and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role
// that may create databases and roles. Each case clones a template database
@ -978,103 +803,6 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
});
it("startup refuses a role that lacks INSERT or SELECT on identity_moves", async () => {
expect(STORE_TABLE_PRIVILEGES.identity_moves).toEqual(["SELECT", "INSERT"]);
for (const [kept, dropped] of [[["SELECT"], "INSERT"], [["INSERT"], "SELECT"]] as const) {
const { name, owner } = await appliedDatabase();
await grant(owner, { ...STORE_TABLE_PRIVILEGES, identity_moves: kept });
expect(await start(urlFor(name, role, rolePassword), false), `without ${dropped}`).toBe(refusal("SCHEMA_PRIVILEGES"));
}
});
it("on real Postgres: the move contract holds", async () => {
const { owner } = await appliedDatabase();
await exerciseMoveContract(new PostgresStore(owner));
});
describe("concurrent moves", () => {
const at = "2026-11-14T12:00:00.000Z";
async function pair(store: PostgresStore) {
const make = async (provider: "discord" | "patreon") => {
const account = await store.createAccount({ createdAt: at, lastSeenAt: at });
await store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId: `${provider}-${randomUUID()}`, scopes: [], profileJson: {}, createdAt: at, updatedAt: at });
const sessionId = randomUUID();
await store.createSession({ id: sessionId, accountId: account.id, appSlug: "bifrost", createdAt: at, lastSeenAt: at, expiresAt: "2999-01-01T00:00:00.000Z", accessRevision: 1, claimsJson: {} });
return { id: account.id, proof: { appSlug: "bifrost" as const, sessionId, accountId: account.id, accessRevision: 1 } };
};
return { x: await make("discord"), y: await make("patreon") };
}
const request = (from: { id: string; proof: object }, to: { id: string; proof: object }, moveId: string) => ({
moveId,
appSlug: "bifrost" as const,
fromAccountId: from.id,
toAccountId: to.id,
identityIds: "all" as const,
covers: "account" as const,
proofSessions: [from.proof, to.proof] as never,
proof: { from: [], to: null },
at,
});
it("opposite moves A->B and B->A over two pools leave one applied and never deadlock, ten rounds", async () => {
const { name, owner } = await appliedDatabase();
const storeA = new PostgresStore(owner);
const other = fixturePool(urlFor(name));
cleanup.push(async () => {
await other.end();
});
const storeB = new PostgresStore(other);
for (let round = 0; round < 10; round += 1) {
const { x, y } = await pair(storeA);
const results = await Promise.all([
storeA.moveIdentities(request(x, y, `move-${randomUUID()}`)),
storeB.moveIdentities(request(y, x, `move-${randomUUID()}`)),
]);
const kinds = results.map((r) => r.kind).sort();
expect(kinds[0], `round ${round}: ${kinds}`).toBe("moved");
expect(["already_moved", "proof_lapsed"], `round ${round}: ${kinds}`).toContain(kinds[1]);
const held = (await storeA.listStoredLinkedIdentitiesForAccount(x.id)).length + (await storeA.listStoredLinkedIdentitiesForAccount(y.id)).length;
expect(held, "no identity is lost or duplicated").toBe(2);
}
});
it("one moveId over two different pairs at once: one moved, the other move_id_conflict, never a receipt for a move it did not make", async () => {
const { name, owner } = await appliedDatabase();
const storeA = new PostgresStore(owner);
const other = fixturePool(urlFor(name));
cleanup.push(async () => {
await other.end();
});
const storeB = new PostgresStore(other);
for (let round = 0; round < 6; round += 1) {
const one = await pair(storeA);
const two = await pair(storeA);
const id = `move-${randomUUID()}`;
const results = await Promise.all([storeA.moveIdentities(request(one.x, one.y, id)), storeB.moveIdentities(request(two.x, two.y, id))]);
expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["move_id_conflict", "moved"]);
const winner = (await storeA.findIdentityMove(id))!;
const moved = results.find((r) => r.kind === "moved") as { move: { fromAccountId: string } };
expect(winner.fromAccountId).toBe(moved.move.fromAccountId);
}
});
it("two concurrent identical moveIds: one moved, one replayed", async () => {
const { name, owner } = await appliedDatabase();
const storeA = new PostgresStore(owner);
const other = fixturePool(urlFor(name));
cleanup.push(async () => {
await other.end();
});
const storeB = new PostgresStore(other);
for (let round = 0; round < 5; round += 1) {
const { x, y } = await pair(storeA);
const body = request(x, y, `move-${randomUUID()}`);
const results = await Promise.all([storeA.moveIdentities(body), storeB.moveIdentities(body)]);
expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["moved", "replayed"]);
}
});
});
it("on real Postgres: an upsert never moves an identity, and concurrent unlinks of the last two leave one", async () => {
const { name, owner } = await appliedDatabase();
const clientA = new PostgresStore(owner);
@ -1192,7 +920,6 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
const appliedByIndex = {
auth_completions_attempt_unconsumed_unique_idx: "auth_completions",
consent_receipts_revokes_unique_idx: "consent_receipts",
identity_moves_reverses_unique_idx: "identity_moves",
};
const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }]));
expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort());