Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
336d0c6
test(serve): a branch bound key never acts outside its branch, red first
futurebuildai Oct 10, 2026
e141cdc
fix(middleware): the key branch wall holds a bound key to its pin eve…
futurebuildai Oct 10, 2026
0164f77
docs: the branch bound key's pin holds on every route, recorded
futurebuildai Oct 10, 2026
7ef2d1d
test(drafts): the kill switch flip rides the outbox lock
futurebuildai Oct 10, 2026
bcb72ad
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 10, 2026
502ac8e
test(serve): a grant body with trailing data must not reach the handl…
futurebuildai Oct 10, 2026
af9b3f7
fix(middleware): the body branch wall reads the whole body or refuses it
futurebuildai Oct 10, 2026
afb0646
test(serve): the dealer wide surface refuses a bound key, red first
futurebuildai Oct 10, 2026
2fcf981
fix(serve): the dealer wide surface refuses a branch bound key
futurebuildai Oct 10, 2026
0d6ea2d
test(serve): customer scoped writes confine a bound key, red first
futurebuildai Oct 10, 2026
1d13026
fix(middleware,serve): customer scoped writes confine a bound key to …
futurebuildai Oct 10, 2026
63b0168
test(middleware): the key branch wall's own unit tests, fail closed i…
futurebuildai Oct 10, 2026
100bcd8
fix(middleware): a row that names no branch is outside every pin
futurebuildai Oct 10, 2026
ee49d13
docs: the branch bound key's pin as the reviews and the lead ruled it
futurebuildai Oct 10, 2026
03b1ad4
test(serve): the deny by default round's refusals and confinements, r…
futurebuildai Oct 10, 2026
6a2428a
feat(middleware): the branch bound key route allowlist, deny by default
futurebuildai Oct 10, 2026
5332367
feat(serve): the customer wall completed: jobs, reads and tier targets
futurebuildai Oct 10, 2026
f7a04ce
test: the wall's fail closed arms and serve's confinement call sites
futurebuildai Oct 10, 2026
cfc268a
docs: deny by default, the allowlist record
futurebuildai Oct 10, 2026
5b20837
style: gofmt the allowlist table
futurebuildai Oct 10, 2026
c8aaeb4
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 10, 2026
a4eebd6
security: a category rule element that targets a tier never passes th…
futurebuildai Oct 10, 2026
38948f2
security: the pos and account row routes hold a bound key to its branch
futurebuildai Oct 10, 2026
a891702
security: the price calculation resolves job_id through the job's cus…
futurebuildai Oct 10, 2026
1cf0c93
security: a tax preview naming a ship to is confined to that ship to'…
futurebuildai Oct 10, 2026
c862197
test: the gate's nil resolver arm, the unnamed refusals and the locat…
futurebuildai Oct 10, 2026
c0264fe
security: a branch bound key reads dealer wide reference data and nev…
futurebuildai Oct 10, 2026
e6a4dff
security: the WALLED class names its filter column and holds it under…
futurebuildai Oct 10, 2026
2ab3111
security: the principal's PD-4, PD-5 and PD-6 rulings on shared custo…
futurebuildai Oct 10, 2026
e7c0437
docs: ADR 0007 5.5, CONTRACT-CHANGES and the module pages say what th…
futurebuildai Oct 10, 2026
2e3488e
ci: request the hosted checks on this head
futurebuildai Oct 10, 2026
a6adc88
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 10, 2026
0213a78
merge origin/refactor/v1: the AP wire contract rename and the census …
futurebuildai Oct 10, 2026
372b37a
Merge origin/refactor/v1 into refactor/sec-bound-key-pin
futurebuildai Oct 10, 2026
a8f3795
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 10, 2026
de06769
security: hold the customer a body names to the key's pin on every cr…
futurebuildai Oct 10, 2026
6d7001c
security: an order refuses a ship to or contact its customer does not…
futurebuildai Oct 10, 2026
e3a48b3
security: a legacy tier rule that names a customer stays dealer wide
futurebuildai Oct 10, 2026
12b5fef
security: a key bound to one branch changes no restricted field of a …
futurebuildai Oct 10, 2026
382c1f7
test: rebuild the walled twin sweep on valid bodies and the twins' ow…
futurebuildai Oct 11, 2026
e8ca16d
test: close the round 7 test strength gaps and audit the AI upload first
futurebuildai Oct 11, 2026
6753d25
test: the refusal names the lowest numbered other branch, never the pin
futurebuildai Oct 11, 2026
7116cc5
docs: state the round 4 rules in ADR 0007 section 5.5, the contract c…
futurebuildai Oct 11, 2026
4bad569
Merge origin/refactor/v1 into refactor/sec-bound-key-pin
futurebuildai Oct 11, 2026
c05cf0c
security(keywall): the pos sync holds its replayed sales' customers t…
futurebuildai Oct 11, 2026
e853ae3
security(keywall): a document refuses a job its customer does not own
futurebuildai Oct 11, 2026
071448d
security(keywall): the quote convert rechecks the quote's customer
futurebuildai Oct 11, 2026
04258cc
security(customer): a bound key may not add a default ship to on a sh…
futurebuildai Oct 11, 2026
0771eea
security(crm): an activity refuses a contact its customer does not own
futurebuildai Oct 11, 2026
1a168f0
security(keys): customers:restricted, the shared customer restricted …
futurebuildai Oct 11, 2026
c726f95
fix(pricing): a legacy single target rule update answers the invarian…
futurebuildai Oct 11, 2026
5d800ed
fix(payments): the intent route answers its own 400 for a body naming…
futurebuildai Oct 11, 2026
224e302
tests(customer): round 10's test gaps, and the escalation audit's sig…
futurebuildai Oct 11, 2026
9b10422
docs: round 5 of the branch bound key pin in the ADRs, contract and m…
futurebuildai Oct 11, 2026
88ca773
style: gofmt the round 5 test files
futurebuildai Oct 11, 2026
30d6412
ci: retrigger the workflow for the round 5 head
futurebuildai Oct 11, 2026
c1d831d
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 11, 2026
fcdb420
Merge remote-tracking branch 'origin/refactor/v1' into lead/pr83-v1
futurebuildai Oct 11, 2026
544a722
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 11, 2026
dd7275c
security: a register's branch falls back to pos_registers.branch_id (…
futurebuildai Oct 11, 2026
7f7ec90
security: shared customer caller refusals write a row and use the bou…
futurebuildai Oct 11, 2026
dc8947f
test: the stored job recheck, the claims-less mint refusal and a trul…
futurebuildai Oct 11, 2026
40aadbd
security: finance reaches only the restricted customer field routes (…
futurebuildai Oct 11, 2026
8f06f71
security: activities follow the contact rule on a shared customer (PR…
futurebuildai Oct 11, 2026
8ce0fc2
docs: ADR 0007 section 5.5, the allowlist notes, SEC-BOUND-KEY-PIN-6 …
futurebuildai Oct 11, 2026
a4f5bfd
test: re-record the pos goldens for the till session's branch; seed R…
futurebuildai Oct 11, 2026
6898a53
Merge remote-tracking branch 'origin/refactor/v1' into lead/pr83-v1
futurebuildai Oct 11, 2026
7a0cc54
Merge remote-tracking branch 'origin/refactor/v1' into lead/pr83-v1
futurebuildai Oct 11, 2026
a62f563
pos: migration 108 backfills the branch on legacy till, Z report and …
futurebuildai Oct 11, 2026
0c7e145
pos: a user's register branch refusal on the sale answers 403, not 50…
futurebuildai Oct 11, 2026
9c84e54
pos: till open and a return hold the caller's branch against the regi…
futurebuildai Oct 11, 2026
8cdc752
pos: the offline sync refuses a batch register of another branch befo…
futurebuildai Oct 11, 2026
bccdf4a
customers: finance may read the customer and its escalation policy (i…
futurebuildai Oct 11, 2026
5dfd25c
customers: the shared refusals name their caller behind one shared cl…
futurebuildai Oct 11, 2026
be6962f
seed: the REG-01 upsert is limited to the seed's own row and the fiel…
futurebuildai Oct 11, 2026
d40efd1
docs: the round 7 rules in CONTRACT-CHANGES, ADR 0007's module pages
futurebuildai Oct 11, 2026
ac00ab9
seed: the REG-01 upsert moves only the row still on 027's placeholder
futurebuildai Oct 11, 2026
995c0fc
pos: a hidden till session's report reads as missing, not a 500
futurebuildai Oct 11, 2026
3910066
middleware: the exclusive walls use the quoted lead in (r17 P3-2)
futurebuildai Oct 11, 2026
8bc4ba8
pos: an unknown sync batch register is refused before the log row
futurebuildai Oct 11, 2026
ae8f690
docs: round 8 in CONTRACT-CHANGES, the pos row its own id
futurebuildai Oct 11, 2026
22643f9
Merge remote-tracking branch 'origin/refactor/v1' into lead/pr83-v1
futurebuildai Oct 11, 2026
d3ab4cc
Merge remote-tracking branch 'origin/refactor/v1' into refactor/sec-b…
futurebuildai Oct 11, 2026
5913209
seed: the register upsert fires only on the shape 027 and 066 left
futurebuildai Oct 12, 2026
6eea086
pos: an empty sync batch register is refused before the log row
futurebuildai Oct 12, 2026
7981742
serve: the exclusive wall test asserts the refusal's audit row
futurebuildai Oct 12, 2026
460d17d
docs: round 9 in CONTRACT-CHANGES as SEC-BOUND-KEY-PIN-9
futurebuildai Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions core/api/fragments/pos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ paths:
tags:
- pos
summary: Replay offline transactions
description: A missing batch_id or an empty items list answers 400.
description: A missing batch_id, an empty items list, or a register_id that is empty or names no register answers 400 for every caller.
parameters:
- $ref: '#/components/parameters/XBranchId'
- $ref: '#/components/parameters/IdempotencyKey'
Expand Down Expand Up @@ -443,7 +443,7 @@ paths:
tags:
- pos
summary: Live X report of a session
description: An unknown session answers 500.
description: An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read.
parameters:
- name: id
in: path
Expand All @@ -465,6 +465,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/ForbiddenEither'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/pos/till/{id}/close:
Expand Down
6 changes: 4 additions & 2 deletions core/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10473,7 +10473,7 @@ paths:
tags:
- pos
summary: Replay offline transactions
description: A missing batch_id or an empty items list answers 400.
description: A missing batch_id, an empty items list, or a register_id that is empty or names no register answers 400 for every caller.
parameters:
- $ref: '#/components/parameters/XBranchId'
- $ref: '#/components/parameters/IdempotencyKey'
Expand Down Expand Up @@ -10602,7 +10602,7 @@ paths:
tags:
- pos
summary: Live X report of a session
description: An unknown session answers 500.
description: An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read.
parameters:
- name: id
in: path
Expand All @@ -10624,6 +10624,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/ForbiddenEither'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/pos/till/{id}/close:
Expand Down
58 changes: 54 additions & 4 deletions core/internal/app/seed/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ func Run() {
log.Fatalf("Failed to ping database: %v", err)
}

// Read the default branch before anything below changes it: dedupeBranches
// may clear the setting and section 1 re-points it at Kelowna, while the
// register upsert in section 22 compares against the value the migrations
// left, the branch migration 066 stamped REG-01 with.
regMigrationBranch := preSeedDefaultBranch(db)

// The AR core: every invoice, payment and credit memo the seed writes moves
// AR through it, never through a raw balance_due or subledger write.
ar, err := newReceivables(dbURL)
Expand Down Expand Up @@ -1314,10 +1320,13 @@ func Run() {
// =========================================================================
// POSTerminal.ts hardcodes the register id "REG-01"; without a row in
// pos_registers, /api/v1/pos/transactions fails with "failed to resolve
// register branch".
db.Exec(`INSERT INTO pos_registers (id, location_id, branch_id, name, is_active)
VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true)
ON CONFLICT (id) DO NOTHING`, kelMainID.String())
// register branch". Migration 027 seeds REG-01 on a location that does not
// exist and migration 066 gives it the migration-time default branch, so
// the row is moved to Kelowna, the branch the seed makes the default, but
// only while it is still exactly in that shape: a row an operator moved,
// by its location or by its branch alone, is not the seed's to move back
// (the shape condition on posRegisterUpsertSQL below).
db.Exec(posRegisterUpsertSQL, kelMainID.String(), regMigrationBranch)
db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at)
VALUES ('sync-batch-001', 'REG-01', 42, 0, 0, NOW() - interval '2 hours')`)
db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at)
Expand Down Expand Up @@ -1366,6 +1375,47 @@ const pricingRuleUpsertSQL = `
expires_at = EXCLUDED.expires_at,
updated_at = NOW()`

// preSeedDefaultBranch reads system_settings.default_branch_id as it stands
// before this seed run changes anything. Migration 059 records the setting and
// migration 066 stamps every register with it, so that value is half of the
// shape migrations 027 and 066 leave REG-01 in, and the register upsert below
// fires only on that exact shape (r19 P2-1). A database holding no setting yet
// reads NULL, which matches no row: the seed then leaves REG-01 where it is
// rather than move a row it cannot prove is still the migrations' own.
func preSeedDefaultBranch(db *sql.DB) sql.NullString {
var v sql.NullString
db.QueryRow(`SELECT value FROM system_settings WHERE key = 'default_branch_id'`).Scan(&v)
return v
}

// posRegisterUpsertSQL moves the demo register REG-01 onto the location given
// (Kelowna main, the branch the seed makes the default), as a package level
// constant for the same reason as pricingRuleUpsertSQL: the behaviour worth
// testing is what a re-seed leaves behind, and the test has to run the same
// statement the seeder runs.
//
// The conflict is on the id, so the update can only ever touch the seed's own
// REG-01 row; a register with any other id, created by a person, an import or
// another seed, is never matched. Of that one row the seed owns the location
// and the branch alone, and only while the row is exactly as migrations 027
// and 066 left it: the placeholder location, and the branch 066 gave it, the
// default branch the seed reads before it changes anything. The placeholder
// names no locations row, so while the register sits on it the register's
// branch comes from pos_registers.branch_id alone on every pos read and write,
// and an operator who re-points REG-01 by its branch alone has moved it as
// completely as one who moves the location; the seed's to move is neither
// (item 57 reads migration 027 as another seed; r16 P2, r19 P2-1), and the
// register's name is an operator's to keep the same way. A database whose
// default an operator changed then keeps REG-01 where it is, the safe side.
// The shape condition also makes a second run a no-op: the row no longer
// carries it.
const posRegisterUpsertSQL = `
INSERT INTO pos_registers (id, location_id, branch_id, name, is_active)
VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true)
ON CONFLICT (id) DO UPDATE SET location_id = $1, branch_id = $1
WHERE pos_registers.location_id = '00000000-0000-0000-0000-000000000000'
AND pos_registers.branch_id = $2::uuid`

// pricingRule is one row of the demo pricing_rules table.
//
// DiscPct and MarginFloor are PERCENTAGES — 10 means ten percent — because
Expand Down
186 changes: 186 additions & 0 deletions core/internal/app/seed/seed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,189 @@ func TestPricingRuleUpsert_IsIdempotentAndRepairsValues(t *testing.T) {
t.Errorf("discount_pct = %v after re-seeding at 15, want 15 — DO NOTHING would have left the stale value", discount)
}
}

// CORRECTNESS: a re-seed moves the seed's own REG-01 and nothing else
// (principal item 57 question 3's condition).
//
// The upsert conflicts on the id, so it can only ever touch the migration's
// demo register REG-01; a register created by a person, an import or another
// seed has another id and must be left exactly as it was. Of REG-01 the seed
// owns the location and the branch alone, and only while the row still sits
// on migration 027's placeholder location: a register's name is an
// operator's to keep, so a re-seed must not rename it back (r14 P3-2), and a
// register the operator re-pointed by hand is not the seed's to move back
// either, because migration 027 created the row and item 57's condition
// reads that as another seed (r16 P2).
//
// DB-gated: it is a test of a Postgres upsert, so it needs Postgres, and it
// runs the seeder's actual statement, twice per leg.
func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) {
db := testutil.RequireDB(t)
ctx := context.Background()

// A branch location of the test's own for the seed's target, and one the
// operator re-point leg moves REG-01 onto.
loc := uuid.New()
opLoc := uuid.New()
if _, err := db.Pool.Exec(ctx,
`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'seed test branch')`,
loc, "SEEDT-"+loc.String()[:8]); err != nil {
t.Fatal(err)
}
// A register created outside the seed, on a location and branch of its
// own, with its own name.
foreign := "REG-SEEDTEST-" + uuid.NewString()[:8]
if _, err := db.Pool.Exec(ctx,
`INSERT INTO pos_registers (id, location_id, branch_id, name, is_active)
VALUES ($1, $2, $2, 'a person made this counter', false)`, foreign, loc); err != nil {
t.Fatal(err)
}
// REG-01 as migrations 027 and 066 leave it: the placeholder location, the
// default branch, the name 027 gave. The fixture is forced, not assumed:
// the upsert under test can never put the row back once it has moved, so
// the test starts from the migration shape whatever an earlier run (or an
// operator) left behind, and the cleanup restores the same shape.
restoreMigrationShape := func() {
t.Helper()
if _, err := db.Pool.Exec(ctx,
`UPDATE pos_registers SET location_id = '00000000-0000-0000-0000-000000000000',
branch_id = (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'),
name = 'Counter 1'
WHERE id = 'REG-01'`); err != nil {
t.Fatalf("restore REG-01's migration shape: %v", err)
}
}
restoreMigrationShape()
t.Cleanup(func() {
_, _ = db.Pool.Exec(ctx, `DELETE FROM pos_registers WHERE id = $1`, foreign)
restoreMigrationShape()
_, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, loc)
_, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, opLoc)
})
if _, err := db.Pool.Exec(ctx, `UPDATE pos_registers SET name = 'renamed by the operator' WHERE id = 'REG-01'`); err != nil {
t.Fatal(err)
}

// The branch half of the migration shape: the seeder reads
// default_branch_id before it changes anything (preSeedDefaultBranch in
// seed.go), and the test reads it the same way so the two run the same
// statement with the same comparison value.
var migrationBranch string
if err := db.Pool.QueryRow(ctx,
`SELECT value FROM system_settings WHERE key = 'default_branch_id'`).Scan(&migrationBranch); err != nil {
t.Fatal(err)
}
upsert := func() {
t.Helper()
if _, err := db.Pool.Exec(ctx, posRegisterUpsertSQL, loc.String(), migrationBranch); err != nil {
t.Fatalf("the seeder's register upsert: %v", err)
}
}
upsert()
upsert()

// The register created outside the seed is exactly as it was.
var fLoc, fBranch, fName string
var fActive bool
if err := db.Pool.QueryRow(ctx,
`SELECT location_id::text, branch_id::text, name, is_active FROM pos_registers WHERE id = $1`, foreign).
Scan(&fLoc, &fBranch, &fName, &fActive); err != nil {
t.Fatal(err)
}
if fLoc != loc.String() || fBranch != loc.String() || fName != "a person made this counter" || fActive {
t.Errorf("a re-seed changed a register it did not create: location %s branch %s name %q active %v", fLoc, fBranch, fName, fActive)
}

// The seed's own row moved onto the seed's location, twice idempotently,
// and kept the operator's name.
var loc1, branch1, name1 string
if err := db.Pool.QueryRow(ctx,
`SELECT location_id::text, branch_id::text, name FROM pos_registers WHERE id = 'REG-01'`).
Scan(&loc1, &branch1, &name1); err != nil {
t.Fatal(err)
}
if loc1 != loc.String() || branch1 != loc.String() {
t.Errorf("the seed's own register did not move to the seed's location: location %s branch %s", loc1, branch1)
}
if name1 != "renamed by the operator" {
t.Errorf("a re-seed renamed the register to %q, want the operator's name kept", name1)
}

// An operator re-pointed REG-01 onto a location and branch of their own
// and named it: the row migration 027 created is not the seed's to move
// back, so a re-seed, run twice, leaves it exactly where the operator
// put it.
if _, err := db.Pool.Exec(ctx,
`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', $2, 'seed test operator branch')`,
opLoc, "SEEDT-"+opLoc.String()[:8]); err != nil {
t.Fatal(err)
}
if _, err := db.Pool.Exec(ctx,
`UPDATE pos_registers SET location_id = $1, branch_id = $1, name = 'operator counter' WHERE id = 'REG-01'`, opLoc); err != nil {
t.Fatal(err)
}
upsert()
upsert()
var opLoc1, opBranch1, opName1 string
if err := db.Pool.QueryRow(ctx,
`SELECT location_id::text, branch_id::text, name FROM pos_registers WHERE id = 'REG-01'`).
Scan(&opLoc1, &opBranch1, &opName1); err != nil {
t.Fatal(err)
}
if opLoc1 != opLoc.String() || opBranch1 != opLoc.String() {
t.Errorf("a re-seed moved an operator re-pointed REG-01: location %s branch %s, want it kept at %s",
opLoc1, opBranch1, opLoc)
}
if opName1 != "operator counter" {
t.Errorf("a re-seed renamed the re-pointed register to %q, want the operator's name kept", opName1)
}

// The location alone leg (r16 P2's case, held since round 8): the
// operator moved the location and left the branch as the migrations set
// it. A re-seed, run twice, leaves the row where the operator put it.
if _, err := db.Pool.Exec(ctx,
`UPDATE pos_registers SET location_id = $1, branch_id = $2 WHERE id = 'REG-01'`,
opLoc, migrationBranch); err != nil {
t.Fatal(err)
}
upsert()
upsert()
var lLoc, lBranch string
if err := db.Pool.QueryRow(ctx,
`SELECT location_id::text, branch_id::text FROM pos_registers WHERE id = 'REG-01'`).
Scan(&lLoc, &lBranch); err != nil {
t.Fatal(err)
}
if lLoc != opLoc.String() || lBranch != migrationBranch {
t.Errorf("a re-seed moved a location only re-pointed REG-01: location %s branch %s, want %s and the migration branch",
lLoc, lBranch, opLoc)
}

// While the location is the placeholder, which names no locations row,
// the register's branch comes from pos_registers.branch_id alone on every
// pos read and write, so an operator who re-points REG-01 by its branch
// alone has moved it as completely as a location re-point (r19 P2-1,
// item 59's "the operator's row is never moved"). A re-seed, run twice,
// must leave the branch where the operator put it and the location on
// the placeholder.
if _, err := db.Pool.Exec(ctx,
`UPDATE pos_registers SET location_id = '00000000-0000-0000-0000-000000000000',
branch_id = $1, name = 'operator counter' WHERE id = 'REG-01'`, opLoc); err != nil {
t.Fatal(err)
}
upsert()
upsert()
var bLoc, bBranch, bName string
if err := db.Pool.QueryRow(ctx,
`SELECT location_id::text, branch_id::text, name FROM pos_registers WHERE id = 'REG-01'`).
Scan(&bLoc, &bBranch, &bName); err != nil {
t.Fatal(err)
}
if bLoc != "00000000-0000-0000-0000-000000000000" || bBranch != opLoc.String() {
t.Errorf("a re-seed moved a branch only re-pointed REG-01: location %s branch %s, want the placeholder and %s",
bLoc, bBranch, opLoc)
}
if bName != "operator counter" {
t.Errorf("a re-seed renamed the branch only re-pointed register to %q, want the operator's name kept", bName)
}
}
16 changes: 14 additions & 2 deletions core/internal/app/serve/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ type AuditSink = confirmgate.AuditSink
//
// RequestLogger -> HTTPMetrics -> RequestID -> Recovery
// -> RateLimit -> CORS -> Actor -> [Auth or MachineKeyAuth]
// -> ConfirmGate -> MaxRequestSize -> Idempotency
// -> CacheControl -> Mux
// -> KeyBranchRouteGate -> ConfirmGate -> MaxRequestSize
// -> Idempotency -> CacheControl -> Mux
//
// so the confirm gate sits INSIDE auth (the gate sees the key id the
// machine-key core set, so ADR 0007 5.4 "keyed requests are governed by
Expand Down Expand Up @@ -103,6 +103,18 @@ func buildChain(d ChainDeps) http.Handler {
// Request size limit (10MB default).
finalHandler = middleware.MaxRequestSize(10 << 20)(finalHandler)

// The branch bound key's route allowlist gate (ADR 0007 section 5.5, deny
// by default): inside auth, so it reads the pin the machine key core set,
// and outside the confirm gate and idempotency, so a refused request
// claims nothing. It refuses a bound key every route the allowlist
// (pkg/middleware/keybranch_allow.go) does not admit, whatever the module
// mounts beneath it; a request with no pin passes untouched.
var branchAuditor middleware.BranchRefusalAuditor
if ba, ok := d.AuditLog.(middleware.BranchRefusalAuditor); ok {
branchAuditor = ba
}
finalHandler = middleware.KeyBranchRouteGate(d.Mux, branchAuditor)(finalHandler)

// Auth (JWT verification; a Bearer machine key dispatches to the
// machine-key core inside it). In AUTH_MODE=dev the JWT layer is off but
// machine keys still authenticate and scope check exactly as behind it.
Expand Down
Loading
Loading