# Published contracts for the taste repo. # # Single source of truth for what taste exposes to consumers. Per the hosaka # cross-repo contract convention (hearsay/mirror contracts/published.yaml # shape, carrefour#40), adapted for taste's surface: taste publishes HTTP # endpoints to browser-side consumers, NOT database tables — no cross-repo DB # grant exists or ever will (G-7: structural no-PII-to-consumer; consumers get # a subject-scoped bearer token, never a service credential). # # Rules: # - Every endpoint listed here is reachable ONLY with the subject's own # bearer token (Authorization: Bearer taste_v1_…) except POST /me/token, # which exchanges a single-use connect code for that token. # - The CORS origin allowlist and the per-consumer return= allowlist below # are HAND-MIRRORED into src/api/consumers.ts (plain const data — no runtime # YAML parse); a drift-vitest (src/api/consumers.test.ts) asserts the two # never diverge (D-R1). Adding a consumer is a CONTRACT change (a PR touching # BOTH this file and consumers.ts), not a runtime config change (FR-18 AC-4). # - Response-shape changes and endpoint removals are breaking — coordinate # via a carrefour issue before merging. # # ── FINAL CUT (Sprint 11, FR-21 AC-2) ───────────────────────────────────────── # This is the SHA-pinned handoff cut delivered to ninsei in the SAME PR as the # public connect + /me implementation. ninsei pins this file's commit SHA as the # contract it built its connect button + fetchProfile() against. The surface is # frozen for cycle-002: the endpoints, the connect URL + params, the CORS # allowlist, and the per-consumer return= allowlist below are what taste has # published. OQ-R2 (DELETE /me/token revoke) shipped IN and is listed below; # DELETE /api/v1/me (self-serve consent withdrawal + erasure) shipped too. # # CONSUMER DUTIES (SDD §12.5 — taste cannot enforce these; they are yours): # - XSS BLAST RADIUS. The bearer token lives in the player's browser (you hold # it in localStorage / memory). taste minimises the damage — 90-day expiry, # revocable, profile:read-only scope, no-store on every response — but taste # CANNOT fix an XSS on YOUR origin: a script injected into ninsei can read # that player's token and thus their own artist list. The blast radius of a # consumer XSS is exactly that one player's read-only profile; keep your # origin XSS-clean, and call DELETE /me/token (or DELETE /api/v1/me) if a # token is suspected compromised. # # ── 2026-07-07 ADDITIVE change (Connect-your-taste Stage-0 rider) ───────────── # POST /api/v1/me/token 200 body gains `sub` — the pairwise pseudonymous subject # (taste vision doc §4 Stage 0). ADDITIVE only: no existing field changed, no # endpoint touched; ninsei is unaffected (consumer-tolerant of unknown fields). # Coordination note to be posted on carrefour#121. # # - TIER CAP (public upload deferred → FR-21 AC-3). Public joiners get tier-2 # coverage day one via Last.fm scrobbles AND public Discogs collections (by # username, no OAuth). But the taste-side EXPORT UPLOAD source is # operator-imported ONLY this cycle — it is NOT on the public connect flow — # so `connected_sources.upload` is ALWAYS false for a player who joined via # /connect. Plan your UI copy accordingly: do not promise upload-based # coverage to public joiners. version: 1 owner: hosaka-fm/taste last_updated: 2026-07-07 # + ADDITIVE `sub` on the POST /me/token 200 (Stage-0 pairwise-sub rider); prior FINAL cut 2026-07-03 (Sprint 11). endpoints: # ───────────────────────────────────────────────────────────────── # /me surface — the subject's OWN profile, selected by their token # ───────────────────────────────────────────────────────────────── - path: /api/v1/me/profile method: GET auth: bearer # Authorization: Bearer taste_v1_… (scope profile:read) cors: true # exact-origin allowlist below; Vary: Origin; credentials NEVER allowed description: > The token-holding subject's own profile. No person id in the path — the token IS the subject selector (SDD §12.3). Cache-Control: no-store on every response, including 401s. response: status: 200 body: connected_sources: "{ lastfm: bool, discogs: bool, upload: bool }" connected_source_count: integer resolved_cluster_ids: "string[] # canonical + unverified tiers only; unmatched excluded (FR-11 two-tier honesty)" errors: - status: 401 body: "{ error: { code: token_invalid | token_expired | token_revoked } }" - path: /api/v1/me/token method: POST auth: none # the connect code in the body IS the credential cors: true description: > Exchange a single-use connect code (handed back by the /connect flow) for a bearer token — the ONLY time the token plaintext crosses the wire (SDD §12.3). Codes are single-use and expire 60s after mint. request: body: "{ code: string }" response: status: 200 body: token: "string # taste_v1_…, plaintext, returned exactly once" expires_at: "string # ISO-8601; 90-day sliding expiry, renews on use" scopes: "string[] # default [profile:read]" sub: "string # sub_v1_…, stable pairwise pseudonymous subject — your per-app primary key; the SAME person has a DIFFERENT sub in every other consumer; do not attempt cross-app correlation; never use email as an account key" errors: - status: 400 body: "{ error: { code: code_invalid } } # one reason for unknown/expired/claimed — no oracle" - status: 500 body: "{ error: { code: sub_unavailable } } # misdeploy only (TASTE_SUB_PEPPER not injected — operator action required); the code is NOT consumed and can be re-exchanged once fixed" - path: /api/v1/me/token method: DELETE auth: bearer cors: true description: > Explicit revocation of the presented token (OQ-R2 — shipped IN). Erasure- driven revocation is structural (person CASCADE) and needs no endpoint; this is the in-game "sign out this device" affordance. response: status: 204 errors: - status: 401 body: "{ error: { code: token_invalid | token_expired | token_revoked } }" - path: /api/v1/me/capture method: POST auth: bearer # Authorization: Bearer taste_v1_… (the token IS the subject) cors: true # /me surface — exact-origin allowlist; credentials NEVER allowed description: > Universal-capture ingestion (SDD §13.4/§13.5, FR-25/FR-28). Accepts multipart/form-data (image/PDF + modality) OR application/json { modality, html|text }. The image byte is guarded (size 15 MiB → magic-byte allowlist PNG/JPEG/WebP/PDF → header-only pixel-flood 40 MP → PDF page cap) then OCR'd/extracted IN-MEMORY and DISCARDED — never a column, key, or temp file. Consent is recorded per source BEFORE extraction. Candidates land 'pending' (not yet trusted; never on /me/profile until confirmed). Tighter capture-scope rate limit (per-token + per-IP) than profile read. Cache-Control: no-store on every response, including 400/413/empty. request: body: > multipart/form-data { image: file, modality: string } | application/json { modality: string, html?: string, text?: string } response: status: 200 body: batch_id: "string # confirm operates per batch" candidates: "[{ id, artist, title, album, kind, extraction_confidence, low_confidence }] # [] + honest note when unreadable (never 500)" errors: - status: 400 body: "{ error: { code: modality_invalid | image_missing | input_missing | input_rejected | input_invalid } }" - status: 413 body: "{ error: { code: input_rejected, reason } } # oversize image (>15 MiB) or text (>4 MiB)" - status: 429 body: "{ error: { code: rate_limited } } # capture-scope per-token/per-IP bucket" - status: 401 body: "{ error: { code: token_invalid | token_expired | token_revoked } }" - path: /api/v1/me/capture/confirm method: POST auth: bearer cors: true description: > Batch approve-all-with-untick (FR-25 AC-1). Body { batch_id, rejected_ids }: every PENDING candidate in the batch NOT in rejected_ids is promoted through the reused resolve→persist path (resolved to a cluster_id BEFORE it lands) → state='confirmed'; unticked rows → 'rejected'. Nothing enters the resolved profile without passing this gate (G-11). A double-confirm is a no-op. request: body: "{ batch_id: string, rejected_ids: string[] }" response: status: 200 body: confirmed: "integer" rejected: "integer" errors: - status: 400 body: "{ error: { code: batch_invalid } }" - status: 429 body: "{ error: { code: rate_limited } }" - status: 401 body: "{ error: { code: token_invalid | token_expired | token_revoked } }" - path: /api/v1/me method: DELETE auth: bearer cors: true description: > Self-serve consent withdrawal + erasure (FR-19 AC-3): the token-holding subject withdraws consent and erases ALL their taste data. Runs the same irreversible two-class DSR erasure the operator console does — the person CASCADE structurally revokes the presenting token, so it 401s immediately after. This is the player's GDPR right-to-erasure button; give them one. response: status: 204 errors: - status: 401 body: "{ error: { code: token_invalid | token_expired | token_revoked } }" # ───────────────────────────────────────────────────────────────── # Profile axes (cycle-004 dashboard; PUBLISHED cycle-013 FR-54). # The taste-dashboard consumer note below cites these four by name — # they were shipped but undocumented until cycle-013. Same auth shape # as /me/profile: the token IS the subject selector. # ───────────────────────────────────────────────────────────────── - path: /api/v1/me/timeline method: GET auth: bearer # scope profile:read cors: true # exact-origin allowlist; Vary: Origin; credentials NEVER allowed description: > The subject's own listening timeline. Two-tier honest (FR-11): unmatched items are excluded, never presented as resolved. Cache-Control: no-store. response: status: 200 - path: /api/v1/me/collection method: GET auth: bearer cors: true description: > The subject's own resolved collection. Public-Discogs subjects surface an honest error for private collections, never partial data (FR-21 AC-3). response: status: 200 - path: /api/v1/me/clusters method: GET auth: bearer cors: true description: > The subject's resolved cluster_ids — canonical + unverified tiers only; unmatched excluded (FR-11 two-tier honesty). cluster_id is the fleet join anchor (carrefour#144); the legacy locators are NOT identity. response: status: 200 - path: /api/v1/me/metrics method: GET auth: bearer cors: true description: > Per-subject resolution metrics — the honest coverage instrument (hit-rate and tier split), so a consumer can render a gap rather than imply completeness. response: status: 200 - path: /api/v1/me/recommendations method: GET auth: bearer cors: true description: > Graph-traced recommendations over the subject's resolved clusters. Every rec traces a real crate edge — nothing is fabricated when the neighbour set is empty (cycle-005 E7). Shipped cycle-005; PUBLISHED cycle-013 after a consumer audit found it undocumented (search, carrefour#141). response: status: 200 - path: /api/v1/profile/{personId}/{axis} method: GET auth: api-key # X-API-Key: partner key, scoped to one person cors: false # server-to-server; no browser origin echoed description: > Keyed partner read of ONE person's ONE axis. 401 without a key, 403 for a wrong-person or wrong-scope key. Distinct from the /me surface: here the path names the subject and the KEY carries authority. response: status: 200 # ───────────────────────────────────────────────────────────────── # Data-subject rights (PUBLISHED cycle-013 FR-54). Published because # they are a GDPR/CCPA commitment worth stating publicly — not because # a consumer asked for them. /api/v1/me DELETE above is the in-session # erasure button; these are the explicit DSR endpoints. # ───────────────────────────────────────────────────────────────── - path: /api/v1/dsr/export method: POST auth: bearer # the subject's own token — no operator path to another subject's data cors: true description: > Machine-readable export of everything taste holds for the subject (right to data portability). Token material is EXCLUDED from the bundle. response: status: 200 - path: /api/v1/dsr/delete method: POST auth: bearer cors: true description: > Two-class erasure (right to erasure). Class-A personal data is destroyed leaving zero re-identifiable residue; Class-B shared non-PII facts (resolution_cache, dossier_cache) are retained and are person-FK-free. response: status: 202 # ───────────────────────────────────────────────────────────────── # DECLARED NOT-CONTRACT (cycle-013 FR-54). # # These routes ship but are deliberately NOT consumer contract: they are # browser-, operator- or subject-mediated. They are listed so that no route # is SILENTLY absent — the failure mode a consumer found for us in the # cycle-013 audit. A route appearing in neither `endpoints:` nor `internal:` # is a red test (src/api/published-surface.test.ts, FR-54 AC-1). # # Adding a route here is a contract DECISION, not a formality: it asserts # "no external consumer may depend on this". # ───────────────────────────────────────────────────────────────── internal: - path: /api/v1/connect/discogs why: browser-mediated OAuth start; redirects to Discogs. Not a callable consumer API. - path: /api/v1/connect/discogs/callback why: OAuth redirect target for the above; browser-only, single-use state. - path: /api/v1/connect/lastfm why: browser-mediated connect start (username-based); not a callable consumer API. - path: /api/v1/connect/{source}/reimport why: subject- or operator-initiated re-pull of an already-connected source. - path: /api/v1/consent/{source}/withdraw why: subject action from taste's own UI; consent state is taste-owned, not consumer-driven. - path: /api/v1/me/login/request why: magic-link issuance. Email stays on taste's origin by design — a consumer never sees it. - path: /api/v1/me/login/callback why: magic-link landing; browser-only, single-use. - path: /api/v1/upload why: FENCED — returns 503 UPLOAD_UNAVAILABLE (round-16). Bytes are never persisted, so the honest answer is unavailable rather than a false 202. S3 staging is the upgrade trigger. # ───────────────────────────────────────────────────────────────── # Public connect flow (page + POST — ships Sprint 11) # ───────────────────────────────────────────────────────────────── connect: url: /connect params: service: "lastfm | discogs # discogs = PUBLIC collection by username (no OAuth); taste-side upload is NOT on the public surface (tier cap) and Bandcamp is NEVER server-side" return: > Consumer URL to redirect back to with ?taste_code=. Validated against the per-consumer return allowlist below BEFORE the page renders (open-redirect defense, FR-16 AC-3); unlisted values refuse to start. flow: > GET /connect?service=…&return=… renders a per-source GDPR consent step + username entry (return= validated first). The form POSTs to /api/public/connect (deliberately NOT under /api/v1/connect/* — that prefix is the operator surface). On success taste records consent, queues the import, mints a single-use connect code, and 302s to ?taste_code=. The consumer's browser then POSTs the code to /api/v1/me/token to obtain the bearer token. # ───────────────────────────────────────────────────────────────── # Consumers — CORS origin allowlist + per-consumer return= allowlist. # HAND-MIRRORED into src/api/consumers.ts, drift-tested (D-R1). # ───────────────────────────────────────────────────────────────── consumers: - id: ninsei origin: https://ninsei.hosaka.fm return: origin: https://ninsei.hosaka.fm # exact match — scheme + host + port path_prefix: "/" notes: > First consumer (static SPA, no backend — S3+CloudFront). DUTIES (SDD §12.5, taste cannot enforce them): the token lives in the player's browser only — a consumer XSS's blast radius is that one player's read-only artist list; taste cannot fix consumer XSS, so keep your origin clean and revoke a suspect token via DELETE /me/token. TIER CAP (FR-21 AC-3): the upload source is operator-imported ONLY this cycle (public upload deferred), so connected_sources.upload is ALWAYS false for players who join via /connect — plan UI copy accordingly. Public Discogs is by-username public-collection only (private collections surface an honest error, not data). - id: taste-dashboard origin: https://taste.hosaka.fm return: origin: https://taste.hosaka.fm # exact match — scheme + host + port path_prefix: "/dashboard" notes: > First-party dashboard consumer (Sprint 16): taste's own browser session reading its own profile axes (timeline/collection/clusters/metrics) via the subject's bearer token, SAME-ORIGIN — those axis reads emit NO CORS. This entry exists so the /connect + /me/capture flows may hand back to https://taste.hosaka.fm/dashboard (return= allowlist, FR-34); the return= prefix is /dashboard, so a hand-back to any other path on this origin is refused. - id: search # cycle-013 FR-55 (carrefour#141 §B). RETURN-ONLY: deliberately NO `origin:` # key, so search receives NO CORS echo. This is the narrower privilege search # explicitly asked for, not an omission — see notes. return: origin: https://search.hosaka.fm # exact match — scheme + host + port path_prefix: "/api/taste/callback" notes: > Third consumer (search.hosaka.fm, carrefour#141 §B). RETURN-ONLY BY DESIGN: search exchanges the connect code from its BACKEND (POST /api/v1/me/token server-to-server) and discards the bearer, keeping only the pairwise `sub` and setting its own first-party cookie. No browser request ever crosses to taste, so it needs no CORS entry and has not been given one — a CORS echo would be privilege it did not request. CONSEQUENCE, intended: because this entry has no origin, a BROWSER presenting an Origin while redeeming a search-issued code is refused (400 code_invalid); only the server-side exchange path works. DUTIES search accepted (taste cannot enforce them): connected_sources.upload is ALWAYS false for /connect joiners (FR-21 AC-3) so UI copy must not imply uploads; public Discogs is by-username public-collection only and private collections surface an honest error, not data; resolved_cluster_ids is canonical + unverified only with unmatched EXCLUDED (FR-11), so it is not a complete collection; `sub` is a pairwise pseudonymous subject id — per-app, never cross-app correlatable, never an email. LIMITATION: path_prefix is PREFIX-matched, so the value above also admits /api/taste/callback-anything on this origin; exact-path matching is not expressible today and search has been told (no second landing route planned).