# Skin / 护理手账 API Base URL: `https://skin.stinkycat.club` · contract v1 · snapshot version 4 · updated 2026-10-24. Read this document before accessing application data. Skin uses a JSON action API, **not GraphQL**. [Machine-readable contract](/api-schema.json) describes `AppData` and all 20 current mutations; it is descriptive, not runtime validation. `/llms.txt` points here. Examples below are entirely synthetic. ## Availability and authentication `GET /api/data` and `POST /api/mutate` currently require the owner's existing Google / NextAuth browser session. Other signed-in accounts are rejected by the owner guard. Agent Bearer tokens do not authenticate these session routes. The separate opt-in read-only pilot is documented below; it is disabled by default. Do not extract cookies, submit login passwords as keys, or reuse `CRON_SECRET`. The backup cron secret is not an Agent credential. Public documentation grants no access to private records, reviews, or images. Current session handlers ignore `Authorization`; a supplied Bearer token does not authenticate and does not override an existing session. Do not test token validity from a logged-in browser. The dedicated Agent route never accepts a session cookie and never falls back to these full-data/session routes. Without an existing approved API credential, an Agent must stop before accessing private data. A human can use this read in the app's already signed-in browser; it does not export the session. Do not paste the private result into public logs: ```javascript const response = await fetch('/api/data', { credentials: 'same-origin', cache: 'no-store' }); if (!response.ok) throw new Error(`Skin read failed: ${response.status}`); const data = await response.json(); if (data.version !== 4) throw new Error('Unsupported snapshot version'); ``` ## Endpoints and results | Method and path | Behavior | Authentication / result | | --- | --- | --- | | `GET /api.md`, `/api-schema.json`, `/llms.txt` | Public discovery | No login; no business data | | `GET /api/data` | Full private snapshot | Owner session; `200 AppData` | | `POST /api/mutate` | One action from the table below | Owner session; JSON; `200 {"ok":true}` | | `GET /api/backups` | Private backup metadata list; may create backup table on first use | Owner session; maintenance only | | `POST /api/backups` | `{"action":"snapshot"}` or `{"action":"restore","id":123}` | Owner session; maintenance only; `200 {"ok":true}` | | `GET /api/backup` | **Creates a backup: this GET writes** | Owner session or exact backup cron credential; maintenance only | Auth-provider endpoints under `/api/auth/*` serve browser login, not business API or token issuance. Backup/restore/clear operations are excluded from any ordinary Agent candidate. Do not probe `/api/backup` as a health check. `GET /api/data` has no filters, pagination, incremental cursor, field selection, ETag, or deletion tombstone. Unknown query parameters do not provide these features. The snapshot includes `kinds` with embedded instances/reviews, all routine versions, `logs` keyed by date (including replacement attribution), `colors`, and `checkinLayouts`. Routine/log arrays include `anytime`; legacy snapshots may omit `anytime` and `checkinLayouts`, which clients should treat as empty. No point-in-time transaction is promised across its multiple database reads. Synthetic response: ```json {"version":4,"kinds":[],"routines":[{"version":1,"effectiveFrom":"2026-01-15","am":[],"pm":[],"anytime":[]}],"logs":{},"colors":{"categories":{},"activeAccent":null},"checkinLayouts":{}} ``` ## Fields, identities, and dates The full field definitions are in `/api-schema.json#/$defs`; the repository's `src/lib/types.ts` and `src/lib/api-contract.ts` are the TypeScript references. Kind, bottle, review and step IDs are UUID strings in PostgreSQL. Generate unique UUIDs for new objects; obtain existing IDs from a fresh authorized snapshot, never from examples. `productId` references a **kind**, not an individual bottle (`ProductInstance`). - `ProductKind`: `id`, `name`, `brand`, `category`, nullable `image` (HTTP(S) or data URL), nullable integer `rating` (1–5), `review`, arrays `activeIngredients`, `otherIngredients`, `otherReviews`, `instances`, and `createdAt`. - `ProductInstance`: `id`, `kindId`, `volume`, nullable numeric `price`, nullable integer `paoMonths`, nullable dates `manufactureDate`, `expiryDate`, `openedDate`, `finishedAt`, `status` (`unopened`, `in_use`, `finished`, `discarded`), and `createdAt`. - `OtherReview`: `id`, `source`, `content`, `note`, `createdAt`. - `Period`: exactly `AM`, `PM`, or `ANYTIME`. `RoutineStep`: `id`, `productId`, `period`, `frequency`. - `Frequency`: `{type:"daily"}`, `{type:"weekdays",days:[0,2]}` (Sunday=0), `{type:"interval",every:3}`, `{type:"weekly",times:2}`, `{type:"cycle",length:3,days:[0,1],anchor:"2026-01-15"}` (zero-based cycle offsets), or `{type:"asNeeded"}`. Positive integers for intervals/length; weekly times 1–7; weekday values 0–6; cycle days must be less than length. These describe valid client input, not comprehensive runtime checks on legacy requests. - `ColorConfig`: `categories` maps category strings to color-key strings; `activeAccent` is a color key or `null`. UI keys: `blue`, `teal`, `green`, `amber`, `rose`, `pink`, `purple`, `slate`. Dates are `YYYY-MM-DD` logical local dates. Supply `today` from the user's intended local day; the UI uses a 6am day rollover. Do not substitute the server's UTC date. `createdAt` for kinds, bottles and reviews is stored as a PostgreSQL date, not a timestamp; send `YYYY-MM-DD`, and expect date text on reads. Backup `takenAt` is a separate UTC timestamp. Kind order is `(created_at,name)`; instances/reviews `(created_at,id)`; routines by version; routine step arrays preserve position. Logs are keyed by date; do not rely on product-ID order within log arrays. Omit patch fields to leave them unchanged; `null` clears only nullable fields. Kind patch allows name/brand/category/image/rating/review/activeIngredients/otherIngredients. Instance patch allows volume/price/manufactureDate/expiryDate/openedDate/paoMonths/status/finishedAt. Review patch allows source/content/note. Other patch keys are ignored; empty or unknown-ID updates may succeed with no change. Creation requires the documented fields; `addKind` inserts only its explicit first `instance` (using `kind.id`), not `kind.instances` or `kind.otherReviews`. Existing routes do not have a uniform length limit, complete runtime schema validation, relationship validation, or stable validation-error envelope. Database/platform limits can still reject input. ## Mutation index and retry semantics All bodies go to `POST /api/mutate` with `Content-Type: application/json`. Every listed action returns `200 {"ok":true}` on handler success; no entity, version, affected-row count, or durable receipt is returned. Read back to verify. **No action implements `Idempotency-Key` or a request ID.** Such headers/extra fields are ignored; there is no receipt key format, scope, retention, payload conflict response, atomic replay, or exactly-once guarantee. Duplicate creation IDs may fail, not return a previous success. No operation supports optimistic concurrency/version preconditions. Usage writes use date/period advisory transaction locks; these serialize writes but do not prevent a late old intent from overwriting a newer one. A timeout/500 can follow a partial write. Do not automatically retry any write. Re-read and reconcile with the human's current intent first; even a matching state does not prove which request wrote it. | action | Required fields besides action | Effects / retry boundary | | --- | --- | --- | | `addKind` | kind, instance | Create kind + first bottle in a transaction; duplicate IDs may fail | | `updateKind` | id, patch | Assign allowed fields; stale replay can overwrite later edits | | `removeKind` | id, today | Snapshot first; delete kind and cascade bottles/reviews; remove from current routine, preserve historical routine/log IDs; not one atomic operation | | `addInstance` | instance | Create bottle; duplicate ID may fail | | `updateInstance` | id, patch | Assign allowed fields; stale replay can overwrite later edits | | `removeInstance` | id, today | Snapshot first; delete bottle; removing last bottle also removes kind; not one atomic operation | | `addOtherReview` | productId, review | Append review; duplicate ID may fail | | `updateOtherReview` | reviewId, patch | Assign fields; stale replay can overwrite later edits | | `removeOtherReview` | reviewId | Delete; no durable receipt or affected-row result | | `addStep` | step, today | Fork/reuse day's routine, append; duplicate ID may fail | | `updateStep` | stepId, frequency, today | Fork/reuse day's routine; assign frequency; stale replay may overwrite | | `removeStep` | stepId, today | Fork/reuse day's routine; remove step; repeated calls may cause version side effects | | `reorderSteps` | period, ids, today | Complete ordered step-ID list for the period; reorder; stale replay may overwrite | | `setUse` | date, period, productId, used | Absolute boolean; repeated same value converges only without intervening edits; an older value can overwrite a newer one | | `toggleUse` | date, period, productId | Legacy flip; every replay may reverse intent; prefer setUse | | `replaceUse` | date, period, originalId, productId | Atomically replace actual usage for one routine kind; selecting originalId restores the default; no receipt, stale replay may overwrite | | `setCheckinLayout` | date, period, layout | Replace layout for that date/period, merging other dates/periods; no receipt, same-slot edits may overwrite | | `completeSection` | date, period, sectionId | Batch-mark eligible fixed-due steps, preserving replacements; re-evaluates eligibility on every request, no safe automatic replay | | `setColors` | colors | Replace configuration; stale replay may overwrite | | `clearAll` | none | Maintenance only; snapshot then clear all tables; no automatic retry | `replaceUse` requires originalId in that date/period's routine. Alternatives must be active (in-use/unopened) products of the same nonblank trimmed category, not reserved by another step or independently used in that period. Read `logs[date].replacements[period]` as original-kind UUID → actual-kind UUID; usage arrays contain the actual kind. Replacement never edits the routine. Domain failures currently return 500 with a string error, not a stable 409. `CheckinLayout` contains `sections:[{id,name}]` and `assignments` mapping default kind UUIDs to section IDs. Layout writes validate a real date and supported period; 1–20 sections; unique IDs matching `[a-zA-Z0-9-]{1,64}`; nonblank names of at most 30 characters, unique after trimming; at most 2,000 assignments with keys of at most 64 characters and values naming an existing section. The server does not validate assignment-key UUID format or product existence. Unknown extra fields are not rejected. `checkinLayouts` maps date → period → layout; UI chooses the latest applicable date's layout for the period or uses its defaults. `completeSection` validates date/period and a string sectionId of at most 64 characters (no existence/nonempty check). It includes only due, fixed-frequency steps with an active product, excluding optional/flexible/rest steps and already recorded originals or replacements. An unknown section can succeed as a no-op. The eligibility snapshot precedes the transaction, so it is not a versioned compare-and-set. No Agent permission is activated for any of these actions. Routine edits reuse the supplied day's version or fork the latest one. Historical `today` values can target existing historical versions; do not backdate routine edits. The route does not enforce the UI's date restrictions. Synthetic body examples for every action (not a batch endpoint; send one object only after authorization and replacing IDs/dates). `clearAll` is shown to make the existing contract explicit, not as a recommended test: ```json [ { "action": "addKind", "kind": { "id": "00000000-0000-4000-8000-000000000001", "name": "Synthetic cream", "brand": "Example", "category": "moisturizer", "image": null, "rating": null, "review": "", "activeIngredients": [], "otherIngredients": [], "otherReviews": [], "instances": [], "createdAt": "2026-01-15" }, "instance": { "id": "00000000-0000-4000-8000-000000000011", "kindId": "00000000-0000-4000-8000-000000000001", "volume": "50ml", "price": null, "manufactureDate": null, "expiryDate": null, "openedDate": null, "paoMonths": null, "status": "unopened", "finishedAt": null, "createdAt": "2026-01-15" } }, { "action": "updateKind", "id": "00000000-0000-4000-8000-000000000001", "patch": { "review": "Synthetic note" } }, { "action": "removeKind", "id": "00000000-0000-4000-8000-000000000001", "today": "2026-01-15" }, { "action": "addInstance", "instance": { "id": "00000000-0000-4000-8000-000000000012", "kindId": "00000000-0000-4000-8000-000000000001", "volume": "50ml", "price": null, "manufactureDate": null, "expiryDate": null, "openedDate": null, "paoMonths": null, "status": "unopened", "finishedAt": null, "createdAt": "2026-01-15" } }, { "action": "updateInstance", "id": "00000000-0000-4000-8000-000000000011", "patch": { "status": "in_use", "openedDate": "2026-01-15" } }, { "action": "removeInstance", "id": "00000000-0000-4000-8000-000000000012", "today": "2026-01-15" }, { "action": "addOtherReview", "productId": "00000000-0000-4000-8000-000000000001", "review": { "id": "00000000-0000-4000-8000-000000000021", "source": "Synthetic source", "content": "Example only", "note": "", "createdAt": "2026-01-15" } }, { "action": "updateOtherReview", "reviewId": "00000000-0000-4000-8000-000000000021", "patch": { "note": "Synthetic note" } }, { "action": "removeOtherReview", "reviewId": "00000000-0000-4000-8000-000000000021" }, { "action": "addStep", "step": { "id": "00000000-0000-4000-8000-000000000031", "productId": "00000000-0000-4000-8000-000000000001", "period": "AM", "frequency": { "type": "daily" } }, "today": "2026-01-15" }, { "action": "updateStep", "stepId": "00000000-0000-4000-8000-000000000031", "frequency": { "type": "asNeeded" }, "today": "2026-01-15" }, { "action": "removeStep", "stepId": "00000000-0000-4000-8000-000000000031", "today": "2026-01-15" }, { "action": "reorderSteps", "period": "AM", "ids": [ "00000000-0000-4000-8000-000000000031" ], "today": "2026-01-15" }, { "action": "setUse", "date": "2026-01-15", "period": "AM", "productId": "00000000-0000-4000-8000-000000000001", "used": true }, { "action": "toggleUse", "date": "2026-01-15", "period": "AM", "productId": "00000000-0000-4000-8000-000000000001" }, { "action": "setColors", "colors": { "categories": { "moisturizer": "teal" }, "activeAccent": null } }, { "action": "clearAll" }, { "action": "replaceUse", "date": "2026-01-15", "period": "ANYTIME", "originalId": "00000000-0000-4000-8000-000000000001", "productId": "00000000-0000-4000-8000-000000000002" }, { "action": "setCheckinLayout", "date": "2026-01-15", "period": "AM", "layout": { "sections": [ { "id": "demo-section", "name": "Synthetic section" } ], "assignments": { "00000000-0000-4000-8000-000000000001": "demo-section" } } }, { "action": "completeSection", "date": "2026-01-15", "period": "AM", "sectionId": "demo-section" } ] ``` A human with an existing approved owner session can send a selected body from within the app; this example illustrates mechanics, not permission to write: ```javascript const response = await fetch('/api/mutate', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'setUse', date: '2026-01-15', period: 'AM', productId: 'REPLACE_WITH_EXISTING_KIND_ID', used: true }) }); const result = await response.json(); if (!response.ok || result.ok !== true) throw new Error(`Skin write failed: ${response.status}`); // Re-read /api/data; never automatically resend this write after an ambiguous failure. ``` ## Errors and recovery | Endpoint / status | Actual body or limitation | Client action | | --- | --- | --- | | data/mutate/backups `401` | `{"error":"未登录"}` | Stop; authenticate through supported owner flow | | backup `401` | `{"error":"未授权"}` | Stop; do not borrow a cron key | | mutate/backups POST `400` | `{"error":"请求体不是 JSON"}` | Fix malformed JSON | | mutate/backups POST `400` | `{"error":"未知操作"}` | Fix unsupported action (backup restore also requires numeric id) | | mutate `400` (`setCheckinLayout`) | `{"error":"板块名称需唯一且为 1–30 个字,至少保留一个板块"}` | Fix date/period/layout validation failure | | mutate `400` (`completeSection`) | `{"error":"板块参数无效"}` | Fix date/period/sectionId validation failure | | mutate/backups/backup `500` | `{ "error": "..." }`, text may be a database/internal exception | Treat as unknown write outcome; never expose raw text as public telemetry | | data/backups GET `500` | No guaranteed JSON envelope; framework response possible | Handle non-JSON safely; do not mistake it for empty data | Valid JSON `null` or wrongly shaped bodies are not uniformly rejected as `400`; legacy validation gaps can produce `500`. Existing errors have no stable machine code, `retryable` flag, or conflict (`409`) contract. Unsupported HTTP methods and platform failures may be framework responses. Inspect HTTP status AND JSON business result when available. Read-only transient network/5xx failures may be retried at most three times with exponential backoff and jitter (honor any Retry-After); do not retry 401 or input failures blindly. Maintenance GET backup is a write and is excluded from this read retry advice. The backup-list GET may also initialize its database table; exclude maintenance endpoints from probes. ## Verification and known boundaries Public docs can be fetched anonymously. Validate private read behavior only with approved access; use synthetic fixtures or an isolated database for mutation, backup, restore, and deletion tests. Never test `clearAll` on production and never publish real records or images as fixtures. Only remove records you created in an explicitly authorized isolated test environment. The session contract does not grant independent Agent CRUD access. The separately approved pilot below exposes only a current-period read. Full-snapshot access or Agent writes require a new explicit approval and separate validation of scope, concurrency, privacy and isolated integration tests. ## Opt-in Agent read-only pilot This build contains `GET /api/agent/checkins?date=YYYY-MM-DD&period=AM` (or `PM`). The route is disabled by default and returns 503 until the approved deployment gates and server configuration are complete. This document does not confirm production activation or issue credentials. Only the approved interactive Codex client on the owner's Mac may use the 24-hour, non-renewing `skin:checkins:read` pilot credential, stored in macOS Keychain. Lighthouse validates the token and scope on every read without a positive cache. The route accepts only the exact configured pilot token ID; other valid tokens for the same owner/tenant are rejected. Expired or revoked tokens return 401; wrong scope, owner or tenant returns 403. Revocation takes effect on the next validation, not on an already authorized in-flight read. Never expose the bearer or service secret. Supply exactly one `date` and one `period`, with no other query parameters. The server fixes `America/Los_Angeles`: AM is [06:00,18:00), PM is [18:00,next 06:00), with the pre-06:00 hours belonging to the previous logical date. ANYTIME and history are excluded. Client clock, timezone and date headers do not change this policy. If SQL crosses a date/period boundary, the result is discarded with 409. Success contains only `date`, `period`, and `items` of `id`, `name`, `used`. It contains active routine products, not a recommendation of what is due. No photos, reviews, prices, history, revision or write precondition is returned. Synthetic: ```json {"date":"2026-01-15","period":"AM","items":[{"id":"00000000-0000-4000-8000-000000000001","name":"Synthetic cleanser","used":false}]} ``` Responses use `Cache-Control: no-store` and `Vary: Authorization`. Requests accept only `Authorization: Bearer `, never cookies or backup credentials. The endpoint exports only GET; unsupported methods return 405. No Agent write route is mounted, and the experimental set-use entry point remains hard closed with 503 `agent_writes_disabled`. Existing browser routes are unchanged. | Status | Error | Recovery | | --- | --- | --- | | 400 | invalid_request | Correct missing, extra, duplicate or malformed query keys | | 401 | invalid_token | Stop; do not renew automatically | | 403 | insufficient_scope | Stop; verify approved owner, tenant and scope | | 403 | date_out_of_scope / period_out_of_scope | Use the current server policy window | | 409 | scope_changed | Discard the result and recompute the current window | | 503 | authentication_unavailable / policy_unavailable / data_unavailable | Stop and diagnose; no session or full-data fallback | Keep only one outstanding read and at most six reads per minute; this pilot does not automatically retry. Shared ingress limiting (30/minute/IP for this exact path, including unauthenticated traffic) must be verified before activation; it can produce a platform 429 response. SQL uses a read-only transaction with local lock_timeout 250ms and statement_timeout 2s, plus an HTTP deadline of 4s. There is no schema or trigger migration. HTTP timeout alone is not proof of backend cancellation; production Neon cancellation is a separate deployment gate.