Agent-operable ticket API: REST handlers over /soporte, single auth definition
Elazar directive via pmmaster (relayed by pm-enamel-cc 2026-08-16): every app's support-ticket surface must be fully agent-operable via API - no UI-only action. AUDIT RESULT (coder-enamel-cc, against deployed 8feeee8/0.5.0): GAP on every ticket action; enamel has no ticket API at all. app/src/app/api holds only app-version, health and cron/drain-email-outbox. All ticket operations are Next.js server actions - soporte/actions.ts (readStaffQueue, readTicketDetail, readTicketMessages, readAssignableStaff, assignTicket, setTicketStatus which covers close AND reopen via supportTicketCategoryStatuses, replyToTicket which carries isInternal so comment and reply are one action) and portal/actions.ts (createPortalTicket, readClientQueue, readStatusLabels). A server action is technically a POST endpoint but its address is an opaque build-generated action id that changes every deploy, the body is RSC-encoded and it needs the Next-Action header - no publishable contract. Nothing is missing from the UI, so this is one surface to build, not ten features. SCOPE: REST under /api/soporte - GET and POST /api/soporte/tickets, GET and POST /api/soporte/tickets/{id}/messages, PATCH /api/soporte/tickets/{id} for status and assignee. Route handlers must be THIN and delegate to the same gated statements already in soporte/actions.ts: exactly one authorization definition, never a second copy. The existing gate correlates the caller's live admin grant against the ticket's own appKey in the same statement - that property must survive the refactor. Class-A: touches the authorization path on a multi-tenant surface. Needs audit-enamel-ca review pre-push. BLOCKED ON A FLEET-LEVEL DECISION, DO NOT START. lib/auth.ts isGenuineGoogleUser() admits a session only when identities[] carries a google provider with a non-empty sub, and resolveSessionUser() then requires a live users row bound to that supabaseAuthId. An agent has no Google identity and cannot hold a session even if the endpoints existed. A non-cookie caller identity (service credential mapped to a users row, or a bearer/API-key path resolving to one) is a fleet auth decision escalated to pmmaster by pm-enamel-cc - not enamel's to invent locally. pm-enamel-cc instruction: filing does not need the identity answer, building does. SECOND BLOCKER, PRE-EXISTING: zero userGroups rows with appKey='enamel', so an agent caller would have no grant to correlate against and the API would ship denying everyone - same wall as the three already-shipped surfaces. See mem:project_enamel_no_enamel_grants.md. Related memory: mem:project_enamel_app_gap_inventory.md.
Questions
Activity
-
Agent-identity decision escalated to pmmaster: no non-cookie caller identity exists (isGenuineGoogleUser requires a Google sub), so an agent cannot authenticate at all. Second, pre-existing: zero enamel grants to correlate a gate against. Do not start implementation until the identity answer returns.
-
Follow-ups from pluto's parallel audit via pmmaster, answered 2026-08-16. (1) LIST/discovery is covered by readStaffQueue (staff) and readClientQueue (portal) and is a gap for the same reason as everything else - server action, not an addressable endpoint. Shape notes for the API spec: readStaffQueue takes NO arguments (no status/app/assignee filter, no pagination), and its appKey scope comes from the caller's grants inside the statement, never a parameter. Agent-facing GET /api/soporte/tickets needs filters and paging added WITHOUT making appKey a caller-supplied input. (2) No support bearer key exists. Only secret-gated route in the tree is /api/cron/drain-email-outbox on CRON_SECRET (Bearer, sha256 + timingSafeEqual, fails closed); no SUPPORT_API_KEY equivalent. A bearer key would not sidestep the identity ruling: it authenticates a machine and yields no users row, while every ticket write needs an actor (reply author + isInternal, assignedTo, resolvedBy) and every gate correlates the caller's live admin grant to the ticket's own appKey in-statement. Options are a hardcoded gate bypass (a cross-tenant write primitive on a co-tenanted DB - refused) or mapping the key to a real users row with real grants, which IS the identity decision. Do not reuse CRON_SECRET: it would merge an email-outbox blast radius with cross-tenant ticket write.
-
AUTH SHAPE DECIDED 2026-08-16 - venus SUPPORT_API_KEY template adopted, with two enamel deltas. Template: bearer key (constant-time compare, fail-closed if unset) authenticates the machine, never touching cookies or session; every write carries an explicit actorUserId resolving to a real users row. This substitutes cleanly for resolveSessionUser().id as the $1 of every correlated gate - statements unchanged, zero-rows still the refusal, no hardcoded bypass. Fleet identity ruling is therefore NO LONGER the blocker. DELTA 1 (stricter than venus): the key must NOT choose the actor. A caller-supplied actorUserId gives one key the union of every admin's authority, and this ref is co-tenanted with terra - the gate would faithfully grant the bearer terra's admin's reach over terra's rows. Not a bypass, impersonation working correctly, which is harder to spot. Bind the actor server-side (SUPPORT_API_ACTOR_ID or a pinned allowlist); keep actorUserId required on the request but validate it EQUALS the bound value, fail closed otherwise. DELTA 2 (blocking, db lane): supportTicketMessages (enamel_003) has no origin/channel column - id, ticketId, authorId, body, isInternal, timestamps. Stamping actorUserId makes an agent-written reply indistinguishable from a human one in the permanent record and in what the filer reads. A discriminator column must land BEFORE any route writes. db-enamel-cc to author. STILL BLOCKING, untouched by any of this: zero userGroups rows with appKey='enamel', so the bound actor has no grant to correlate and every call refuses. One INSERT, Elazar's authorization only. Standing: separate credential from CRON_SECRET, never reuse - would merge an email-outbox blast radius with cross-tenant ticket write.
-
pm-enamel-cc approved both conditions 2026-08-16. Migration ask sent to db-enamel-cc (tag coder-enamel-cc-msvm1ip606hj): proposed enamel_031 adding a constrained-text origin column ('web' | 'api') to BOTH supportTicketMessages and supportTickets - add nullable, backfill existing rows to 'web', set not null, then drop the default so every insert must state it (same loud-failure contract as emailOutbox). Asked them to check enamel_015 view projection and the resolveScoped* family for column drift. Final column name is their call. Routes do not start until that column exists and the actor binding is wired.
-
SUPERSEDES the identity-ruling block, which is resolved: venus SUPPORT_API_KEY template adopted (bearer authenticates the machine, server-side-pinned actor stamped as the real users row, gates unchanged). Now blocked on three things. (1) enamel_031 origin/channel discriminator column on supportTicketMessages + supportTickets - specced to db-enamel-cc, must land BEFORE any route writes. (2) Actor binding wired (SUPPORT_API_ACTOR_ID or allowlist; request param must equal the bound value, fail closed) and a support credential separate from CRON_SECRET. (3) Grant #1 - still zero userGroups rows with appKey='enamel', Elazar's authorization alone, escalated and not being chased. Routes do not start until (1) and (2) are done.
-
LIVE BREAKAGE, found and fixed same turn. enamel_031 applied with origin NOT NULL and no default while the deployed app's two inserts did not name the column - createPortalTicket and replyToTicket would both have raised 23502 from apply until fix. Invisible: both tables are 0 rows and neither path has ever been exercised, so no failed row, no error log, no complaint; it would have surfaced the first time a real person filed a ticket. Fixed at f7f92f5: both inserts state 'web' explicitly with comments barring a future default, new assertion in soporte.test.ts, and a NEW test file for portal/actions.ts which had none at all (8 tests) - the absence of that file is why a schema change could break a write path silently. Lesson recorded: a NOT-NULL-no-default column is a simultaneous change to schema AND every writer, so the apply and the app push are one piece of work, not two lanes' separate queues.
-
Build SHIPPED at f334206 (2026-08-16). Routes, shared gated core (lib/support-core.ts), bearer + server-pinned actor, origin='api' discriminator, atomic PATCH, ownerAppKey-scoped vocabulary. audit-enamel-ca PASS:pre-push after two BLOCK rounds (category vocabulary unenforced for API callers; PATCH partial-write contract undeliverable; lookupOptions.ownerAppKey ignored). 179 tests, tsc + eslint clean. Still BLOCKED, and on nothing in code: (1) SUPPORT_API_KEY / SUPPORT_API_ACTOR_ID unset in Vercel, must be a new credential not CRON_SECRET, and Vercel env binds at BUILD time so a redeploy is required after setting; needs Elazar. (2) Pinned actor holds no grants - zero userGroups rows at appKey='enamel' - so every route currently refuses correctly and the surface is inert. Coverage limit: no gate has been exercised against real rows.