D2 Design User and Supplier Services - AY2627S1-CS3219-P23/foc GitHub Wiki
Answers to the eleven D2 questions (instructions) as built. Demo: Week 7, 20–30 min, live. Related: architecture.md · supplier-service.md · wireframes · demo script + curl walkthrough
flowchart LR
subgraph M["Merged"]
UA["user-service: signup + OTP email ·<br/>login · JWT + RBAC · owner/admin ·<br/>purge · demo-account seed"]
SA["supplier-service: schema + seed ·<br/>browse/search/filter/sort/page ·<br/>JWT role gate · CRUD writes"]
WA["web: auth + OTP dialog · suppliers<br/>with admin CRUD modals · admin pages"]
end
subgraph R["In review"]
PROF["#149 profile screens"]
UPD["#157 account updates (#92)"]
end
subgraph O["Deferred"]
LOGOUT["logout denylist #90"]
RESET["recovery / reset #94"]
end
classDef ok fill:#1a7f37,color:#ffffff
classDef rev fill:#9a6700,color:#ffffff
classDef gap fill:#cf222e,color:#ffffff
class UA,SA,WA ok
class PROF,UPD rev
class LOGOUT,RESET gap
Requester/courier are modes of USER, not roles — one account does both, no re-login (F5.1.1).
| Role | Problem it solves | Obtained | Can do |
|---|---|---|---|
| USER (default) | the same student makes requests and delivers | sign-up | both modes · manage own account · own profile |
| ADMIN | day-to-day governance without developer involvement | promoted by an owner or admin (dashboard button → PATCH /users/{id}) |
list/search users · remove accounts · promote/demote user↔admin · supplier CRUD |
| OWNER | administers the administrators — an anchor admins can't demote or remove |
POST /auth/setup-owner with the setup token |
admin-equivalent · untouchable by admins · multiple owners allowed |
-
Enforced where: the JWT
roleclaim travels with every request; each service checks it server-side (user-service #144, supplier-service #143). -
Adding a role later (mentor question — scalable roles): add one
Roleenum value and authorization logic inSecurityConfigfiles and the UI. -
Can an admin edit a user's profile? No — admin powers are role change and removal only; profile fields are self-service via
/users/me. - Can a normal user edit suppliers? No — supplier writes require admin role; a direct API call gets 403 problem+json (see Part 2 §4).
PostgreSQL 17 + Spring Data JPA. Why:
- Integrity rules enforced by the database: unique email/username (including soft-deleted rows by using soft-delete flag), role checks (NFR4.1).
- Flows are transactional multi-row writes: verify→insert, lockout counters, dependents-first purge. A failed write rolls back — no partial state.
- 60 000 accounts (NFR2.1) and operations within 2 seconds (NFR3.1) achievable.
Schema (bold = required / NOT NULL, rest nullable):
erDiagram
users {
bigint id PK "req"
text email UK "req - eXXXXXXX at u.nus.edu"
text username UK "req - 3-30 A-Za-z0-9_"
text password_hash "req - BCrypt"
text role "req - USER or ADMIN or OWNER"
int failed_login_attempts "req - default 0"
timestamptz locked_until "opt - null = unlocked"
timestamptz created_at "req"
timestamptz deleted_at "opt - null = active"
bigint version "req - optimistic lock"
}
pending_signups {
bigint id PK "req"
text email UK "req"
text username "req - not unique, first verify wins"
text password_hash "req - BCrypt"
text code_hash "req - BCrypt-hashed OTP"
timestamptz last_sent_at "req - resend cooldown origin"
timestamptz expires_at "req - 10 min, never extended"
int attempts "req - max 5 per row, resends included"
}
account_tokens {
bigint id PK "req"
bigint user_id FK "req"
text token_hash "req"
text kind "req - PASSWORD_RESET or RECOVERY"
timestamptz expires_at "req"
}
token_denylist {
text jti PK "req"
timestamptz expires_at "req"
}
users ||--o{ account_tokens : "has"
Why these relations:
-
pending_signupshas no FK touserson purpose: with insert-after-verify (F1.1.2) there is no user row yet — the OTP state is keyed by email; theusersrow appears only on successful verify. -
users 1—* account_tokens: a user may hold several outstanding hashed reset/recovery tokens (F4.1); FK ties their lifecycle to the account (purged together). -
token_denyliststands alone: it's token-scoped (keyed byjti), not user-scoped — reserved for logout.
Credential storage (NFR1.1): only BCrypt hashes persist — passwords and OTP codes; JWT_SECRET and the owner-setup token live in env, never the DB; codes/tokens/secrets are never written to logs (mentor question).
✍️ Open inconsistency to answer if probed: D1 NFR1.2 says emails are hashed too — they're stored plaintext because email is the login/OTP identifier. Team to amend the NFR or revisit.
Account lifecycle (F3.1.1–F3.1.3, F4.1):
stateDiagram-v2
[*] --> Pending: signup (202)
Pending --> Active: OTP verified (users row inserted)
Pending --> [*]: expired / 5 wrong codes / identifier taken
Active --> Deleted: self delete or admin remove (soft)
Deleted --> Active: login within 30 d recovers
Deleted --> [*]: purge daily 03:00, day 31
Token-based (JWT, HS256, shared secret). Self-contained claims let every microservice and the web UI read the caller's role locally — stateless, no rerouting allowing for less latency.
| Claim | Value | Mentor questions answered |
|---|---|---|
sub |
user id (numeric, enforced) | identity always comes from here — see IDOR below |
role |
USER / ADMIN / OWNER | RBAC input for every service |
jti |
random UUID | reserved for logout denylist |
exp |
+1 h | token expiry: 1 hour; stored by the SPA in localStorage; invalidation today = expiry only — logout denylist is designed (schema ready) but deferred #90; a demoted role is stale ≤ 1 h |
-
Every request is validated by the filter chain (#144): signature (HS256 only),
exppresent + unexpired, numericsub, knownrole— then URL rules apply (/usersadmin routes →hasAnyRole("ADMIN","OWNER"), fail-closed defaultauthenticated()). Violations → problem+json 401/403. -
IDOR (User A vs User B): self-service routes are
/users/me— there is no id parameter to tamper with; the target is always the verifiedsub. The only id-taking read,GET /users/{id}, returns username alone. -
Sign-up (F1.1): unique email/username (F1.1.1),
u.nus.edudomain, password 10–50 with upper+lower+digit (F1.1.3), all violations returned with exact reasons (F1.1.5–6). Then OTP by email (F1.1.2):
sequenceDiagram
actor Student
participant SPA
participant US as user-service
participant SMTP as Mailpit / Gmail
SPA->>US: POST /auth/signup
US-->>SPA: 202 (row parked in pending_signups)
US->>SMTP: 6-digit code (10 min, 5 attempts, BCrypt-hashed)
Student->>SPA: enter code
SPA->>US: POST /auth/signup/verify
US-->>SPA: 201 (users row inserted)
The SPA side is a dialog over the register card (six code boxes, "Resend code", per wireframes signup.png) — built in #150 as the sign-up/OTP half of #109, which keeps logout and token handling.
Who may move a pending sign-up along — decided by the User Service owner in #150's reviews (options Q&A), after review found the first shape unsafe:
Repeat POST /auth/signup for a pending email |
Answer |
|---|---|
| same username and a password matching the stored hash | resend: new code, same details, attempt count kept |
| anything else, while the row is live | 409 — never merged, never rewritten |
| the row has expired | any sign-up takes it over outright |
inside OTP_RESEND_COOLDOWN (60s) |
429 + Retry-After
|
Why, and what was rejected: the original "last request wins" let anyone who knew an address overwrite a pending sign-up's username/password, so the code the real student received created an account holding someone else's password. Reversing it to "first request wins, later ones only resend" was tried and dropped — it merely reverses who has to move first, since a planted row would then be what the student's own sign-up completed into. Matching on the details the requester supplied is what proves it is the same request without a token for the SPA to carry.
Two follow-on rules from the same review, both about a row that outstays its usefulness: a resend replaces the code but not expires_at, so a row lives at most OTP_TTL from creation and always reaches the takeable state (otherwise its creator could resend once per cooldown for ever and hold the address, while its owner kept getting a 409 telling them to wait for an expiry that never came); and verify discards a pending row whose email or username was taken meanwhile, since it can never complete and used to lock whoever lost a username race out of their own address for the rest of the TTL. Consequence accepted on purpose: a late resend hands out a short-lived code, and the email and the 202 quote the seconds actually left.
5 wrong codes → 429, signup discarded (counted per pending sign-up, resends included). Mail failure → 502, rolled back — on a resend that leaves the previous code live. Provider: Gmail SMTP, with a Mailpit container as the local target (inbox at localhost:8025) — switched by .env only.
-
Login (F5.1): username-or-email + password; 5 failures → 15 min lockout (429 +
Retry-After); errors name the cause (decided #148 — the lockout countdown reveals existence anyway); login within 30 days recovers a soft-deleted account (F4.1).
Routes and response types (mentor ask: which fields does each operation return):
| Route | Returns | Access |
|---|---|---|
POST /auth/signup |
202 SignupResponse {email, expiresInSeconds, resendInSeconds}
|
public |
POST /auth/signup/verify |
201 UserResponse {id, email, username, role, createdAt}
|
public |
POST /auth/login |
200 LoginResponse {accessToken, tokenType, expiresIn}
|
public |
POST /auth/setup-owner |
201 UserResponse
|
setup token |
GET /users/me |
UserResponse |
bearer |
GET /users/{id} |
PublicProfileResponse {username} |
bearer |
DELETE /users/me |
204 (soft delete) | bearer |
GET /users?search=&role=&sort=&page=&size= |
PagedModel<UserResponse> (content + page metadata) |
ADMIN/OWNER |
PATCH /users/{id} {role}
|
UserResponse (idempotent) |
ADMIN/OWNER |
DELETE /users/{id} |
204 (soft delete) | ADMIN/OWNER |
POST /auth/logout · PATCH /users/me · password reset |
— | deferred #90 · #92 · #94 |
Same token, verified independently by each service with a local JwtVerifier — no runtime call back to user-service, no shared library coupling:
sequenceDiagram
actor A as ADMIN
actor U as USER
participant SPA
participant US as user-service
participant SS as supplier-service
A->>SPA: login
SPA->>US: POST /auth/login
US-->>SPA: JWT (role: ADMIN, 1 h)
SPA->>SS: POST /suppliers + Bearer
SS->>SS: verify signature locally, read role
SS-->>SPA: 2xx
U->>SPA: login
US-->>SPA: JWT (role: USER)
SPA->>SS: POST /suppliers + Bearer
SS-->>SPA: 403 problem+json
The supplier-side gate merged in #143 (2026-09-29) — the diagram above is live behaviour on main, including the ADMIN-or-OWNER rule on writes (owner is admin-equivalent, decided in that PR's review).
- Live now:
GET /users/me(own data incl. role),DELETE /users/me(confirm dialog in UI, then soft delete). - Update flows (#92, screens in #149): edits re-validated like sign-up (uniqueness F2.1.2, policy re-check), gated by an OTP to the existing email first (F2.1.1), new email confirmed by its own OTP (F2.1.3), password double-entry.
-
Protected fields (role, status, id — the P1.5 question): request DTOs are allow-lists —
PATCH /users/mewill bind only email/username/password, so a request carrying"role": "ADMIN"has no field to land in. Already live at sign-up: arolein the body is ignored.
Applies to every endpoint that changes an account today (DELETE /users/me, the admin PATCH / DELETE /users/{id}, sign-up and owner setup), and is what the profile-update endpoint (#92) will plug into — there is no PATCH /users/me yet.
-
Authentication:
JwtAuthenticationFilterchecks the bearer token on each request: HS256 signature with the shared secret, anexpthat is present and not passed, a numericsub, a knownrole. A bad token is a 401 problem+json.SecurityConfigthen requires a signed-in caller on every route except/auth/**(sign-up, login, owner setup — public by design) and/actuator/health. -
Authorization: URL rules in
SecurityConfig, checked before any controller code runs:-
GET /users/*andDELETE /users/me— any signed-in user. - everything else under
/users, includingPATCH /users/{id}(role change) — ADMIN or OWNER only; others get 403. Unlisted methods fail closed. -
/users/meroutes act on the caller's own account: the ID comes from the token'ssub, never from the request.
-
-
Validation: request bodies are records checked with Jakarta Validation (
@Valid), e.g. the password rules via@PatterninAccountRules.ProblemDetailAdviceturns failures into one 400 problem+json (RFC 9457) listing each broken rule, sorted by field; an unreadable body is 400 "Request body is missing or malformed". Business rules then run in the service (uniqueness including soft-deleted accounts, "OWNER can't be changed", …). -
Concurrency:
Userhas a@Versioncolumn (optimistic locking). Each save checks that the row hasn't changed since it was read; if another request saved it in between, the save fails withOptimisticLockingFailureException, answered as 409 "The account was changed by another request; reload and try again". Example: one admin changes a user's role while another admin removes that user — without the check, the role change (made from a copy read before the removal) could write the account back as active. Login's own counter update uses a row lock instead (LoginAttempts), so parallel wrong passwords all count.
-
User ID: never taken from the request.
/users/meroutes get the caller's ID from the verified login token'ssubclaim (JwtAuthenticationFilter→CallerId.from), so there's no way to name someone else's account. Theidcolumn is also generated by the database. -
Role:
- Sign-up's request type (
SignupRequest) has norolefield, andAuthServicealways createsRole.USER. Arolesent in the body is dropped (there's a test for this inAuthControllerTest). - The only way to change a role is the admin endpoint (§6).
- The authorization rules use the role in the signed token, which the server issued, so a client can't make one up.
- Sign-up's request type (
-
Account status: there's no field a client can set.
-
deleted_atchanges only throughsoftDelete(onDELETE /users/meor an admin's removal), or when logging in within 30 days recovers the account. - The lockout counters change only inside login (
LoginAttempts). -
created_atcan't be updated (updatable = false).
-
-
Allow-list request types: each request type lists only the fields it accepts. For example,
UpdateRoleRequestbinds onlyrole.
Run against the live stack on 2026-09-30 (main + the demo-account seeder); the responses are the service's actual output. $USER_TOKEN is a normal user's login token, $ADMIN_TOKEN an admin's.
| # | Attempt | Request | Result |
|---|---|---|---|
| 1 | Sign up as an admin |
POST /auth/signup {"email":"[email protected]","username":"sneaky_sam","password":"StudentPass123","role":"ADMIN"}
|
201, account created with "role":"USER" — the role field is dropped |
| 2 | Promote yourself |
PATCH /users/me {"role":"ADMIN"} with $USER_TOKEN
|
403 "You do not have permission to access this resource" — no profile-update route exists yet, and unlisted /users routes fail closed |
| 3 | Promote someone else |
PATCH /users/2 {"role":"ADMIN"} with $USER_TOKEN
|
403 — admin routes need ADMIN or OWNER |
| 4 | Read someone else's account |
GET /users/2 with $USER_TOKEN
|
200 {"username":"alex_tan"} — the public profile is the username only; no email, role or status |
| 5 | Edit the token's role claim to ADMIN
|
GET /users with the tampered token |
401 "Invalid token" — the signature no longer matches |
| 6 | Admin grants OWNER |
PATCH /users/2 {"role":"OWNER"} with $ADMIN_TOKEN
|
403 "OWNER can only be granted through owner setup"
|
| 7 | Admin smuggles extra fields |
PATCH /users/2 {"role":"USER","id":1,"email":"[email protected]","username":"renamed","deletedAt":"2026-01-01T00:00:00Z"} with $ADMIN_TOKEN
|
200, account unchanged except role — UpdateRoleRequest has no other fields, so id, email, username and deletedAt are ignored |
flowchart LR
T["setup token in env<br/>known only to the developers"] -->|"X-Setup-Token header<br/>while OWNER_SETUP_TOKEN is set"| O["OWNER(s)<br/>POST /auth/setup-owner"]
O -->|"dashboard promote button"| A["first ADMIN"]
A -->|"owners and admins<br/>promote / demote"| MORE["user ↔ admin"]
The first owner comes from the setup endpoint; the first ADMIN is then promoted by an owner through the admin dashboard.
-
The route:
POST /auth/setup-owner, inOwnerSetupControllerandOwnerSetupService. It takes email, username and password, validated with the same rules as sign-up. It is the only way to create an OWNER. -
What controls it:
-
Shared secret: the request must carry an
X-Setup-Tokenheader matching the server'sOWNER_SETUP_TOKEN, compared in constant time withMessageDigest.isEqual. A wrong or missing token gets a 403. - Off without a token: if the server has no token configured, the endpoint returns 503. The intended practice is to set the token, create the owner, then unset it.
-
No duplicates under concurrent calls: a database advisory lock (
acquireSetupLock) serialises setup calls, so two at once can't both pass the uniqueness checks.
-
Shared secret: the request must carry an
-
Things to know:
- Each call with a valid token creates another owner. The team removed the "only when no owner exists" limit.
- The token never expires; expiry was added and then deferred.
- The route sits under
/auth/**, which is open to anyone, so the token is the only protection.
-
The request: an ADMIN or OWNER opens
/adminand clicks Promote to Admin, then confirms in a dialog. The web app sendsPATCH /users/{id}with{"role":"ADMIN"}. -
Access check:
SecurityConfigallows it only if the token's role is ADMIN or OWNER; anyone else gets a 403. -
Business rules (
AdminService.changeRole):- Asking for
OWNERis refused (403, "OWNER can only be granted through owner setup"). - The target must be an active account, or it's a 404.
- The target must not be an OWNER (403).
- A promotion to the role the user already has returns 200 without saving anything.
- A simultaneous change that loses the race gets a 409, via the
@Versioncheck.
- Asking for
- When it takes effect: the new role is saved immediately, but permissions come from the login token. So the promoted user gets admin access at their next login, and their current token keeps the old role for up to an hour.
No — the server refuses them, and the web app doesn't show them the page.
| What a normal (USER) caller tries | What happens | Where it's enforced |
|---|---|---|
PATCH /users/{id} (promote/demote), or any admin route (GET /users, DELETE /users/{id}), with their own token |
403 problem+json, before the request reaches any controller code, so the body isn't even read |
SecurityConfig: everything under /users except GET /users/* and DELETE /users/me needs hasAnyRole("ADMIN","OWNER"); unlisted methods fail closed |
| The same call with no token, or a bad/expired one | 401 problem+json |
JwtAuthenticationFilter + SecurityConfig
|
Sending "role": "ADMIN" in their own sign-up |
ignored — the account is created as USER |
SignupRequest has no role field; AuthService sets Role.USER
|
Opening /admin in the browser |
redirected to the home page (logged out: to /login first) |
AdminRoute (web) checks the role from GET /users/me; ProtectedRoute handles logged-out visitors |
| Looking for an admin link | not shown — the Admin Dashboard nav link and mobile tab appear only for ADMIN and OWNER |
NavBar / TabBar (web) |
- The web checks only shape the UI. The server check is the real control: calling the API directly with a USER token still gets 403.
- Because the page check uses the current role (
/users/me) but the API trusts the role in the token, a demoted admin loses the/adminpage on their next page load but can keep calling admin APIs with their old token until it expires (up to 1 h) — the stale-role case in the table below.
Edge cases:
| Case | Behaviour |
|---|---|
| Admin demotes another admin | allowed (confirmed) |
| Admin demotes themselves | allowed — verified in code: AdminService.changeRole has no self-check; only OWNER targets are refused |
| Anyone demotes/removes an OWNER | 403 — owners cannot be demoted, by anyone |
| Last admin demotes/deletes themselves | allowed — owners are the backstop; zero admins is recoverable in-app |
| No owners left | recover via the setup endpoint with the setup token (it doesn't expire; unsetting OWNER_SETUP_TOKEN closes the route, and re-setting it is a deliberate developer action) |
| Stale role in a live token | accepted trade-off: up to 1 h (exp) — revisit with logout #90
|
PostgreSQL 16 + JPA. Why:
- Supplier metadata is fixed and structured — it comes straight from the course CSV; every CSV column maps to a typed column (below), nothing document-shaped.
- One genuinely relational need: a supplier has multiple categories (CSV
TypelikeFood/Coffeeis split into join-table rows) — F1.1.1 says "categories", plural. - Capacity (NFR2.1): 1 000 suppliers — NUS-scale, not a million-row marketplace. At that size a sequential scan clears the 5 s listing bound (NFR1.1) with no indexes (supplier-service.md D7); revisit only if the target grows.
Schema (required = NOT NULL; everything else optional — real CSV rows have gaps):
erDiagram
Suppliers {
bigint id PK "req - the identity"
text name "req - NOT unique, see branches note"
text category "opt - raw CSV Type cell"
text building "opt"
text floor "opt"
text location_description "opt"
double latitude "opt - distance sort + Order 1 km rule later"
double longitude "opt"
time starting_time "opt - CSV 0900hrs parsed to TIME"
time closing_time "opt"
text image_url "opt"
text supplier_description "opt - admin-filled"
}
SupplierCategories {
bigint id PK "req - shares supplier PK"
text category PK "req - one row per split Type value"
}
Suppliers ||--o{ SupplierCategories : "categorised as"
-
Types (mentor ask): text for names/locations,
doublelat/lng (real coordinates — Order Service's 1 km rule needs them), SQLTIMEfor opening hours parsed from the CSV's0900hrsformat, join rows for categories. -
Supplier identity (mentor question — same names, branches): the
idis the identity;nameis deliberately not unique. Starbucks @ YIH and Starbucks @ UTown are two rows, same name, distinguished bybuilding/floor/location_descriptionand coordinates. -
Validation (mentor question):
nameis the only hard-required field (DB NOT NULL). On the CRUD endpoints, Bean Validation enforces it plus format checks (numeric coordinate ranges, time format) — a request that bypasses the UI is rejected by the backend with 400 problem+json whosedetaillists every failing field, which is also what the UI shows.
Key patterns (all live): paged browse · search by name · filter by category · sort by name asc/desc · sort by distance ("nearest to me": UI sends the browser's lat/lng, ordering is Haversine in SQL) · supplier details from the list payload · no match → 200 with an empty page, UI shows the empty state (F2.2.1).
| Route | Returns | Access |
|---|---|---|
GET /suppliers?search=&category=&lat=&lng=&page=&size=&sort= |
PageResponse<SupplierResponse> — content[] {id, name, location, latitude, longitude, categories[], openingTime "HH:mm", closingTime "HH:mm", description, imageUrl} + page, size, totalElements, totalPages; defaults size 20, name asc |
any authenticated (#143) |
GET /suppliers/categories |
string[] — distinct categories, used only by the filter dropdown |
any authenticated |
POST /suppliers |
201 SupplierResponse
|
ADMIN/OWNER |
PUT /suppliers/{id} |
200 SupplierResponse
|
ADMIN/OWNER |
DELETE /suppliers/{id} |
204 (hard delete) | ADMIN/OWNER |
Denied requests: missing/invalid token → 401, wrong role → 403 — problem+json with the reason in detail (a regular user calling a restricted endpoint directly gets the 403, not a hidden button).
- Delete semantics — decided (team meeting, 2026-09-29): F1.1.3 is a hard delete. Future guard once the Order Service exists: deleting a supplier with outstanding orders is rejected with an error — referential integrity by refusal, not soft-delete. (supplier-service.md D5 records the decision.)
- The three write endpoints shipped in #153 (merged 2026-09-30, closing #106): Bean Validation on
SupplierRequest, 404 problem+json for a missing id, and ademo-crud.shwalkthrough alongside the service. - Backend works without the UI: every operation above is demoable with curl while the web container is stopped — the demo script logs in against user-service for a real JWT, then drives every supplier endpoint, showing accepted and rejected calls.
Full flow: login → JWT → supplier API → database, with role-appropriate outcomes (Part 1 §4 diagram).
- UI reflects the role — non-admins see no "add supplier" button and no edit/delete on supplier cards — and the backend enforces it independently: the same action via curl returns 403 problem+json. Restricted buttons are hidden and the endpoints are gated (the mentor's "merely hidden in the UI?" question).
- Accepted/rejected pairs per role are scripted in the demo script.
- UI in place: the Admin Dashboard nav link / mobile tab appears for ADMIN and OWNER (#147); supplier management lives on the Suppliers page (add, plus edit/delete on supplier cards — modals shipped with #153). The admin dashboard shows only the Users section for now — its Suppliers section was removed (#158, Ryan Ang, 2026-09-30).
- Workflows live on real API data (no mocks): browse, search, filter, sort, paginate, details; admin create/edit/delete modals wired to §3's endpoints (#153).
- Desktop
NavBar/ mobile bottomTabBar; every screen at both widths. - Demo data: seeded (#155) —
DemoAccountsSeederinserts 1 owner, 3 admins and 100 users at startup whenUSER_SEED_DEMO=true(off by default; passwords documented in the user-service README, local/demo only) — so pagination, search and role scenarios look real.
| # | Rubric | Show |
|---|---|---|
| 1 | P1.1 | role matrix + live USER/ADMIN/OWNER accounts |
| 2 | P1.2 | schema; a BCrypt-hashed row; pending_signups mid-signup |
| 3 | P1.3 | login → decoded JWT; endpoint with/without/expired token (logout deferred — say it, tokens die in 1 h) |
| 4 | P1.6 | setup-owner with token → owner promotes first admin → demote; owner-untouchable refusal |
| 5 | P1.5 | update carrying role has no effect (allow-list DTO) |
| 6 | P2.1–3 | supplier CRUD + queries via curl script, web container stopped |
| 7 | P1.4/P2.4 | admin vs user token on supplier writes: 2xx vs 403 |
| 8 | P2.5 | supplier UI, desktop + mobile, live data |
| 9 | wrap | diagrams + "why" from the deciders |
- Before demo: merge #149 (profile screens) and, if ready, #157 (#92's account updates). Everything else on the old list has merged — #143, #150, supplier writes (#153), the demo seed (#155), the
/adminnav link (#147). - Next: the rest of #109 (logout, token handling — its sign-up/OTP screens shipped in #150) · logout denylist #90 · account update #92 — in review as #157, reusing
OtpServiceand the pending-table-per-operation shape · recovery/reset #94. - Prepared answers: NFR1.2 email-hashing inconsistency (Part 1 §2) · supplier rename vs order history (delete is decided; rename/snapshot question goes to Order Service design).
Migrated from Lark 2026-09-29 (original frozen); refreshed from the PR record and the 2026-09-29 team meeting. Diffs live on the PRs.