Plan #5565 — Pinbox24 W4 authentication remediation

Status: Code-change-design v1 (ready for /review-plan) Owner-issue: #5565[SECURITY][Plan] Pinbox24 W4 auth remediation Branch: plan/5565-pinbox24-w4-auth-remediation Target repo of the eventual fix: gitlab.com/pinbox24/p24-back-ts (development branch) — a Pinbox24-owned repo. p24-infra can push branches + open MRs but cannot merge (docs/pinbox24/gitlab-repo-locations.md §Known limitation). Found via: #3826 / PR #5559 (W4 user/office/workspace provisioning playbook).

PLAN ONLY — do NOT implement. The deliverable is this document. No GitLab MR, no MongoDB writes, no source edits until this plan is /review-plan-approved and the human decisions in §8 are made. Authored by a dev-coder worker: writing the design is in scope; every credential, server, DB and GitLab-MR action it enumerates is delegated / human-gated — see §9. Role recorded per worker-issue-scoped Step R4: Role: dev-coder.


0. Read this first — a source-vs-deployed drift that reframes Finding #1

The issue’s Finding #1 (from reading p24-back-ts development-branch source) states the main signup path stores the registration password in plaintext and login does plain string equality. p24-infra’s own deployed-build ground-truth (docs/playbooks/w4-auth-password-model.md, traced inside the running v42-prod container on bms-1) states something adjacent but materially different:

AspectFinding #1 (source, development)w4-auth-password-model.md (deployed dist/, bms-1)
Stored value in pinbox.profiles.pass”the registration password directly … no hashing” (auth.controller.ts:96-102)md5(plaintext), lowercase hex, unsalted
Login compareplain string equality (auth.helper.ts:51)assert.equal(profile.pass, req.body.password) — verbatim
Who produces the hash(implied none)the Angular frontend hashes client-side before POST

Both agree the backend performs no server-side hashing. They disagree on what the string actually is: true plaintext, or a client-supplied MD5. This distinction changes the entire migration design and must be resolved before any code is written (Gate G1, §7). Two cases:

  • Case A — server receives client-side md5(plaintext) (what the deployed model says). The server never sees the real plaintext, so it cannot bcrypt-the-plaintext as the issue’s suggested shape assumes. It can only bcrypt(md5_from_client). Still a real win (a DB dump no longer yields the password-equivalent MD5 in the clear; MD5 is unsalted+fast, bcrypt is salted+slow), but the transport/at-rest-in-transit value is still a password-equivalent — a client-side concern out of scope for a backend MR (§10 residual risk).
  • Case B — server receives true plaintext (what the literal source reading implies, if the deployed frontend for this path does not pre-hash). Then the issue’s original bcrypt-the-plaintext shape is correct as-is.

The migration in §2 is written to be correct in both cases by keying every decision on the shape of the value the server actually receives and stores, never on an assumption about whether it is “plaintext.” G1 (§7) is the go/no-go: clone development, read auth.controller.ts + auth.helper.ts + the frontend submit path, and diff against the deployed dist/ on bms-1 (read-only) to confirm which case is live on the currently-deployed build — the same deployed-vs-source discipline the issue demands for Finding #4.

This drift is not hypothetical for this codebase: w4-auth-password-model.md and pinbox24-office-membership-grant.md already document three prior incidents (#4135/#4144/#4150) that burned time by assuming the W4 password format. Do not repeat that — verify at G1.


1. Findings under remediation (verbatim from the issue, with source citations)

#FindingCited location (p24-back-ts development)Severity
1Password stored without server-side hashing on the primary signup path; login = verbatim compareauth.controller.ts:96-102, auth.helper.ts:51Critical (see §0 for the plaintext-vs-client-MD5 nuance)
1bTwo narrower system-generated-password paths already MD5 server-side (context, not a bug per se)pinboxProfile.controller.ts:422-424, pinboxProfile.helper.ts:270-280Informational
2Invitation accept/reject has no caller-identity check (anyone can accept/reject any invite)acceptUserInvitation.helper.tsHigh (auth bypass / IDOR)
3Likely bug: isOfficeUser: false set on the office.users doc created post-accept (probably should be true)acceptUserInvitation.helper.ts:105-114Medium (needs blast-radius check)
4Deployed-vs-source drift: office-doc embedded users[] write is commented-out dead code in source, yet live testing proved that same array is the real access gateoffice.helper.ts:372-386 vs docs/playbooks/pinbox24-office-membership-grant.mdMedium (verification-gated)

Hard constraint (from the issue, applies to all password work): the fix MUST NOT force-invalidate, force-reset, or lock out any existing user’s current password. No mass rehash/reset. Real Pinbox24 customers log in daily.


2. Deliverable 1 — Password migration: lazy rehash-on-successful-login

2.1 Design shape (adapted for the §0 drift)

Principle: the stored representation upgrades exactly once, transparently, on the next successful login; the value the user types never changes and they never notice. New writes are strong from day one.

Let submitted = the value the server receives in req.body.password (per §0/G1: either md5(plaintext) in Case A, or true plaintext in Case B). Let stored = pinbox.profiles.pass.

Login path (auth.helper.ts login, currently auth.helper.ts:51):

1. Load profile by { login }.
2. Classify `stored` by shape (see 2.2):
     - looks-like-bcrypt  ($2a$/$2b$/$2y$, 60 chars)  -> MIGRATED
     - otherwise (32-hex MD5, or arbitrary plaintext)  -> LEGACY
3. If MIGRATED:  ok = bcrypt.compare(submitted, stored)
   If LEGACY:    ok = constant-time-equal(submitted, stored)     // preserves today's verbatim compare
4. If !ok: reuse the EXISTING wrongPassCount/wrongPassTime lockout unchanged. Return 401. Do NOT
   leak which branch failed (uniform error + uniform-ish timing; see §10).
5. If ok AND LEGACY:  stored := bcrypt(submitted); persist to pinbox.profiles.pass in the SAME
   request (best-effort — a write failure must NOT fail the login; log + metric, retry next login).
6. Issue JWT exactly as today.

Every write path must hash-at-write (no new legacy values from merge-day forward):

Write pathFile:line (source)Change
Primary registrationauth.controller.ts:96-102replace the raw pass write with bcrypt(submitted)
Password reset (/api/auth/reset/:token)auth.helper.ts reset flow (see w4-auth-password-model.md §Reset) — stores confirmnewpassword verbatimwrap in bcrypt(...)
Admin/system-generated #1pinboxProfile.controller.ts:422-424after its md5(...), wrap bcrypt(md5(...)) (or bcrypt the source value — decide at G1 per the case, keep the client contract intact)
Admin/system-generated #2pinboxProfile.helper.ts:270-280same as above
Any other pass writergrep at G1 (grep -rn "\.pass\s*=" src/)enumerate ALL and convert — a single missed writer reintroduces a legacy value

Critical contract preservation: whatever transform the client applies before POST must be left untouched. If the client sends md5(plaintext) (Case A), the server stores bcrypt(md5(plaintext)) and at login compares bcrypt.compare(md5_from_client, stored). We wrap the received value; we never require the client to send something new. This is what keeps every currently-active password valid (the issue’s hard constraint) — the migration is invisible to the client and to the user.

2.2 The “already-migrated vs legacy” marker — is it reliable?

The issue asks to verify distinguishability is real before relying on it. It is reliable, for a concrete reason:

  • bcrypt output is always $2[aby]$<cost>$<22-char-salt><31-char-hash> — 60 chars, always starts with the literal $2. bcrypt libraries (bcrypt, bcryptjs) reject a non-$2 string at compare().
  • Legacy MD5 is exactly 32 lowercase hex chars, no $.
  • Legacy plaintext is arbitrary but, per the deployed model, in practice is a 32-hex MD5; even if a true plaintext existed it cannot begin with $2$ + valid bcrypt structure by accident.

So stored.startsWith('$2') (optionally bcrypt.getRounds() succeeds) is a robust MIGRATED test. No new schema field is required — bcrypt’s own prefix is the format marker. (A dedicated passVersion/passAlgo field is an optional belt-and-suspenders; §8 decision D1. Recommendation: do not add one — it is redundant with the prefix and adds a second thing to keep in sync.)

  • Verify at G1: sample (read-only) the live pinbox.profiles.pass distribution on bms-1 to confirm every existing value is 32-hex (or catalogue any outliers) before relying on “non-$2 == legacy”. Command shape (read-only, via w4_app, project out nothing sensitive since we only read lengths/prefixes, never print a full hash): db.getCollection("pinbox.profiles").aggregate([{$project:{l:{$strLenCP:"$pass"},p:{$substrCP:["$pass",0,2]}}},{$group:{_id:{l:"$l",p:"$p"},n:{$sum:1}}}]) — expect {l:32, p:<hex>} dominating. Any $2-prefixed rows already exist = someone migrated; any non-32 non-$2 rows = true-plaintext outliers to handle explicitly.

2.3 Hashing library choice

  • G1 must determine what p24-back-ts already depends on. Read package.json / package-lock.json on development. If bcrypt or bcryptjs is already present (common in Node/TS backends), use it — no new dependency, no supply-chain review.
  • If neither is present: recommend bcryptjs (pure-JS, no native build step — safest for a container/PM2 deploy where native bcrypt can fail to compile on the target Node/glibc), cost factor 12 (tune at G1 against p24-back-ts’s per-request latency budget; W4 login already does a lockout check so it is not a hot inner loop). Justify the added dependency in the MR.
  • argon2 is stronger but pulls a native addon — higher deploy risk on this PM2/container stack; not recommended unless already a dependency.

2.4 Dormant accounts (never-log-in-again residual)

Accounts that never log in during any observation window stay on the legacy representation indefinitely — this is inherent to lazy migration and the issue calls it out.

  • In scope for this plan: document the residual, and expose a count metric so the tail is observable (see §6.3): w4_profiles_legacy_pass_total (count of non-$2 pass values), scraped read-only. When it plateaus, the remaining accounts are effectively dormant.
  • Out of scope (recommend deferring to a follow-up issue): a time-boxed, opt-in soft email nudge (“we’ve upgraded security, next login refreshes your protection”) — never a mass forced-reset or lockout (violates the hard constraint). Decision D2 (§8). Recommendation: defer; ship lazy migration first, watch the metric for N weeks, then decide with real numbers.

3. Deliverable 2 — Invitation accept/reject caller-identity check

Finding: acceptUserInvitation.helper.ts accept/reject does not verify the authenticated caller’s identity against the invitation’s invitedToEmail, so any authenticated (or possibly unauthenticated — verify at G2) caller can accept/reject any invitation → privilege escalation / IDOR.

3.1 Design

  1. G2 — locate the auth context available in this code path (must verify at source before writing the check — the plan cannot assume the middleware shape):

    • Trace how acceptUserInvitation is routed. In this codebase login attaches identity via the JWT (auth.helper.login issues result.token; officeId-scoped endpoints already gate on it). Find the middleware that decodes the JWT and where it deposits the caller (req.user, req.profile, res.locals, a ctx, …). Confirm the decoded identity exposes the caller’s email/login (the field that can be compared to invitedToEmail) or a profileId that resolves to it.
    • If the accept/reject route is currently unauthenticated (no JWT middleware at all), the fix is larger: it must be moved behind the auth middleware first. Flag this at G2 — it changes the MR’s blast radius (any legitimate caller that relied on calling it token-less breaks).
  2. The check (pseudocode, exact field names resolved at G2):

const caller = <decoded JWT identity from G2>;            // e.g. req.user.login / req.user.email
const invite = await Invitation.findById(invitationId);
if (!invite) return 404;
if (normalizeEmail(caller.email) !== normalizeEmail(invite.invitedToEmail)) {
    return 403;                                            // not your invitation
}
// ... existing accept/reject logic unchanged ...
  • normalizeEmail = trim + lowercase (W4 stores login/email case-sensitively per w4-auth-password-model.md; confirm at G2 whether invites store invitedToEmail already normalized — if the compare is case/whitespace-sensitive on live data it will 403 legitimate users; this is a real regression risk, verify).
  • Apply the identical check to both accept and reject.
  1. Regression risk: any existing legitimate flow that accepts an invite on behalf of another user (e.g. an admin bulk-accept) would now 403. G2 must grep for all callers of the accept/reject endpoint (frontend + any server-to-server / automation) and confirm none rely on cross-identity accept. If one does, the check needs an explicit admin-role bypass (decision D3, §8).

4. Deliverable 3 — isOfficeUser default bug + blast-radius

Finding: acceptUserInvitation.helper.ts:105-114 writes isOfficeUser: false on the office.users doc it creates post-accept; almost certainly should be true.

4.1 Blast-radius check design (G3 — do this BEFORE flipping anything)

The value must not be flipped blind — something may already depend on the current false.

  1. Grep every reader across p24-back-ts development: grep -rn "isOfficeUser" src/ — enumerate every read, every query filter ({ isOfficeUser: true } / $eq/$ne), every place it gates behaviour (UI role, permission, list membership, notification target, billing seat count …).
  2. Also grep the frontend repospinbox24-version-4 (gitlab.com/pinbox24/pinbox24-version-4, Angular, per gitlab-repo-locations.md) — for isOfficeUser, since the flag may drive UI gating client-side even if the backend ignores it.
  3. Classify each reader as: (a) expects true for a real office member (confirms the bug), or (b) relies on false (flipping would break it). Only proceed to flip if no class-(b) reader exists, or each class-(b) reader is reconciled.

4.2 Fix + backfill decision

  • Code fix: set isOfficeUser: true at creation in acceptUserInvitation.helper.ts:105-114 (assuming G3 confirms it).
  • Already-created wrong docs — decision D4 (§8). Two options:
    • Backfill migration (recommended if G3 shows readers actually expect true): a one-shot, idempotent, read-only-until-approved MongoDB update, human-supervised (per pinbox24-office-membership-grant.md, production office.users writes on a non-owned tenant are classifier-blocked and need a foreground session): db.getCollection("office.users").updateMany({ isOfficeUser: false, /* + any G3-derived guard so we only touch invite-created docs */ }, { $set: { isOfficeUser: true } }) — with a before/after count to prove the exact number changed, and a documented rollback ($set back to false on the same guarded set).
    • Leave-as-is, fix forward only (recommended if G3 shows nothing meaningfully reads it, or the existing false docs are harmless): document that historical docs keep the old value; only new accepts get true.
  • Do not run a blanket updateMany({}, ...) — always guard to the invite-created subset G3 identifies, so unrelated office.users docs (some legitimately false?) are untouched.

5. Deliverable 4 — Office-membership drift reconciliation (verification gate, no code yet)

The conflict: source (office.helper.ts:372-386) has the office-doc embedded users[] write commented out (dead code) on development; but docs/playbooks/pinbox24-office-membership-grant.md

  • docs/playbooks/w4-auth-password-model.md §office-users proved empirically (live, 2026-08-02) that the office document’s embedded users[] array is the real access gate — pushing an entry there is what makes GET /api/offices and officeId-scoped calls start working.

Interpretation: the deployed production build almost certainly is not the development source at that line — either a different branch/build is deployed, or a host-mounted persistent patch re-enables the embedded write at container-run time (the exact “persistent-patches gotcha” documented for the W3/W4 stack and the s3-v2-v42-prod service in docs/pinbox24/gitlab-repo-locations.md and docs/w3-w4-stack-operations.md).

5.1 Live-verification step (G4) — REQUIRED before any office-membership code ships

Mirror how gitlab-repo-locations.md ground-truthed s3-v2-v42-prod (matched running container’s compiled dist/ against candidate source):

  1. On bms-1, read-only, identify the deployed W4 backend build: docker ps → the W4 API container; pm2 show <app> → the running script path under /app/dist.
  2. Read the deployed compiled office.helper.js (or equivalent) in that dist/ and determine whether the embedded offices.users[] write is live (present/executed) in production.
  3. Check for host-mounted persistent patches (/root/... mounts) that override the office helper at run time — same class as the four s3v2-prod patch files.
  4. Diff the deployed build’s git provenance against development HEAD and against any other candidate branch (a build tag / commit stamped in the image, if present).
  5. Record the reconciliation in pinbox24-office-membership-grant.md (append a “source vs deployed reconciled” note) and in this plan’s issue thread.

Decision gate: do not touch, re-enable, remove, or “clean up” the office.helper.ts:372-386 embedded write until G4 says which mechanism is actually live. If the embedded write is load-bearing in production, “removing dead code” would break office access for real clients. This deliverable ships no code in this plan — it produces a verified fact + a decision (D5, §8) for a separate implementation issue.

Ownership: the G4 live inspection on bms-1 is a sys-admin / infra-task action (SSH + docker/pm2 on production), not dev-coder — delegated per §9. dev-coder consumes the result.


6. Deliverable 5 — Rollout plan (human review + post-deploy verification)

Because the MR targets a Pinbox24-owned GitLab repo p24-infra cannot merge (gitlab-repo-locations.md §Known limitation), the rollout is explicitly human-gated.

6.1 What must be human-reviewed/approved BEFORE any MR is opened

  1. This plan /review-plan-approved and decisions D1–D6 (§8) made.
  2. Gates G1–G4 (§7) all executed and their findings appended to the issue.
  3. Explicit human sign-off to touch production auth code on a third-party-owned repo (business decision — the code guards paying customers’ logins).
  4. For any MongoDB backfill (D4) or office-membership change (D5): a separate, foreground, human-supervised session — production writes on non-owned tenants are classifier-blocked (pinbox24-office-membership-grant.md) and must not be attempted from an autonomous worker.

Split into independent MRs so each can be reviewed/reverted alone — do not bundle the auth bypass fix behind the risky password migration:

OrderMRRiskRationale
1Invitation caller-identity check (§3)Low-MedPure authz add; smallest, highest-value security win; no data migration
2isOfficeUser code fix (§4, code only)LowOne-line default flip once G3 clears it; backfill handled separately + supervised
3Password hash-at-write on all writers (§2.1 table)MedStops new legacy values; no read-path change yet, so zero login-behaviour change
4Lazy rehash-on-login (§2.1 login path)HighTouches the hot login path for every user; ship last, behind a feature flag if the repo supports one, with the §6.3 metrics live first
Office-membership change (§5)GatedNot in this plan — separate issue after G4
isOfficeUser backfill (D4)GatedSeparate supervised MongoDB session, not an MR

MRs 3 and 4 can be one MR if reviewers prefer, but 4 is the only one that can lock out a live user if wrong — keeping it separable is safer.

6.3 Post-deploy verification (what a human at Pinbox24/p24-infra checks)

  • Before each MR merges: confirm a real login still works end-to-end. Use the read-only round-trip from w4-auth-password-model.md §Verify: POST https://api.w4.pinbox24.com/api/auth with a known account, expect 200 + JWT.
  • After MR 3 (hash-at-write): register a fresh test account (or reset one), then read its pass (read-only, length/prefix only) — must now be $2.... Existing accounts still 200 on login (unchanged read path).
  • After MR 4 (rehash-on-login): log in a legacy test account → 200; re-read its pass → now $2... (migrated). Log it in again → still 200 (bcrypt.compare path). Log in a wrong-password → 401 and lockout counter still increments. Confirm the w4_profiles_legacy_pass_total metric decrements by one after the successful legacy login.
  • Standing observability: the two metrics (w4_profiles_legacy_pass_total, w4_login_rehash_failures_total) — expose via the existing W4 monitoring approach (docs/plans/plan-4132-w3-w4-monitoring-strategy.md); a persistent non-zero rehash-failure count means the in-request re-store write is failing and users are re-migrating every login (perf + correctness smell). Wire to the standard Discord/GH-issue error path per CLAUDE.md §Error Notification.

7. Verification gates — the go/no-go checklist (all read-only; do before writing any fix)

GateWhat it establishesHowOwner
G1Which §0 case is live (client-MD5 vs plaintext); every pass writer; the hashing lib already in package.json; the live pass-shape distributionClone p24-back-ts development (read-only) + read deployed dist/ on bms-1 + read-only Mongo aggregate on pinbox.profiles.pass shapesdev-coder (source) + sys-admin (bms-1 dist/ + Mongo read)
G2Where the caller’s authenticated identity is available on the accept/reject path; whether that route is currently authenticated at all; all callers of accept/rejectSource trace in p24-back-ts + frontend grepdev-coder
G3Every reader of isOfficeUser (backend + frontend); does anything rely on falsegrep -rn isOfficeUser across p24-back-ts + pinbox24-version-4dev-coder
G4Whether the office-doc embedded users[] write is live in the deployed build (source says dead)Deployed dist/ + persistent-patch mounts on bms-1, per §5.1sys-admin / infra-task

No fix MR is opened until the gate(s) feeding it are green. G1→MR3/MR4; G2→MR1; G3→MR2 (+D4); G4→the deferred office-membership issue (D5).

dev-coder scope note: G1’s source clone requires GITLAB_ADMIN_PAT (secrets/administration.env.sops). Decrypting a SOPS value is outside dev-coder’s role (cannot: decrypt values from any secrets/*.env.sops file). At implementation time the clone step is either (a) run by the secret-manager/human who holds the age key, or (b) done in a session with the PAT already provisioned to the worker env — not by decrypting SOPS inline in a dev-coder session. This is why G1/G4 are written as gates, not steps this plan executes.


8. Human decisions required (resolve during/after /review-plan)

#DecisionRecommendation
D1Add an explicit passAlgo/passVersion field, or rely on the bcrypt $2 prefix as the marker?Rely on the prefix — reliable (§2.2), no schema change, nothing extra to sync
D2Dormant-account soft email nudge — in scope now, or defer?Defer to a follow-up issue; ship lazy migration + metric first, decide with real numbers. Never a forced mass reset
D3Does any legitimate flow accept an invite on behalf of another user (needs admin bypass in §3)?Determine at G2; default no bypass unless G2 finds a real caller
D4isOfficeUser historical docs: backfill vs fix-forward-only?Decide from G3: backfill (guarded, supervised) if readers expect true; fix-forward if nothing meaningfully reads it
D5Office-membership: after G4, re-enable/keep the embedded users[] write, or leave the deployed mechanism untouched?Separate issue — do nothing until G4; default is “match whatever is live in prod, change nothing that breaks access”
D6Password hashing library + cost factorbcryptjs cost 12 if none present; reuse whatever G1 finds already in package.json

9. Role & delegation boundaries (worker-issue-scoped Step R2/R3)

This plan was authored by a dev-coder worker. In-scope here: reading docs, reasoning about the GitLab-cloned source, and writing this plan doc in p24-infra. Everything the plan enumerates for implementation that falls outside dev-coder is explicitly delegated, not performed:

  • SOPS decrypt of GITLAB_ADMIN_PAT (to clone for G1–G3) → delegate_to.credentials: secret-manager / human age-key holder. dev-coder never decrypts SOPS values.
  • bms-1 SSH + docker/pm2 + production MongoDB reads/writes (G1 dist/, G4, D4 backfill, post-deploy verification) → delegate_to.server-operations: sys-admin (via the infra-task worker on bms-4). Production DB writes on a non-owned tenant additionally require a foreground, human-supervised session (classifier-blocked otherwise — pinbox24-office-membership-grant.md).
  • Opening the GitLab MR is implementation, gated on this plan’s approval — not done now (the issue’s own scope: “Do NOT open a GitLab MR yet”).

No credential value appears anywhere in this plan — key names only (GITLAB_ADMIN_PAT, mongodb_w4_app_password, SUPABASE_SERVICE_ROLE_KEY).


10. Residual risks (documented, some out of scope for a backend MR)

  1. Client-side MD5 is a password-equivalent in transit/at-rest-on-client (Case A). bcrypt at the DB defeats a DB-dump attack but not a TLS-MITM or compromised-frontend attack, because the value the client sends is itself sufficient to log in. A true fix (send plaintext over TLS, hash only server-side, or add a challenge) is a frontend + backend contract change — out of scope for this remediation; flag as a follow-up security issue. Confirm the case at G1.
  2. Dormant accounts never migrate (§2.4) — accepted, observable via metric.
  3. Login timing/oracle: the bcrypt vs verbatim branches have different timing; keep the 401 and lockout identical across branches and avoid branch-revealing error text (§2.1 step 4). A perfect constant-time login is out of scope; note it.
  4. Rehash write failure mid-login must be non-fatal (login still succeeds); monitored via w4_login_rehash_failures_total.
  5. Deployed-vs-source drift may exist on the auth path too (§0) — the whole plan is gated on G1 precisely because a development-source reading may not match the deployed build.

11. Compliance

  • New service/container: none — this modifies an existing third-party backend. No dev_r_services row change in p24-infra.
  • EU AI Act: not applicable — no AI system, scoring, or automated employment decision is added (dev-coder Startup EU AI Act trigger check: none of the three triggers apply).
  • Secrets: no new secret; no value displayed; SOPS untouched by this plan.
  • Error notification: the two monitoring metrics (§6.3) wire into the standard Discord + GH-issue error path per CLAUDE.md §Error Notification when the rehash-failure count is non-zero.

  • docs/pinbox24/gitlab-repo-locations.md — GitLab access + repo map + persistent-patches gotcha
  • docs/playbooks/w4-auth-password-model.mddeployed W4 password model (client-side MD5, verbatim compare)
  • docs/playbooks/pinbox24-office-membership-grant.md — empirical office-access gate (Finding #4 conflict source)
  • docs/w3-w4-stack-operations.md — W3/W4 ownership + permission matrix + escalation
  • docs/playbooks/pinbox24-w4-user-office-workspace-provisioning.md — the #3826 / PR #5559 playbook this came from
  • docs/plans/plan-4132-w3-w4-monitoring-strategy.md — W4 monitoring approach for §6.3 metrics
  • Issues: #5565 (this), #3826 (origin), #2742 (office-membership empirical finding), 4135 (W4 password-format incidents)

Authored by a p24-infra dev-coder worker (bms4-cw-1). Plan only — implementation gated on /review-plan + §8 decisions + §7 gates.