refreshLinkedIdentityCredentialIfNeeded is deleted. refreshManagedCredential
owns the policy; HeimdallStore.updateLinkedIdentityCredential owns the row
(SELECT ... FOR UPDATE in Postgres, a per-id chain in memory), so a refresh
token is presented once across concurrent requests and processes. invalid_grant
clears the tokens (410 managed_credential_revoked); any other failure keeps
them (502 managed_credential_refresh_failed). The "?? refreshToken" keep-old
fallback is gone; keeping it is an explicit branch for non-rotating providers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 7's Y2, treating only single quotes as quoted text, survived:
no test gave the splitter a double-quoted identifier. One case now holds
an identifier containing " AND (" and checks it stays whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mutations dropping indisvalid or indisready each survived alone,
because a failed CREATE INDEX CONCURRENTLY clears both. Postgres never
marks an index valid before it is ready (DROP INDEX CONCURRENTLY clears
validity first), so indisready adds nothing and is deleted. A fixture now
holds an index that is ready but not valid, the state an interrupted DROP
INDEX CONCURRENTLY leaves, set directly in pg_index by the admin role.
Comparing the raw declared predicate instead of Postgres's printing of it
survived too: the schema's one predicate splits into the same conditions
either way. A live test now pins printedDeclaredPredicates to the printed
predicate of the applied attempt index, so the printing itself is
checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The startup check's refusal left the store's pool open: the process exits
on it in production, but the connection outlived the refusal. createStore
now closes a store it refuses.
Two live cases the first run showed undefended (mutants D6 and D9): a
unique key replaced by a plain index on the same columns, and by a unique
index on other columns of the same count. The read-only-role case also
checks that no session of the role outlives the refusal. The fixture's
owner pool ignores the connection ends its own forced drop causes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's ON CONFLICT clauses and the foreign keys need a unique,
non-partial index on exactly the key's columns; whether it is the primary
key changes nothing a request does. Requiring the primary flag added a
refusal no failing request motivated, so it is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self's ruling on the open apply=0 item: connecting proves the URL, not the
schema, so a good URL to an empty database started and then answered every
request with a 500 (42P01).
PostgresStore.checkSchema replaces checkConnection. One query finds every
table and index CREATE_SCHEMA_SQL creates and every column it adds, and
anything missing fails with SCHEMA_MISSING, which startup reports through
the same fixed message. The required names are read from CREATE_SCHEMA_SQL
itself, so a new table or migration is required as soon as it is written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 3 (R2): the pool had no 'error' listener. pg emits an idle
client's connection loss there (a database restart, a terminated backend),
so the event went unheard, the process died, and Node printed the pg Client
with its connection parameters.
createPostgresStore now listens and writes one line naming the error code.
The pool has already dropped the dead client and connects again on the
next query.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>