Skip to content

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 mongodb connection with no vendor_id (verified: app/RawModels/Stand.php, StandBatch.php, Representative.php, FieldInvite.php, FieldSession.php, MediaAttachment.php — all CONNECTION = 'mongodb'; a grep for vendor_id across 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:

#RuleCite
1Any role carrying a merchant_id is skipped outrightcontinue, never evaluated further.:37-39
2A role whose slug is global-administrator returns true immediately.:40-42
3Otherwise 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 roleslug = '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.php and tests/Unit/Services/FieldOps/FieldOpsAuthorityServiceTest.php exist 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.

MethodPath (under /back-office/field-ops)PermissionCite
GET/users/lookup (minimal-PII lookup for rep→admin linking, throttle:30,1)manage-field-ops:17-18
GET/repsview-field-ops:21
POST/repsmanage-field-ops:22
GET/reps/{id}view-field-ops:23
PATCH/reps/{id}manage-field-ops:24
POST/reps/{id}/invitemanage-field-ops:25
DELETE/reps/{id}/invitemanage-field-ops:26
POST/reps/{id}/deactivatemanage-field-ops:27
GET/reps/{id}/placementsview-field-ops:28
GET/batchesview-field-ops:32
POST/batchesmanage-field-ops:33
GET/batches/{id}view-field-ops:34
GET/standsview-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:

MethodPathThrottleCite
POST/field/auth/redeem10,1:20-21
POST/field/auth/refresh30,1:22-23

Everything else sits behind ['auth', 'type:field'] (:25):

MethodPathThrottleCite
POST/field/auth/pin10,1:26-27
POST/field/auth/logout30,1:28-29
POST / DELETE/field/auth/push-token30,1:33-36
GET/field/me60,1:38-39
GET/field/metrics30,1:43-44
POST/field/stands/allocate30,1:49-50
GET/field/stands/{code}60,1:52-53
POST/field/stands/{code}/activate15,1:54-55
GET/field/business/search30,1:57-58
GET/field/business/{placeId}60,1:59-60
POST/field/media/presign30,1:62-63
POST/field/media/commit30,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

ConstantValueCite
Permissions::VIEW_FIELD_OPSview-field-opsapp/Constants/Permissions.php:474
Permissions::MANAGE_FIELD_OPSmanage-field-opsapp/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

ConstantSlugSeeded levelCite
Roles::FIELD_OPSfield-opsLEVEL_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_REPglobal-repLEVEL_GLOBALRoles.php:27; SeedFieldOpsRoles.php:36-41
Roles::RESELLER_REPreseller-repLEVEL_RESELLERRoles.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 unlike RolesAndPermissionsSeeder, which deletes all role_user + roles and reassigns Merchant Owner to every user — safe only on a fresh DB. The deploy runbook calls field-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

EnumValuesNotesCite
StandStatusunassigned, placed, active, dead, lost, disabledapp/Enums/StandStatus.php:7-12
StandModeredirect, landingv1 is redirect-only. landing ("Smart Tap") has no v1 write path — every activation hard-writes redirectapp/Enums/StandMode.php:7-11; ActivateStandOrchestrator.php:138-141
RepKindstaff, temp, partnernull on commission-only (non-field) repsapp/Enums/RepKind.php:7-9; Representative.php:32
MetricsScopeown, teamderived kind-only — see belowapp/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.

ModelCollectionPurpose
StandstandsOne physical NFC stand: code, status, business, review_url, placing/credited rep, geo, media, actions[]
StandBatchstand_batchesA minted print run of stands + its QR manifest object key
RepresentativerepresentativesA field rep (kind, market/country, placement_fee, odoo_user_id, optional user_id admin link)
FieldInvitefield_invitesA single-use invite: hashes only of the long token and the short code
FieldSessionfield_sessionsA device-bound rep session — the JWT tokenable
MediaAttachmentmedia_attachmentsPlacement 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.envtesting / staging / production (app/Traits/FieldOpsEnvironmentTrait.php:11-22).


6. Invite → session lifecycle

  1. 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).
  2. 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-bound FieldSession and returns {field_jwt, refresh_token, rep, needs_pin: true}.
  3. Set PIN (POST /field/auth/pin) — stored as a bcrypt hash; resets the failure counter and any lockout (FieldAuthService::setPin(), :104-115).
  4. Refresh (POST /field/auth/refresh) — rotating refresh tokens. Only sha256 hashes 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:

SettingDefaultEnv var
invite_ttl_hours72FIELD_OPS_INVITE_TTL_HOURS
field_jwt_ttl_min30FIELD_OPS_FIELD_JWT_TTL_MIN
refresh_ttl_days30FIELD_OPS_REFRESH_TTL_DAYS
pin_max_attempts5FIELD_OPS_PIN_MAX_ATTEMPTS
pin_lockout_min15FIELD_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:

  1. Rep must exist and be active, else 403 (:68-76).
  2. Stand must exist, else 404 (:78-82).
  3. Already activeidempotent path (:86-88).
  4. Status must be unassigned or placed, else 409 (:90-96).
  5. A placed stand belongs to whoever placed it — another rep gets a 404, not a 403, so placements are not enumerable (:98-101).
  6. A placement photo is mandatory: the media must exist, belong to the calling rep, and be MediaKind::Photo, else 422 (:103-111).
  7. Credit resolution — defaults to the caller. Crediting someone else requires kind === staff and the rep's allowsOnBehalf() flag; temp reps can never credit others (403). The credited rep must exist and be active (422) (:249-279).
  8. Review URL derived as https://search.google.com/local/writereview?placeid=%s (:35,115).
  9. Atomic compare-and-swap claimactivateAtomically() means only one of N concurrent activations of a code wins the transition. The loser gets null and 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).
  10. 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).
  11. Photo is linked to the now-active stand (:166-174).
  12. 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:

FlagDefaultEnv varEffect when offCite
native_allocate_enabledfalseFIELD_OPS_NATIVE_ALLOCATE_ENABLEDPOST /field/stands/allocate 404s, and the orphan sweep is a no-opconfig/field_ops.php:48-52; routes/api/field.php:46-48; SweepOrphanStandAllocations.php:43-47
push_enabledfalseFIELD_OPS_PUSH_ENABLEDFieldPushService is a no-op and the credited-rep push job is never even enqueuedconfig/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 Firebase Factory against the separate field project credentials, multicasts to every active FieldSession token for that rep, and prunes unknown tokens from the report.
  • app/Jobs/SendFieldPushJob.php dispatches it from ActivateStandOrchestrator::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 to app.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:

PageFileRoute name
Team listsrc/pages/field-ops/team/index.vuefield-ops-team
Rep detailsrc/pages/field-ops/team/[id].vue
Stands listsrc/pages/field-ops/stands/index.vuefield-ops-stands
Stand detailsrc/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-bound admin may 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).

RouteNameGuard
/homerequiresAuth
/activateactivaterequiresAuth — the stand wizard
/metricsmetricsrequiresAuth
/invite/:tokeninvitepublic
/pin-setuppin-setuprequiresAuth
/unlockunlock
/welcomewelcome
/: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.com is live. The committed .env.production sets VITE_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, override VITE_API_URL for 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 in useLogout.
  • This replaces @capgo/capacitor-native-biometric, whose getCredentials() returned the plaintext with no prompt at all.
  • Android is served by the same SecureUnlock plugin name (Keystore setUserAuthenticationRequired).

upvendo-field CLAUDE.md:196-201 is 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

BlockerStatusCite
TODO(blocker-C) — NFC write moduleThe 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.

QuestionWhy
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 / analyticsTapAnalyticsService 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

  1. A user gets 403 on every /back-office/field-ops/* call despite holding view-field-ops — almost always rule 1: one of their roles carries a merchant_id, and none of their roles is a genuine platform role. Check role.merchant_id is null, not just that level === 'global' (FieldOpsAuthorityService.php:37-39).
  2. 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).
  3. A field token 403s on a back-office route (or vice versa) — by construction, not by permission. TokenType does a strict class check on the tokenable (TokenType.php:53-64).
  4. A rep sees own metrics but expected team — the data tier is RepKind only. Being admin-linked does not widen it; that only adds the admin.backoffice_url deep link (FieldMetricsScopeResolver.php:45-50; FieldMetricsService.php:60-69).
  5. 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).
  6. POST /field/stands/allocate 404sfield_ops.native_allocate_enabled is false, which is the default (config/field_ops.php:52).
  7. Activation succeeded but no Odoo lead appeared — the enqueue failed; the stand is live and crm_lead_dispatched stayed false. A re-activation by the same rep for the same place_id re-dispatches (ActivateStandOrchestrator.php:232-237,311-323).
  8. 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).