Appearance
Field Ops (NFC "Upvendo Tap" Review Stands) — Architecture Overview
⚠️ NOT merchant-facing
Field Ops is an internal sales program, not an Upvendo product feature. Field representatives place physical NFC stands at prospect businesses — businesses that are not yet Upvendo merchants. A customer taps the stand with their phone and is redirected to that business's Google review page. Activating a stand dispatches a CRM lead into Odoo so the sales team can follow up.
Consequences that matter:
- No merchant ever sees this. There is no merchant-facing surface, no merchant setting, and no merchant documentation. Do not add any.
- Field Ops data is not tenant-scoped. Every model lives on the global
mongodbconnection with novendor_id(verified:app/RawModels/Stand.php,StandBatch.php,Representative.php,FieldInvite.php,FieldSession.php,MediaAttachment.php— allCONNECTION = 'mongodb'; a grep forvendor_idacross all six returns zero matches). There is no per-record ownership to assert, which is exactly why the route gate is the authorization boundary — see below.- A merchant owner reaching this data would be reading and writing every other merchant's prospect pipeline. That is the escalation the authorization model exists to prevent.
Repositories: upvendo-backend (API), upvendo-field (rep app), upvendo-backoffice (admin pages), plus a Cloudflare "tap worker" that is not in any of these checkouts. Branch read for this doc: origin/production in each repo.
1. Authorization model — read this section exactly
This is the most important part of the system and the easiest to get wrong.
The single source of truth
app/Services/FieldOps/FieldOpsAuthorityService::hasGlobalFieldOpsAuthority(User $user) (:26-55) iterates the user's roles and applies three rules in this order:
| # | Rule | Cite |
|---|---|---|
| 1 | Any role carrying a merchant_id is skipped outright — continue, never evaluated further. | :37-39 |
| 2 | A role whose slug is global-administrator returns true immediately. | :40-42 |
| 3 | Otherwise the role must carry VIEW_FIELD_OPS and sit at level LEVEL_GLOBAL or LEVEL_STAFF. | :48-51 |
Falls through to false (:54).
Why rule 1 exists
RolesRepair mints a per-merchant admin role — slug = 'admin', level = 'global', is_system = true, and merchant_id set — whose expanded permissions include VIEW_FIELD_OPS, because Permissions::getAllPermissions('admin') merges fieldOpsManagement() (app/Constants/Permissions.php:961-963).
So a merchant owner who self-assigns that admin role does hold view-field-ops and manage-field-ops, and does sit at level = global. Every naive check passes. Rule 1 is the only thing that stops them: a genuine platform role (global-administrator, field-ops) has a null merchant_id; an org-bound one does not.
The rationale is documented in the service's class docblock (:9-23) and again in the middleware (:16-32), where it is tagged "audit follow-up 2026-07-19" (:25). It is explicitly stricter than PermissionService::hasPermission($user, VIEW_FIELD_OPS, null, null), which skips the merchant gate when merchantId is null and then accepts any * role — the H1/H2 over-grant (FieldOpsAuthorityService.php:13-19).
Where the gate is applied
EnsureGlobalFieldOpsAuthority is registered as the alias field-ops-authority (bootstrap/app.php:54) and applied to the entire /back-office/field-ops prefix in one place:
php
Route::middleware('field-ops-authority')->prefix('/field-ops')->group(function () { … });routes/api/backoffice/field-ops.php:15. The reason is stated at :7-14: Field Ops models live on the global connection with no vendor_id, so there is no per-record ownership to assert — the route gate IS the authorization boundary.
The middleware fails closed and returns 403, not 401 — deliberately, because this is an authorization gate rather than an authentication one (EnsureGlobalFieldOpsAuthority.php:42-51). A missing user is also denied.
It is defense in depth on top of the per-route permission: middleware, not a replacement for it (:27-32). Both layers are present on every route.
When touching this code: never relax rule 1 to "level is global", never substitute
PermissionService::hasPermission, and never move the gate from the group to individual routes.tests/Feature/BackOffice/FieldOps/FieldOpsAuthorityGateTest.phpandtests/Unit/Services/FieldOps/FieldOpsAuthorityServiceTest.phpexist to catch exactly that.
2. The two route surfaces
2a. Back-office admin surface
routes/api/backoffice/field-ops.php:15-43. Gated by field-ops-authority at the group level, plus a per-route permission: gate.
| Method | Path (under /back-office/field-ops) | Permission | Cite |
|---|---|---|---|
| GET | /users/lookup (minimal-PII lookup for rep→admin linking, throttle:30,1) | manage-field-ops | :17-18 |
| GET | /reps | view-field-ops | :21 |
| POST | /reps | manage-field-ops | :22 |
| GET | /reps/{id} | view-field-ops | :23 |
| PATCH | /reps/{id} | manage-field-ops | :24 |
| POST | /reps/{id}/invite | manage-field-ops | :25 |
| DELETE | /reps/{id}/invite | manage-field-ops | :26 |
| POST | /reps/{id}/deactivate | manage-field-ops | :27 |
| GET | /reps/{id}/placements | view-field-ops | :28 |
| GET | /batches | view-field-ops | :32 |
| POST | /batches | manage-field-ops | :33 |
| GET | /batches/{id} | view-field-ops | :34 |
| GET | /stands | view-field-ops | :38 |
| GET | /stands/{id} | view-field-ops | :39 |
| PATCH | /stands/{id} | manage-field-ops | :40 |
| PATCH | /stands/{id}/media/{mediaId} | manage-field-ops | :41 |
2b. Field rep app surface
routes/api/field.php:18-66, under the field prefix.
Two routes are unauthenticated (invite redemption and refresh rotation), each rate-limited:
| Method | Path | Throttle | Cite |
|---|---|---|---|
| POST | /field/auth/redeem | 10,1 | :20-21 |
| POST | /field/auth/refresh | 30,1 | :22-23 |
Everything else sits behind ['auth', 'type:field'] (:25):
| Method | Path | Throttle | Cite |
|---|---|---|---|
| POST | /field/auth/pin | 10,1 | :26-27 |
| POST | /field/auth/logout | 30,1 | :28-29 |
| POST / DELETE | /field/auth/push-token | 30,1 | :33-36 |
| GET | /field/me | 60,1 | :38-39 |
| GET | /field/metrics | 30,1 | :43-44 |
| POST | /field/stands/allocate | 30,1 | :49-50 |
| GET | /field/stands/{code} | 60,1 | :52-53 |
| POST | /field/stands/{code}/activate | 15,1 | :54-55 |
| GET | /field/business/search | 30,1 | :57-58 |
| GET | /field/business/{placeId} | 60,1 | :59-60 |
| POST | /field/media/presign | 30,1 | :62-63 |
| POST | /field/media/commit | 30,1 | :64-65 |
Field JWTs are minted against a FieldSession tokenable, never a User. type:field requires exactly that class and an active session (app/Http/Middleware/TokenType.php:58-64), so back-office tokens are rejected here — and field tokens are rejected on back-office routes, which requires get_class($tokenable) === User::class (TokenType.php:53-57). This is rejection by construction, not by permission check (routes/api/field.php:10-14, app/RawModels/FieldSession.php:22-29).
Scope is resolved server-side. No ?scope or ?rep query parameter is ever honoured (routes/api/field.php:41-42); every authenticated query is scoped to the session's rep (:14).
3. Permissions and roles
Permissions
| Constant | Value | Cite |
|---|---|---|
Permissions::VIEW_FIELD_OPS | view-field-ops | app/Constants/Permissions.php:474 |
Permissions::MANAGE_FIELD_OPS | manage-field-ops | app/Constants/Permissions.php:476 |
Both are exposed as a group via Permissions::fieldOpsManagement() (:902-906), which is merged into admin and global-administrator permission expansion (:961-963) — the merge that makes rule 1 of the authority check necessary.
Roles
| Constant | Slug | Seeded level | Cite |
|---|---|---|---|
Roles::FIELD_OPS | field-ops | LEVEL_GLOBAL (set explicitly — the slug lacks a global prefix so levelForSlug() cannot infer it) | app/Constants/Roles.php:29; app/Console/Commands/SeedFieldOpsRoles.php:27-35 |
Roles::GLOBAL_REP | global-rep | LEVEL_GLOBAL | Roles.php:27; SeedFieldOpsRoles.php:36-41 |
Roles::RESELLER_REP | reseller-rep | LEVEL_RESELLER | Roles.php:36; SeedFieldOpsRoles.php:42-47 |
Seeded by php artisan field-ops:seed-roles (SeedFieldOpsRoles.php:21).
This command is create-if-missing only: it never overwrites an existing role, never deletes, and never touches user assignments, so it is safe to re-run against a live production database (
:10-17,58-65). This is deliberately unlikeRolesAndPermissionsSeeder, which deletes allrole_user+rolesand reassigns Merchant Owner to every user — safe only on a fresh DB. The deploy runbook callsfield-ops:seed-roles, not the full seeder (:12-17).
Note the level asymmetry: field-ops is seeded at LEVEL_GLOBAL, but the authority check accepts both LEVEL_GLOBAL and LEVEL_STAFF, because levelForSlug('field-ops') would otherwise default to LEVEL_STAFF (FieldOpsAuthorityService.php:43-47).
4. Enums
| Enum | Values | Notes | Cite |
|---|---|---|---|
StandStatus | unassigned, placed, active, dead, lost, disabled | app/Enums/StandStatus.php:7-12 | |
StandMode | redirect, landing | v1 is redirect-only. landing ("Smart Tap") has no v1 write path — every activation hard-writes redirect | app/Enums/StandMode.php:7-11; ActivateStandOrchestrator.php:138-141 |
RepKind | staff, temp, partner | null on commission-only (non-field) reps | app/Enums/RepKind.php:7-9; Representative.php:32 |
MetricsScope | own, team | derived kind-only — see below | app/Enums/MetricsScope.php:16-19 |
MetricsScope is kind-only — admin linkage never widens it
FieldMetricsScopeResolver::decide(?string $kind) is a pure, I/O-free function:
php
return $kind === RepKind::Staff->value ? MetricsScope::Team : MetricsScope::Own;app/Services/FieldOps/FieldMetricsScopeResolver.php:45-50. team for staff reps, own for everyone else. Nothing else feeds it.
Being linked to a back-office admin is a separate signal (adminLinked) that unlocks the back-office deep-link button only — it never widens the data tier. An own-scope rep can still be admin-linked (:20-23, FieldMetricsService.php:60-69). This separation is the redesign that closed the H1/H2 privilege-escalation flaw: a field credential must never inherit a data tier from a linked back-office user (MetricsScope.php:10-14).
adminLinked is re-evaluated on every request and never cached in the JWT, so a deactivated or permission-stripped admin loses the link on the very next call (FieldMetricsScopeResolver.php:25-26). It requires an active, non-soft-deleted user that passes FieldOpsAuthorityService (:68-87).
5. Data model
All six models live on the global mongodb connection, with no vendor_id.
| Model | Collection | Purpose |
|---|---|---|
Stand | stands | One physical NFC stand: code, status, business, review_url, placing/credited rep, geo, media, actions[] |
StandBatch | stand_batches | A minted print run of stands + its QR manifest object key |
Representative | representatives | A field rep (kind, market/country, placement_fee, odoo_user_id, optional user_id admin link) |
FieldInvite | field_invites | A single-use invite: hashes only of the long token and the short code |
FieldSession | field_sessions | A device-bound rep session — the JWT tokenable |
MediaAttachment | media_attachments | Placement photos (object_key, owner_shared_ok, cleared_for_social) |
Model file references: app/RawModels/{Stand,StandBatch,Representative,FieldInvite,FieldSession,MediaAttachment}.php (each declares CONNECTION = 'mongodb' on line 9).
Stand codes
8-character uppercase Crockford base32, alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ — ambiguous I/L/O/U excluded, ~40 bits of entropy (app/Services/FieldOps/StandCodeGenerator.php:7-9; alphabet also in config/field_ops.php:43-46).
The tap worker's route regex is FROZEN at ^[0-9A-HJKMNP-TV-Z]{8}$ (StandCodeGenerator.php:17-22). Code generation hard-fails on config drift that would produce codes the worker cannot route, because a silent drift would break every already-printed stand (:11-13,88).
Public tap URL
{tap_base_url}/r/{CODE}, built by app/Traits/TapUrlTrait.php:14-17, shared by the allocate and activate paths so the two can never diverge on what gets written to a physical tag (:8-10). Default base is https://tap.upvendo.com (config/field_ops.php:17-18).
KV contract with the tap worker
StandKvService writes one Cloudflare KV entry per stand under key r:{CODE} (uppercased, :15,97-102). The value shape is the frozen worker contract — exactly three keys:
json
{ "review_url": "https://search.google.com/local/writereview?placeid=…", "status": "active", "env": "production" }app/Services/FieldOps/StandKvService.php:8-11,29-34. status is active or disabled (StandService.php:205). env comes from FieldOpsEnvironmentTrait mapping app.env → testing / staging / production (app/Traits/FieldOpsEnvironmentTrait.php:11-22).
6. Invite → session lifecycle
- Generate (
POST /back-office/field-ops/reps/{id}/invite) —FieldInviteService::generate()(:42-88) revokes any outstanding unused invite for the rep (single outstanding invite per rep,:62-63), then mints a 32-byte long token and an 8-char short code from the same Crockford alphabet as stand codes so the rep's input normaliser can be equally forgiving (:19-26). Only SHA-256 hashes are stored (:69-76). The invite URL is{app_url}/invite/{token}; the rep receives the link, the code, and a QR SVG. Emailed when the rep has an address on file (:78-87). - Redeem (
POST /field/auth/redeem) —FieldAuthService::redeem()(:47-102) consumes the invite atomically by long token or short code (:58-60; unknown / used / expired / revoked → 410,FieldInviteService.php:90-107), rejects an inactive rep with 403 (:64-70), then creates a device-boundFieldSessionand returns{field_jwt, refresh_token, rep, needs_pin: true}. - Set PIN (
POST /field/auth/pin) — stored as a bcrypt hash; resets the failure counter and any lockout (FieldAuthService::setPin(),:104-115). - Refresh (
POST /field/auth/refresh) — rotating refresh tokens. Onlysha256hashes of the current and the rotated-out predecessor are stored, so replay of a rotated-out token is detected and revokes the session. Device binding and PIN (with lockout) are enforced (FieldAuthService.php:117-129;FieldSession.php:16,22-29,34-37).
Session status is active or revoked (FieldSession.php:18-20).
TTLs and lockouts
All from config/field_ops.php:89-94, all env-overridable:
| Setting | Default | Env var |
|---|---|---|
invite_ttl_hours | 72 | FIELD_OPS_INVITE_TTL_HOURS |
field_jwt_ttl_min | 30 | FIELD_OPS_FIELD_JWT_TTL_MIN |
refresh_ttl_days | 30 | FIELD_OPS_REFRESH_TTL_DAYS |
pin_max_attempts | 5 | FIELD_OPS_PIN_MAX_ATTEMPTS |
pin_lockout_min | 15 | FIELD_OPS_PIN_LOCKOUT_MIN |
Media presign expiry is 15 minutes and the max upload is 100 MB (:96-98).
App Store review backdoor — non-production only
review_demo_code / review_demo_rep_id (config/field_ops.php:79-87) define a config-gated invite code that always resolves to a fixed demo rep and is never consumed or expired — so Apple Beta App Review can get past the single-use / 72 h invite gate. FieldInviteService adds a hard non-production guard, and both values are empty by default. Set them only on the testing backend.
7. Stand lifecycle
text
batch mint ──► allocate (optional, gated) ──► place ──► activate ──► CRM lead
│
kill switch (disable) ◄────────┤
orphan sweep (unactivated) ◄────┘Batch mint
StandBatchService::create() (:36-90): ensures the unique index on stands.code as the collision backstop (:45-46), generates N unique codes, inserts one unassigned stand document per code stamped with the batch's market/country and env (:65-76), then dispatches GenerateStandManifestJob for the printable QR manifest (:85). KV is not written at mint time — a printed-but-unactivated tag is inert.
Allocate (native NFC write) — gated off by default
POST /field/stands/allocate mints one fresh server-allocated code for the native app to write onto a blank tag. The server is the sole code allocator — a client-invented code would 404 on lookup and could violate the frozen worker regex (app/Services/FieldOps/AllocateStandService.php:15-24). KV is deliberately not written here either (:21-24). The feature gate is asserted before the rep lookup so a disabled endpoint never touches the datastore (:52-61).
Activate
ActivateStandOrchestrator::activate() (:62-180) — the core of the program:
- Rep must exist and be active, else 403 (
:68-76). - Stand must exist, else 404 (
:78-82). - Already
active→ idempotent path (:86-88). - Status must be
unassignedorplaced, else 409 (:90-96). - A
placedstand belongs to whoever placed it — another rep gets a 404, not a 403, so placements are not enumerable (:98-101). - A placement photo is mandatory: the media must exist, belong to the calling rep, and be
MediaKind::Photo, else 422 (:103-111). - Credit resolution — defaults to the caller. Crediting someone else requires
kind === staffand the rep'sallowsOnBehalf()flag; temp reps can never credit others (403). The credited rep must exist and be active (422) (:249-279). - Review URL derived as
https://search.google.com/local/writereview?placeid=%s(:35,115). - Atomic compare-and-swap claim —
activateAtomically()means only one of N concurrent activations of a code wins the transition. The loser getsnulland is routed to the idempotent / 409 path, so KV ownership, DB ownership and the CRM lead all stay single-writer: no split-brain, no double lead (:119-152). - Winner publishes to KV. On KV failure the claim is rolled back so the stand stays activatable — no orphaned "active-in-DB, dead-in-KV" state (
:154-164). - Photo is linked to the now-active stand (
:166-174). - CRM lead dispatch + optional credited-rep push (
:176-177).
mode is always written as redirect, and actions[] is seeded with exactly one enabled google_review action whose url is the derived review_url — so a later Smart Tap needs no data migration (:138-142,362-379).
CRM lead dispatch (Odoo)
dispatchCrmLead() (:281-324) builds the payload entirely from the persisted stand, so a re-dispatch is consistent:
json
{
"type": "field_visit",
"country": "be",
"answers": { "company": "…", "address": "…", "pos": "…", "note": "…" },
"businessPlaceId": "…",
"businessAddress": "…",
"placedByRepId": "…",
"creditedToRepId": "…",
"salespersonOdooUid": 7
}Dispatched as CreateOdooLeadJob with idempotency key field_visit_{CODE}_{PLACE_ID} (:309-312). The crm_lead_dispatched flag is set only after a successful enqueue (:313-315); a queue failure is logged and the activation still succeeds, and a later re-activation re-dispatches until the flag is set (:232-237,281-286).
salespersonOdooUid is the credited rep's own odoo_user_id when set, otherwise the default closer for the stand's market — never the credited rep's market, so an on-behalf credit to an out-of-market rep cannot misroute a lead (:326-344). Defaults come from field_ops.default_closers, a JSON map like {"be":7,"us":12} (config/field_ops.php:32-34). country is normalised to the frozen CRM intake enum com|be|nl|fr|de (:381-400).
Kill switch (disable)
StandService PATCH handling syncs KV before persisting the status change and aborts the whole PATCH if the KV write fails (:200-218). The comment states the reason plainly: the disable path is the safety valve and must not fail open — "a 'disabled' stand that keeps redirecting live is the exact hazard" (:200-203). This covers disable and re-enable. A KV failure raises an IntegrationException for provider cloudflare (:212-217).
Orphan sweep
php artisan field:sweep-orphan-allocations [--days=N] (app/Console/Commands/SweepOrphanStandAllocations.php:32) reaps field-allocated stands that were written to a blank tag but never activated — status = unassigned, placed_by_rep_id set, batch_id null — older than N days (default 3, config/field_ops.php:71-73).
Action is dead (marks StandStatus::Dead; codes are never recycled) or delete (soft-delete) (config/field_ops.php:74-76).
This is bookkeeping hygiene, not a live-tap risk: an allocated-but-unactivated tag is inert because activation is what writes KV — the tap worker sees raw === null for the code and serves its fallback page, so a reaped and a never-reaped orphan behave identically to a customer tapping them (SweepOrphanStandAllocations.php:12-23). The command is a no-op while native allocation is disabled (:43-47).
8. Inert by default — two feature gates
Both default to false in config/field_ops.php, so merging Field Ops changes nothing live until an environment explicitly enables them:
| Flag | Default | Env var | Effect when off | Cite |
|---|---|---|---|---|
native_allocate_enabled | false | FIELD_OPS_NATIVE_ALLOCATE_ENABLED | POST /field/stands/allocate 404s, and the orphan sweep is a no-op | config/field_ops.php:48-52; routes/api/field.php:46-48; SweepOrphanStandAllocations.php:43-47 |
push_enabled | false | FIELD_OPS_PUSH_ENABLED | FieldPushService is a no-op and the credited-rep push job is never even enqueued | config/field_ops.php:54-57; ActivateStandOrchestrator.php:196-200 |
Push has a second gate: push_firebase_credentials is empty by default, and it points at a separate Firebase project from the main firebase.credentials ('app') project so the two never cross. With credentials unset, FieldPushService stays a no-op even with push_enabled on (config/field_ops.php:59-64).
The field JWT's push-token routes (POST/DELETE /field/auth/push-token) exist and store the token (routes/api/field.php:31-36), and the send layer has shipped too:
app/Services/FieldOps/FieldPushService::sendToRep(repId, title, body, data)builds a private Kreait FirebaseFactoryagainst the separate field project credentials, multicasts to every activeFieldSessiontoken for that rep, and prunes unknown tokens from the report.app/Jobs/SendFieldPushJob.phpdispatches it fromActivateStandOrchestrator::notifyCreditedRep().- It is best-effort and never throws into its caller.
- It fires only when a placement is credited to someone other than the placing rep (the winner path, on-behalf credits only) — there is no re-notify on re-activation. Title is
New placement credited to you, pinned toapp.fallback_locale, data{route: '/', standCode}.
It is inert in practice only because field_ops.push_enabled defaults to false, which gates the enqueue. The §8 gate table above is correct as written.
The field app registers and deregisters its FCM token against those two routes.
9. Back-office pages
Vue file-based pages under upvendo-backoffice origin/production:
| Page | File | Route name |
|---|---|---|
| Team list | src/pages/field-ops/team/index.vue | field-ops-team |
| Rep detail | src/pages/field-ops/team/[id].vue | — |
| Stands list | src/pages/field-ops/stands/index.vue | field-ops-stands |
| Stand detail | src/pages/field-ops/stands/[id].vue | — |
Navigation lives at src/navigation/vertical/index.ts:377-403 — a "Field Ops" group with a tabler-map-pin icon and two children. Every entry is gated on action: 'view' / subject: 'global-settings' plus the literal permission: 'view-field-ops'; permission takes precedence in canShowNavItem / canViewNavMenuGroup, so the nav gate mirrors the backend's permission middleware exactly (:276-279).
The nav gate is a permission check only. The API gate is
field-ops-authority(§1), which is strictly stronger. An org-boundadminmay therefore see the menu item and then receive a 403 from every request the page makes. That is by design — the API is the boundary.
10. The field rep app (upvendo-field)
A mobile-first Tailwind PWA that also ships as native iOS and Android via Capacitor 7 + Capgo (upvendo-field origin/production CLAUDE.md:7-18; capacitor.config.ts sets appId com.upvendo.field, CLAUDE.md:175-177). Vue 3 + TypeScript strict, Pinia, vue-router, vue-i18n with 4 locales (en, nl, fr, de), Vitest with happy-dom, Node >= 22 (CLAUDE.md:24-34). Not Vuetify/Vuexy (:20-22).
The app appends /field to VITE_API_URL and, on 401, runs the registered refresh handler once and retries (CLAUDE.md:52-53,75-78). Capgo posture is directUpdate: false — deferred reload — so a mid-flow reload never disrupts the offline queues (:178-181). Unlike the POS, the field app does have an offline activation/upload queue (:65-66,187-192).
Routes and guards
Seven routes plus a catch-all redirect to / (src/router/index.ts:9-49).
| Route | Name | Guard |
|---|---|---|
/ | home | requiresAuth |
/activate | activate | requiresAuth — the stand wizard |
/metrics | metrics | requiresAuth |
/invite/:token | invite | public |
/pin-setup | pin-setup | requiresAuth |
/unlock | unlock | — |
/welcome | welcome | — |
/:pathMatch(.*)* | — | redirect → / |
The guard resolves auth.ensureSession(): locked → /unlock?redirect=, signedOut → /welcome. Both active and error proceed — the 401 → refresh path self-heals once connectivity returns, so bouncing on error would lock a rep out of a working app.
Metrics screen
Reads GET /field/metrics through a store that keeps a stale snapshot on a failed refresh and only sets error when there is nothing at all to render. The server decides the scope, so the client does not compute team-vs-individual visibility.
NFC write
Uses the first-party NfcPlugin (ios/App/App/NfcPlugin.swift); iOS was proven on device 2026-07-15. Android is not built, so it degrades to printed-code entry. already_assigned and locked are non-rejection outcomes — handle them as ordinary results, not errors. The paid @capawesome-team/capacitor-nfc package was explicitly rejected (see the open blocker below).
Environments and hosting
Only
testing.field.upvendo.comis live. The committed.env.productionsetsVITE_API_URL=https://proxy.testing.upvendo.com/api— a production build therefore points at the testing proxy. Its own comment says so: "When a real production field web app is stood up, overrideVITE_API_URLfor it." Do not read a production build as production-backed.
Biometric unlock — blocker A is RESOLVED (verified 2026-07-27)
Biometric unlock is now OS-enforced through a first-party Capacitor plugin registered under the JS name SecureUnlock (ios/App/App/SecureUnlockPlugin.swift, exposing isAvailable, setSecret, getSecret, deleteSecret; keychain service com.upvendo.field.secure-unlock).
- ACL:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly+SecAccessControl(.biometryCurrentSet). The read requires Face ID / Touch ID, enforced by the OS rather than by app-side call ordering. - The item never leaves the device and is destroyed if the enrolled biometric set changes.
- It releases the stored PIN into the unchanged
auth.unlockWithPin(pin) → refresh(pin)path, so there is still zero backend or contract change. The secret is wiped inuseLogout. - This replaces
@capgo/capacitor-native-biometric, whosegetCredentials()returned the plaintext with no prompt at all. - Android is served by the same
SecureUnlockplugin name (KeystoresetUserAuthenticationRequired).
upvendo-field CLAUDE.md:196-201is stale on this point — it still describes blocker A as open. Do not re-cite it; the code is the source of truth here.
One blocker still open — do not document as shipped
| Blocker | Status | Cite |
|---|---|---|
TODO(blocker-C) — NFC write module | The native NFC module is bound via registerPlugin('Nfc') and provisioned at spike time, and ios/App/App/NfcPlugin.swift exists. @capawesome-team/capacitor-nfc is not on public npm, so it is still not a dependency — confirmed absent from package.json. | upvendo-field CLAUDE.md:187-195 |
Signing is likewise deferred: the keystore and keystore.properties are gitignored and must not be committed (CLAUDE.md:202-203).
11. Not verifiable from these repositories
Stated explicitly so nobody fills these gaps by guessing.
| Question | Why |
|---|---|
| Does a tap return HTTP 301 specifically? | The Cloudflare tap worker is not in any local checkout. The backend only ever describes it indirectly: it "redirects to review_url" (app/RawFactories/StandFactory.php:34) and serves a fallback page when the KV key is missing (SweepOrphanStandAllocations.php:20-21). The exact redirect status code, the fallback page, and the worker's own routing are unverified here. |
| Does activation accrue a placement fee? | No accrual was found. placement_fee {amount_cents, currency} is a configuration field on the Representative model (app/RawModels/Representative.php:33,52,146; validated at StoreFieldRepRequest.php:30-32 / UpdateFieldRepRequest.php:30-32; per-market default at config/field_ops.php:24-30). ActivateStandOrchestrator writes no ledger entry and no commission record — a grep for commission/ledger across app/Services/FieldOps/ and app/Services/Orchestrators/FieldOps/ returns only Representative.conversion_commission_eligible (a flag) and FieldMetricsService.php:21 explicitly noting "no money/ledger". Treat the fee as a configured rate, not an accrued transaction. |
| Tap counts / analytics | TapAnalyticsService reads Cloudflare Analytics Engine via its SQL API — the datapoints are written by the tap worker, which is out of tree (app/Services/FieldOps/TapAnalyticsService.php:9-13; config/cloudflare.php:33). Reads are fail-open and cached for 5 minutes. |
12. Debugging pointers
- A user gets 403 on every
/back-office/field-ops/*call despite holdingview-field-ops— almost always rule 1: one of their roles carries amerchant_id, and none of their roles is a genuine platform role. Checkrole.merchant_idisnull, not just thatlevel === 'global'(FieldOpsAuthorityService.php:37-39). - 403 where you expected 401 — intentional. The gate is an authorization gate and fails closed with 403 even for a missing user (
EnsureGlobalFieldOpsAuthority.php:42-51). - A field token 403s on a back-office route (or vice versa) — by construction, not by permission.
TokenTypedoes a strict class check on the tokenable (TokenType.php:53-64). - A rep sees
ownmetrics but expectedteam— the data tier isRepKindonly. Being admin-linked does not widen it; that only adds theadmin.backoffice_urldeep link (FieldMetricsScopeResolver.php:45-50;FieldMetricsService.php:60-69). - Stand PATCH returns an integration error and nothing changed — correct behaviour. The KV sync runs before persistence and aborts the PATCH on failure so a "disabled" stand can never keep redirecting (
StandService.php:200-218). POST /field/stands/allocate404s —field_ops.native_allocate_enabledisfalse, which is the default (config/field_ops.php:52).- Activation succeeded but no Odoo lead appeared — the enqueue failed; the stand is live and
crm_lead_dispatchedstayedfalse. A re-activation by the same rep for the sameplace_idre-dispatches (ActivateStandOrchestrator.php:232-237,311-323). - Two reps activated the same code simultaneously — one wins the atomic claim; the loser resolves as idempotent (same rep + same place) or 409 "already active at another business" (
ActivateStandOrchestrator.php:119-152,230-247).