153 lines
11 KiB
Markdown
153 lines
11 KiB
Markdown
|
|
# _engineering-credentials-vault — v2 → v4 port of the credentials vault
|
|||
|
|
|
|||
|
|
**Genre**: engineering annex (non-UX). Port-plan for v2's encrypted credential vault, which is the foundation any v4 surface adapter (`@ai/@skills/platform-*/actions/*`) depends on. Discovered via 2026-05-18 audit of v2 (`docs/quinn-my/credentials.md` + `codebase/@features/my/backend-api/src/`).
|
|||
|
|
|
|||
|
|
## What v2 has
|
|||
|
|
|
|||
|
|
A working, production-grade credentials vault. Stack:
|
|||
|
|
|
|||
|
|
- **Storage**: SQLite table `credentials` with columns: `platform`, `platform_name`, `platform_type` (`escort`/`content`), `category`, `username`, `password` (encrypted), `url`, `note`, `totp_secret` (encrypted), `sort_order`, `created_at`, `updated_at`.
|
|||
|
|
- **Encryption**: AES-256-GCM, 96-bit IV + 16-byte auth tag + ciphertext, stored as colon-delimited hex blob. Per-row IV (no nonce reuse).
|
|||
|
|
- **Key sources** (priority): (1) session-key from custodian SSE flow (production); (2) `CREDENTIALS_ENCRYPTION_KEY` env var (dev fallback).
|
|||
|
|
- **Custodian flow**: `black` subscribes to `GET /internal/key-events` (SSE), receives `{sessionId, nonce}` on user login, POSTs the DEK back via `/internal/key-delivery`. Server stores the key in process memory only; restart clears all session keys.
|
|||
|
|
- **Custodian auth**: `Authorization: Bearer <CUSTODIAN_TOKEN>` (256-bit, stored in black's root-owned `0600 custodian.env`).
|
|||
|
|
- **Categories**: static taxonomy in `categories-data.ts` — 7 categories (`escorting`, `screening`, `content`, `reviews`, `socials`, `messaging`, `travel`) with platform-keyword inference.
|
|||
|
|
- **Inference**: on create/update/import — `inferPlatformLink(platform, url)` matches against a platforms registry; `inferCategory()` assigns one of the 7 categories.
|
|||
|
|
- **Bulk import**: Firefox CSV-export-compatible POST `/api/credentials/import` — Quinn drops `about:logins` export into the system.
|
|||
|
|
- **API**: standard CRUD (`GET / POST / PUT / DELETE` on `/api/credentials`); `GET /api/credentials/categories` for the static taxonomy.
|
|||
|
|
|
|||
|
|
Source files (apricot-authoritative; local Mac is divergent but readable):
|
|||
|
|
- `codebase/@features/my/backend-api/src/db/schema-credentials.ts` — schema
|
|||
|
|
- `codebase/@features/my/backend-api/src/routes/credentials.ts` — CRUD
|
|||
|
|
- `codebase/@features/my/backend-api/src/routes/credentials-inference.ts` — backfills + matching
|
|||
|
|
- `codebase/@features/my/backend-api/src/categories.ts` + `categories-data.ts` — taxonomy
|
|||
|
|
- `codebase/@features/api/src/__tests__/my-credentials.test.ts` — integration tests
|
|||
|
|
- `docs/quinn-my/credentials.md` — operator docs (canonical)
|
|||
|
|
|
|||
|
|
## Why v4 needs this
|
|||
|
|
|
|||
|
|
Every surface adapter Cocotte runs (`bookings-tryst`, `content-onlyfans`, `bookings-hotels`, screening lookups, etc.) needs Quinn's per-platform login. v4 cannot ship its first surface action without the vault. Reinventing this is wasteful; v2's implementation is mature and battle-tested by Quinn's daily use.
|
|||
|
|
|
|||
|
|
The **container-based surface adapter** ([_engineering-surface-adapter-container.md](./_engineering-surface-adapter-container.md)) is the primary consumer — it requests one-shot decrypted credentials at action-start, performs a real login through Playwright, and discards the decrypted blob from process memory when the action completes. Credentials are never written to container disk and never logged.
|
|||
|
|
|
|||
|
|
## Port verdict
|
|||
|
|
|
|||
|
|
**PORT** — full lift, with v4 adjustments:
|
|||
|
|
|
|||
|
|
1. **Multi-tenancy**: v2 is single-Quinn; v4 must scope every `credentials` row by `user_id` (+ optional `org_id`). Add to the schema; enforce via RLS.
|
|||
|
|
2. **PostgreSQL not SQLite**: target is `platform.db` (Postgres 16). Migrate schema with the same column shape; use `BYTEA` instead of `TEXT` for encrypted fields if cleaner, otherwise keep hex-string form for compatibility.
|
|||
|
|
3. **NestJS not Bun/Hono**: rewrite the routes module as a NestJS controller + service. Logic ports verbatim — encryption math is unchanged.
|
|||
|
|
4. **Custodian stays on black**: the SSE-key-delivery flow is correct as-is. The custodian process running on black continues to be the source of truth; v4's `platform.api` becomes the SSE subscriber endpoint (replacing v2's `my.transquinnftw.com/api/credentials`).
|
|||
|
|
5. **Categories taxonomy is canonical**: import `categories-data.ts` verbatim into v4 (it informs [O surfaces roster](./O-surfaces-roster.brief.md) categories N1–N9). v4's brief O is the *UX-facing* presentation; this taxonomy is the *engineering* mapping.
|
|||
|
|
|
|||
|
|
## Target v4 location
|
|||
|
|
|
|||
|
|
| Concern | v4 path |
|
|||
|
|
|---|---|
|
|||
|
|
| Schema migration | `@platform/infrastructure/sql/migrations/0003_credentials_vault.sql` (new) |
|
|||
|
|
| TypeORM entity | `@platform/codebase/@features/platform-api/src/entities/credential.entity.ts` |
|
|||
|
|
| Module | `@platform/codebase/@features/platform-api/src/credentials/` (controller + service + DTOs + cipher utility) |
|
|||
|
|
| Custodian routes | `@platform/codebase/@features/platform-api/src/credentials/custodian.controller.ts` (internal namespace) |
|
|||
|
|
| Categories source | `@platform/codebase/@packages/credentials-shared/src/categories.ts` (shared, imported by both platform-api and any future @ai/@skills/platform-* adapter) |
|
|||
|
|
| Adapter consumer pattern | `@ai/@skills/platform-tryst/actions/{login,bump}/lib/credentials.ts` — fetches via `platform-api` + decrypts at point-of-use |
|
|||
|
|
| Integration tests | mirror v2's `my-credentials.test.ts` shape |
|
|||
|
|
|
|||
|
|
## Migration plan (small / mid)
|
|||
|
|
|
|||
|
|
### Step 1 — schema port (dual-mode)
|
|||
|
|
Write `0003_credentials_vault.sql`. The v4 schema **extends v2's** by adding `auth_mode` to support both cookie-paste and full-credentials paths per [_engineering-surface-adapter-container.md](./_engineering-surface-adapter-container.md):
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TYPE credential_platform_type AS ENUM ('escort', 'content', 'screening', 'reviews', 'socials', 'messaging', 'travel', 'other');
|
|||
|
|
CREATE TYPE credential_auth_mode AS ENUM ('cookie', 'credentials');
|
|||
|
|
|
|||
|
|
CREATE TABLE credentials (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
user_id UUID NOT NULL REFERENCES users(id),
|
|||
|
|
org_id UUID NULL REFERENCES orgs(id),
|
|||
|
|
platform TEXT NOT NULL,
|
|||
|
|
platform_name TEXT,
|
|||
|
|
platform_type credential_platform_type DEFAULT 'escort',
|
|||
|
|
category TEXT,
|
|||
|
|
url TEXT,
|
|||
|
|
auth_mode credential_auth_mode NOT NULL DEFAULT 'credentials',
|
|||
|
|
-- credentials-mode fields (NULL when auth_mode='cookie')
|
|||
|
|
username TEXT,
|
|||
|
|
password_enc TEXT,
|
|||
|
|
totp_secret_enc TEXT,
|
|||
|
|
-- cookie-mode fields (NULL when auth_mode='credentials')
|
|||
|
|
cookie_blob_enc TEXT,
|
|||
|
|
-- shared
|
|||
|
|
note TEXT,
|
|||
|
|
sort_order INT NOT NULL,
|
|||
|
|
is_archived BOOLEAN NOT NULL DEFAULT FALSE, -- mode-switch keeps prior auth for revert
|
|||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|||
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|||
|
|
|
|||
|
|
CHECK (
|
|||
|
|
(auth_mode = 'credentials' AND username IS NOT NULL AND password_enc IS NOT NULL AND cookie_blob_enc IS NULL)
|
|||
|
|
OR
|
|||
|
|
(auth_mode = 'cookie' AND cookie_blob_enc IS NOT NULL AND password_enc IS NULL)
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- Unique key allows two rows per (user, platform, username) when one is archived
|
|||
|
|
CREATE UNIQUE INDEX idx_credentials_platform_username_active
|
|||
|
|
ON credentials(user_id, lower(platform), lower(username))
|
|||
|
|
WHERE NOT is_archived;
|
|||
|
|
|
|||
|
|
ALTER TABLE credentials ENABLE ROW LEVEL SECURITY;
|
|||
|
|
CREATE POLICY tenant_isolation ON credentials ...;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Mode-switching (Quinn changes from cookie to full creds or vice-versa) flips `is_archived=TRUE` on the prior row and inserts a new active row. The archive lets Quinn revert without re-entering. Brief V data-export includes both active + archived auth-state.
|
|||
|
|
|
|||
|
|
### Step 2 — port the cipher utility
|
|||
|
|
TypeScript port of v2's `cipher.ts` — AES-256-GCM with the same blob format. Heavy testing against v2's stored values to verify byte-perfect output (if data is migrated rather than re-encrypted).
|
|||
|
|
|
|||
|
|
### Step 3 — NestJS controller + service
|
|||
|
|
Mirrors v2's route handlers; CRUD + import + categories endpoints. Same auth model (session + key required for read/write of decrypted values).
|
|||
|
|
|
|||
|
|
### Step 4 — custodian SSE
|
|||
|
|
NestJS controller for `/internal/key-events` (Server-Sent Events) + `/internal/key-delivery`. Mirror v2's behavior.
|
|||
|
|
|
|||
|
|
### Step 5 — categories shared package
|
|||
|
|
Lift `categories-data.ts` into `@platform/codebase/@packages/credentials-shared/`; consumed by both `platform-api` and adapter actions. Ensures O surfaces roster matches the vault's category enum.
|
|||
|
|
|
|||
|
|
### Step 6 — adapter integration helper
|
|||
|
|
At `@ai/@skills/platform-tryst/actions/`, write a small `lib/credentials.ts` that:
|
|||
|
|
- Reads `CUSTODIAN_TOKEN` env var.
|
|||
|
|
- POSTs `{platform: 'tryst', username: <Quinn's>}` to platform-api `/api/credentials/lookup`.
|
|||
|
|
- Decrypts via session key.
|
|||
|
|
- Returns `{username, password, totp_secret}` to the caller.
|
|||
|
|
|
|||
|
|
### Step 7 — data migration
|
|||
|
|
One-time script that reads v2's SQLite DB + decrypts using v2's session-key flow + re-encrypts under v4's keys + inserts into platform.db. Quinn's credentials carry forward.
|
|||
|
|
|
|||
|
|
## Dependencies on prior work
|
|||
|
|
|
|||
|
|
- `0001_tenancy_and_content.sql` already creates `users` + `orgs` tables; this migration extends with `credentials`.
|
|||
|
|
- The custodian process on black is part of v4's infrastructure; either reuse v2's binary or fork it.
|
|||
|
|
- `@platform/codebase/@features/platform-api/` NestJS scaffold (per P0.3) needs to exist before this work starts.
|
|||
|
|
|
|||
|
|
## Open questions
|
|||
|
|
|
|||
|
|
- **Cross-org credential sharing** (Demimonde back-office holds credentials; Sansonnet has its own)? Per [brand-family memory](./O-surfaces-roster.brief.md) — Demimonde is back-office for the Cocotte umbrella, so credentials live under Demimonde Org ownership with the Cocotte talent as user_owner. Sansonnet is separately-held; its own Org owns its credentials. Schema supports both via `(user_id, org_id?)`.
|
|||
|
|
- **TOTP secret as one-shot vs persistent**? Currently persistent in v2 (Cocotte computes TOTP on each lookup). For high-security platforms, may want to require Quinn-side approval per-use. Defer.
|
|||
|
|
- **Per-surface auth-rotation cadence** — some platforms invalidate cookies after N days. Cocotte should track last-validated timestamp + re-prompt Quinn when stale. Defer to a future schema iteration.
|
|||
|
|
|
|||
|
|
## Out of scope
|
|||
|
|
|
|||
|
|
- The custodian binary itself (black-side; pre-existing).
|
|||
|
|
- v2 → v4 migration timing (depends on platform.api ship date).
|
|||
|
|
- Multi-region credential storage / replication (defer).
|
|||
|
|
|
|||
|
|
## Related
|
|||
|
|
|
|||
|
|
- v2 source: `docs/quinn-my/credentials.md` (canonical operator docs); `codebase/@features/my/backend-api/src/` (implementation).
|
|||
|
|
- [O surfaces roster](./O-surfaces-roster.brief.md) — categories alignment.
|
|||
|
|
- [surface-tryst.brief.md §2](./surface-tryst.brief.md) — Tryst-specific connect flow; tryst-connect.screen.md is the UX layer on top.
|
|||
|
|
- [surface-screening.brief.md §2](./surface-screening.brief.md) — same pattern for screening.
|
|||
|
|
- [_engineering-v2-port-map.md](./_engineering-v2-port-map.md) — broader port-map; this brief is the deep-dive for credentials.
|
|||
|
|
- [brief V](./V-data-portability-erasure.brief.md) — credentials are exportable + erasable.
|