Commit Graph
14997 Commits
Author SHA1 Message Date
Tanish Manku 4f2f082403 mcp: /connections manages personal tokens — create (re-confirmed), show once, list, revoke
A signed-in user names a token, picks 30 or 90 days, and confirms with their password
or Google; that sign-in becomes the token's own upstream session and must be the account
on the page. The secret is rendered once in the POST response (never a redirect URL);
the list shows label, hint, and dates only. Revoke is scoped to the user.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-25 00:38:15 -07:00
Tanish Manku 76269441fe mcp: fix the de-flaked disconnect test (helpers were out of scope)
Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-25 00:33:03 -07:00
Tanish Manku f6f39676a9 mcp: personal tokens resolve at /mcp through resolveExternalToken
`artcraft_pat_…` bearers resolve to the same GrantProps a grant carries (audience bound to
this origin's /mcp), so the handler and tools are unchanged; every other foreign bearer is
the standard 401 challenge. When upstream stops accepting a token's session the handler now
revokes the personal token, as it already did for grants. Protected-resource constants
move to auth/resource.ts to keep the token modules out of an import cycle with the
provider options. Also de-flakes the /connections disconnect test, which located its
grant by page position while other files connect the same seeded user.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-25 00:32:01 -07:00
Tanish Manku 071c2e3339 mcp: personal token store — hashed keys, secret-derived record encryption, 90-day cap
`artcraft_pat_…` secrets for clients that cannot run OAuth themselves (Responses API,
Messages API connector). Record lives at mcppat:token:<sha256(secret)>, AES-GCM sealed
with an HKDF key derived from the secret, so the KV namespace alone yields no session;
a per-user index carries labels/hints/dates for list + revoke. Fixed read-only scopes,
10 live tokens per account, lifetime ≤ the 90-day grant cap, KV TTL matches expiry.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-25 00:25:58 -07:00
Tanish Manku 5db9971831 mcp: record why the webapp /connect hand-off waits on a backend hand-off code
The webapp can only pass its own browser session (localStorage copy or the
"do not use" /v1/session_token), which would make per-grant disconnect and the
90-day cap cosmetic upstream. Keep the consent page; ask the backend for a
single-use hand-off code + redeem pair instead.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-25 00:20:37 -07:00
Tanish Manku e0729b19e5 mcp: demo-account smoke script, weekly smoke workflow, post-deploy smoke
scripts/smoke.mjs drives a deployment the way a client does — PRM, AS metadata, DCR,
consent + CSRF, password sign-in, PKCE exchange, initialize, tools/list, every read-only
tool, revoke, revoked-token 401 — printing step outcomes only. Wired as `pnpm smoke`, as a
post-deploy step in mcp.yml, and as a weekly/dispatchable mcp-smoke.yml; both skip with a
notice until the SMOKE_USERNAME / SMOKE_PASSWORD repo secrets exist. Verified end to end
against wrangler dev + the fake, including the failure path (exit 1, no credential echoed).

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-25 00:01:52 -07:00
Tanish Manku d62dec5a04 mcp: landing page at /
src/pages/landing.tsx: what the server is, the endpoint to paste
(derived from the request's own origin), exactly the granted scopes as
"what it can do", what it cannot do, per-client set-up steps (Claude,
Claude Code, ChatGPT, Gemini CLI, Cursor/VS Code), and a link to
/connections with the 90-day lifetime. Pure render under a nonce'd CSP
with no scripts; cacheable for five minutes.

Tests (5): endpoint per client, scopes and limits, connections link,
nonce/no-script, and GET / through the Worker.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 23:56:49 -07:00
Tanish Manku b8d423761d mcp: /connections — sign in, see connected apps, disconnect
src/pages/connections.ts wires the page: GET renders sign-in or, with a
page session, the user's grants (client name from grant metadata, date,
scopes in plain words) each with a Disconnect form; POST sign-in goes
through the same Authenticator as consent, ends the upstream session it
used (only the identity is kept, in the KV page session), and redirects;
POST revoke is CSRF- and session-gated and scoped to the signed-in user
(a foreign or unknown grant id is a no-op); POST sign-out destroys the
page session. Same CSP/no-store/frame-denial as the consent page.

Limitation recorded in docs/backend-handoff.md: revoking a grant makes
its upstream session cryptographically unreachable but cannot log it
out — the props can no longer be decrypted.

Tests (7, end to end through the Worker with the fake upstream): form
and headers, CSRF refusal, wrong password re-render without a session
cookie, sign-in + listing, disconnect (token → 401, gone from the list),
revoke refused without a session and a bogus id ignored, sign-out.

pnpm check: typecheck, eslint, prettier, 252 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 23:54:25 -07:00
Tanish Manku a0352a7d6d mcp: connections page as a pure render
src/pages/connections-page.tsx: signed-out, a sign-in form (password,
optional Google) with a CSRF field; signed-in, the visitor's connected
apps — client name, when connected, what it can do in plain words —
each with a Disconnect form, plus sign-out. Everything escaped; the
password field never carries a value.

Tests (7): form, CSRF field, Google button only with a client id,
escaped error/username, listing with scope words and forms, hostile
client name escaped, empty state, notice.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 23:52:08 -07:00
Tanish Manku a614a91e6b mcp: connections-page building blocks — KV page session, upstream sign-out, CSRF path
src/pages/ui-session.ts: a 15-minute page session for the connections
page — a random id in an HttpOnly cookie scoped to /connections, mapped
in the existing KV namespace (own `mcpui:session:` prefix) to the
Artcraft user token and username. It holds no upstream credential: the
page only needs to know who is looking.

src/upstream/sign-out.ts: best-effort POST /v1/logout for a credential
we hold (used after identifying a page visitor, and wherever a held
session should end). csrfCookieHeader takes a path so the connections
form gets its own cookie scope.

Tests (7): create/read/destroy, isolation between sessions, malformed
and unknown ids ignored, own key prefix with nothing secret stored,
cookie round trip; sign-out header and outcome on 2xx/5xx/network error.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 23:50:40 -07:00
Tanish Manku b708bc614a mcp: document the tool set, resources, prompt, and upstream facts learned
Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 23:48:21 -07:00
Tanish Manku a33bec94da mcp: artcraft://models/{kind} resource and the plan_generation prompt
src/mcp/resources.ts registers the catalogue as a resource template for
principals with read:catalog — five listable documents (one per kind),
each read through list_models and served as JSON. src/mcp/prompts.ts
registers plan_generation: given a brief (and optionally a kind), walk
list_models → estimate_cost → get_credit_balance and recommend, without
generating anything.

Tests (3, via the SDK's in-memory client): template and resource
listing, a JSON read, an unknown kind refused; the resource withheld
without read:catalog; the prompt listed and rendered with its steps.

pnpm check: typecheck, eslint, prettier, 231 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:52:55 -07:00
Tanish Manku 110f47085a mcp: job status tools and the fake's jobs
fake-upstream/src/jobs.ts ports infra/fake-storyteller-web's job
payloads — the single/batch variant and the richer session-list variant
(maybe_failure_category_updated, maybe_failure_message, maybe_batch_token)
— with three seeded jobs for the seeded user: finished with a CDN
result, running at 40%, and failed on a content rule. Routes: single
(404 envelope for unknown), batch (repeated or comma-joined tokens),
session listing (session-only, newest first, include/exclude state
filters). All validated against the spec.

src/mcp/tools/jobs.ts normalises all three payloads into one JobSummary
(category, status, is_finished, progress, model, prompt, failure,
result with CDN URL) and defines get_job_status, get_jobs_status (≤50
tokens, deduplicated) and list_recent_jobs (state filters as the API's
comma-joined queries). The newer failure category is the API's
forward-compatible enum whose catch-all is `{ unknown: raw }` — typecheck
caught that my type had it as a plain string; the normaliser now keeps
the raw value.

Tests (10 tool + 6 fake): normaliser cases incl. the catch-all and
terminal states; finished/running/404 for a single job; batch
deduplication and omission of unknown tokens; session listing order,
failure detail, and filter queries. All three registered under
read:jobs; the handler suite now lists all seven tools.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:50:15 -07:00
Tanish Manku 46f2d84f16 mcp: estimate_cost tool and the fake's cost routes
fake-upstream/src/pricing.ts ports infra/fake-storyteller-web's estimate
(base credits per kind × batch count × video duration in 6s units;
unknown or missing model → 400 with the common envelope); the five cost
routes are anonymous like upstream and validate against the spec.

src/mcp/tools/estimate-cost.ts takes the kind, the model id, and the
same parameters the generate endpoint accepts (named as the API names
them, valid values from list_models), strips nulls, passes the body
through to the kind's cost endpoint (upstream is the validator; its 400
message surfaces), and returns credits, cents, watermark and refund
flags — labelled as public pricing, since upstream does not apply the
caller's plan.

Tests (5 tool + 7 fake): pass-through body and labelled result, null
stripping and bare calls, upstream's validation message for a model of
the wrong kind, every kind; spec-valid estimates, batch/duration
scaling, 400 envelopes. Registered under read:catalog.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:45:41 -07:00
Tanish Manku f96cd8b915 mcp: list_models tool and the fake's model catalogues
fake-upstream/src/catalogue.ts ports infra/fake-storyteller-web's
catalogue: the real model ids, creators and display names for image,
video, audio, mesh and splat, with one representative capability
profile per kind. The image and video endpoints' own `provider` filter
(OmniGen*ModelsProvider: "artcraft" — the default — or "all") is
mirrored, including a 400 for other values; audio, mesh and splat
declare no filter and list everything. All five catalogues validate
against the spec.

src/mcp/tools/list-models.ts always asks upstream for "all" so the
catalogue is complete, applies the caller's GenerationProvider filter
client-side via the providers block, and passes each model's capability
fields through verbatim — what lets a client build a valid
estimate_cost call from one source of truth.

Typecheck caught the contract mismatch on the way: the tool first sent
GenerationProvider values as the endpoint's query, which the fake
accepted and production would reject; the generated types refused it.

Tests (5 tool + 8 fake): normalisation and pass-through, "all" query for
image/video, client-side provider filtering for every kind, every kind
served; spec-valid catalogues, upstream's default and enum, no-filter
kinds listing everything. Server and handler suites list the tool
under read:catalog.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:42:14 -07:00
Tanish Manku bf12fcbf66 mcp: get_account tool
Who is signed in: username, display name, enabled feature flags, and
account-setup state (email set/confirmed, password set, username
customized) with a one-line text rendering that names what is still to
do. Credits and plan stay in get_credit_balance. Registered for
read:account alongside it.

Tests (4): structured output and text, the "setup complete" wording, an
anonymous session as a tool failure, 401 as an invalid session. The
SessionInfoSuccessResponse fixture moves to test/helpers/fixtures.ts
and is shared with the authenticator suite.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:36:34 -07:00
Tanish Manku 728398603a mcp: document the MCP transport modules and verified SDK facts
Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:23:07 -07:00
Tanish Manku 0798f89312 mcp: the protected route — principal from props, stateless Streamable HTTP, revoke on dead session
src/mcp/handler.ts replaces the 501 stub. The provider's decrypted grant
props become a Principal (a grant this build cannot read → 403, never
guessed at); a read-use upstream client is bound to its credential; a
fresh McpServer and the SDK's web-standard Streamable HTTP transport
(stateless, JSON responses) serve the request; and when a tool reports
the upstream no longer accepts the credential, the grant is revoked via
unwrapToken + revokeGrant after the response, so the client's next
request meets a 401 and re-authorizes.

Only /mcp is protected: access tokens are audience-bound to it, so a
legacy /sse route could never be reached with one; the unprotected app
now answers /sse with a 405 pointing at /mcp.

Tests (6, the SDK's real Client + StreamableHTTPClientTransport routed
into worker.fetch, fake upstream in-process): initialize → list →
get_credit_balance with the seeded balances; empty list for a grant
without read:account; 401 unauthenticated; 403 for a grant with
unreadable props; revocation after the fake session is logged out (tool
error, then 401); /sse pointer. Earlier suites that asserted the stub
now assert a JSON-RPC initialize succeeds, with grant props recorded
the way consent records them.

pnpm check: typecheck, eslint, prettier, 181 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:21:13 -07:00
Tanish Manku d1e49ac3d3 mcp: first tool (get_credit_balance) and the per-principal MCP server factory
src/mcp/tools/types.ts declares how a tool is defined — name, model-
facing description, required scope, zod input/output shapes, read-only
annotations — and unwrapUpstream, the single place upstream results
become tool outcomes: 401 → UpstreamSessionInvalid, other 4xx → the
server's message, 5xx/network → "could not be reached".

src/mcp/tools/get-credit-balance.ts fetches credits and the active
subscription in parallel and returns structuredContent plus a one-line
text rendering.

src/mcp/server.ts builds an McpServer for one principal: only tools the
principal's scopes cover are registered (tools/list reflects
permissions); handlers return structured + text, map ToolFailure to
isError results, and report UpstreamSessionInvalid to the handler so
the grant can be revoked. When scopes leave nothing to register, the
tools capability and an empty tools/list are installed through the
public low-level API, so a client sees an empty list rather than
"method not found" (the SDK installs its handlers lazily).

Tests (11): the tool against a scripted upstream with spec-validated
fixtures (text, structure, headers, 401/4xx/5xx mapping); the server
driven by the SDK's real Client over an in-memory transport (identity,
listing with annotations and output schema, scope hiding, structured
call result, session-invalid and unavailable outcomes).

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:16:57 -07:00
Tanish Manku 0ce7dc22b3 mcp: fake-upstream — credits and subscription routes
Credits ported from infra/fake-storyteller-web (seeded 500 monthly +
120 banked); the subscription route written from the spec, which that
branch does not fake. Both are session-only, as upstream: anonymous
callers get the common 401 envelope; unknown namespaces 400.

Tests (5): balances and subscription in the spec shape (validated),
401 without a session, 400 on an unknown namespace.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:12:38 -07:00
Tanish Manku 3ade6330d2 mcp: Principal — the one shape every token resolves to, rebuilt from grant props
src/tokens/principal.ts: userToken, username, displayName, scopes, and
the upstream credential. The provider hands the protected handler only
the decrypted grant props, so finishAuthorization now stores userToken
and the granted scopes in props alongside the credential and issue
time; principalFromProps validates all of it and fails closed with
PrincipalError (→ 403) on anything this build does not understand —
unknown scopes, wrong token prefix, unrecognised credential kind,
missing issue time. Personal tokens (M2) will produce the same props
through resolveExternalToken and land on the same function.

Also drops @hono/mcp: the SDK's WebStandardStreamableHTTPServerTransport
is the right fit for Workers and carries our principal via
AuthInfo.extra.

Tests (8): rebuild, scope checks, no credential leak through JSON, and
the fail-closed cases.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:11:31 -07:00
Tanish Manku 6509a2ea0c mcp: document the consent-flow modules and facts
Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:05:41 -07:00
Tanish Manku dd7de99f8e mcp: wire /authorize — consent page, CSRF, sign-in through the Authenticator, finishAuthorization
GET /authorize validates the OAuth request through the provider and
renders the consent page with a fresh CSRF token, a nonce'd CSP
(scripts/styles by nonce plus Google Identity Services origins only,
form-action 'self', frame-ancestors 'none'), no-store, X-Frame-Options
DENY. The original query is echoed in a hidden field; nothing is stored
between GET and POST.

POST /authorize checks the CSRF double-submit, re-validates the request
from the echoed query (a tampered redirect is refused locally, never
followed), sends access_denied + state + iss back on cancel, signs in
through the Authenticator, re-renders with the reason on failure (401 /
502, username prefilled, password never echoed), and on success calls
finishAuthorization and redirects with the code. Never auto-approves.

createApp takes injectable deps (default: the Artcraft authenticator for
the configured upstream, and the public Google client id when the
GOOGLE_CLIENT_ID binding is set — preview and production carry the id
storyteller-web already verifies; local stays password-only).

Tests (8, end to end through the Worker with the fake upstream served
in-process): page + headers; missing/mismatched CSRF → 403; deny →
access_denied redirect; wrong password → 401 re-render; incomplete form
→ 400; tampered request → 400; real sign-in → 302 with a code that
exchanges for a bearer reaching /mcp, grant under user_localdev1, props
holding the session the fake actually issued; Google credential path.

pnpm check: typecheck, eslint, prettier, 152 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:04:48 -07:00
Tanish Manku 4b546bbe71 mcp: CSRF double-submit and finishAuthorization
src/auth/csrf.ts: random 43-char base64url tokens set in an HttpOnly,
SameSite=Lax cookie scoped to /authorize (Secure over https) and echoed
in a hidden field; compared in constant time. Blocks login CSRF against
the consent form.

src/auth/finish-authorization.ts: the last consent step shared by every
sign-in route — granted scopes = requested ∩ supported (empty request →
all supported), completeAuthorization under the Artcraft user token,
props {credential, grantIssuedAt, username, displayName}, storage-
visible metadata limited to the client name.

Tests (9): token shape and uniqueness, matching rules, cookie round
trip, Secure only on https; scope narrowing never invents scopes; a
grant recorded through the real provider unwraps to the expected props
and yields a bearer that reaches /mcp with the narrowed scope.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 21:01:55 -07:00
Tanish Manku ffc54c7e96 mcp: fake-upstream — sign-in, Google SSO, sign-out, and session, ported from fake-storyteller-web
The fake now serves the auth-use routes the consent flow needs, with the
same seeded account as the infra/fake-storyteller-web branch
(localdev1 / localdev1pass, user_localdev1) and the same session model:
an HS256 JWT (Web Crypto, since this runs on workerd) returned as
signed_session and set as a `session` cookie, accepted back as that
cookie or the `session` header, cookie first — exactly as the real
backend behaves. Login speaks the backend's error dialect (error_type +
error_message); Google SSO accepts any credential and signs in the
seeded user; logout ends only the presented session.

Tests (12): every response validated against the spec snapshot
(LoginSuccessResponse, LoginErrorResponse, SessionInfoSuccessResponse,
LogoutSuccessResponse, GoogleCreateAccountSuccessResponse,
CommonWebError); header and cookie identification; unknown sessions
anonymous; logout scoped to one session; non-allowlisted routes 404.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:59:28 -07:00
Tanish Manku 261a9dd544 mcp: consent page as a pure render function
src/auth/consent-page.tsx renders the one-screen sign-in + consent page
from a view model with Hono JSX: who is asking (client name), what they
get (scope descriptions, "cannot generate or spend credits"), where the
user is sent afterwards (redirect host, with an explicit warning for
loopback redirects), a password form, an optional Google Sign-In button
whose callback submits through the same form under a nonce, and hidden
fields carrying the original authorize query and a CSRF token. No I/O,
no secrets, no raw HTML insertion. Rendering is guarded at runtime with
isValidElement + a synchronous toString (Hono's JSX.Element type and
runtime value differ; the guard documents that).

Tests (7): content and scope descriptions; hidden fields escaped; a
hostile client name, error message, and prefilled username are escaped
rather than rendered; loopback warning; Google button and nonce'd
script only when a client id is configured; the password field never
carries a value.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:57:34 -07:00
Tanish Manku b5c4743ba2 mcp: Authenticator seam with the Artcraft sign-in implementation, and spec contract checks
src/auth/authenticator.ts is swap point 1: what the user submitted on
the consent page → an upstream credential plus the identity it belongs
to. The M1 implementation proxies Artcraft's own sign-in (POST /v1/login
or /v1/accounts/google_sso through the auth-use upstream client), then
identifies the user with the new session (GET /v1/session through a
read client carrying that credential). Failures map to user-facing
reasons (invalid_credentials, account_needs_password, google_rejected,
not_signed_in, upstream_unavailable); 5xx and network errors never echo
upstream details or the submission.

test/helpers/contract.ts validates values against the spec snapshot with
Ajv (2020-12 + formats); `fixture(schema, value)` validates a fixture at
declaration. All authenticator fixtures pass — including a full
SessionInfoSuccessResponse — so the tests exercise shapes the backend
actually publishes.

Tests (8): password success with the exact request sequence, headers,
and body; InvalidCredentials and AccountNeedsPassword mapping; 5xx and
network failures → upstream_unavailable without echo; anonymous session
→ not_signed_in; Google success and rejection with the server message.

Also: pnpm workspace saveExact so `pnpm add` pins exactly.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:53:14 -07:00
Tanish Manku 93a99c0e42 mcp: document the auth modules and verified provider facts
Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:38:45 -07:00
Tanish Manku 03bf1bd795 mcp: CIMD clients end to end, and shared OAuth test helpers
Client ID Metadata Documents are the registration path Claude.ai,
ChatGPT, and Claude Code prefer. The provider fetches the client_id URL;
tests intercept that outbound fetch with a global fetch stub (tests and
the Worker share an isolate; this pool-workers release exports no
fetchMock) and refuse every other host.

Tests (5): a CIMD client resolves from its URL and completes consent,
code exchange with client_id = URL, and a bearer call to /mcp; Claude
Code's loopback redirects match on any port for both localhost and
127.0.0.1 (RFC 8252); non-loopback redirects still need an exact match;
an unfetchable document is rendered locally as 502 by /authorize
(CimdFetchError handled — not a crash, no redirect); a document whose
client_id does not match its URL is refused (502).

test/helpers/oauth-client.ts now holds the client-side helpers (PKCE,
token requests, calls) shared by the token-flow and CIMD suites.

pnpm check: typecheck, eslint, prettier, 110 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:37:32 -07:00
Tanish Manku 99fce07806 mcp: enforce the 90-day absolute grant lifetime on every token exchange
src/auth/grant-age.ts owns GRANT_MAX_AGE_SECONDS and a pure check over
the grant's `grantIssuedAt` prop; oauth.ts wires it into the provider's
tokenExchangeCallback so both code exchanges and refreshes fail closed
with invalid_grant once a grant is older than 90 days — or whenever the
timestamp is missing or malformed. The upstream session cannot be
refreshed, so a grant must eventually re-consent; this is also the
runway for swapping the upstream credential later.

Tests: 6 function-level cases (fresh, boundary ±1s, 90-day default,
malformed/missing timestamps fail closed, explicit maximum) and 2
end-to-end cases through /token (aged-out grant and timestamp-less
grant both → 400 invalid_grant). A test bug found on the way — passing
undefined to a defaulted parameter applies the default — is fixed with
an explicit null sentinel.

pnpm check: typecheck, eslint, prettier, 105 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:34:03 -07:00
Tanish Manku f9398235ff mcp: prove the full OAuth token loop end to end
index.ts now exports the provider options so tests can drive the consent
step through getOAuthApi exactly as the consent page will; the 501 stub
on /mcp names the decrypted credential kind (never the secret).

Tests (10): authorization code redirect carries code, state, and RFC 9207
iss; form-encoded exchange yields a Bearer with expires_in 3600, a
refresh token, and the granted scopes; the bearer reaches /mcp with
props decrypted and the session absent from the body; wrong PKCE
verifier and replayed code → invalid_grant; refresh rotates, the
superseded token is refused once its replacement is used, rotated
access tokens still work; revocation via the published endpoint and via
revokeGrant (the connections page's path) stops both token kinds; KV
never contains the upstream session in clear.

pnpm check: typecheck, eslint, prettier, 97 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:31:23 -07:00
Tanish Manku c1c33caab5 mcp: wire the OAuth provider — lazy 401, RFC 9728/8414 metadata, DCR, CIMD, validating /authorize
src/auth/oauth.ts configures @cloudflare/workers-oauth-provider: /mcp and
legacy /sse protected; /authorize (ours), /token and /register (library);
CIMD enabled (with global_fetch_strictly_public in wrangler.toml); scopes
read:account/read:jobs/read:catalog; 1h access, 30d idle refresh; error
logging by code/category only. `resource` and `authorization_servers`
are derived per request origin so one build serves every environment;
production sets workers_dev = false so its only origin is canonical.

src/index.ts checks the environment invariant first (a misconfigured
deploy fails even /token), then hands off to the provider. src/runtime.ts
holds the memoized config+app. src/auth/authorize.ts validates the OAuth
request via parseAuthRequest, renders unknown-client errors locally and
client-validated errors as redirects, and stops at 501 — it never
auto-approves. src/mcp/handler.ts is a 501 stub reachable only with a
valid token.

Tests (10): 401 + resource_metadata challenge on /mcp and /sse, bogus
bearer → 401 not 500, health still reachable; PRM at root and /mcp
paths with derived resource and this origin as issuer; AS metadata with
S256, CIMD flag, "none" auth method, iss support, registration and
revocation endpoints; /authorize 400 locally on malformed and unknown
client, 201 DCR then 501 with no redirect.

pnpm check: typecheck, eslint, prettier, 87 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:29:23 -07:00
Tanish Manku 7e2708e589 mcp: working method — choose verification grain by blast radius
Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:20:40 -07:00
Tanish Manku afd2d0d6a9 mcp: UpstreamClient — typed openapi-fetch client gated by allowlist, use, origin, and credential
src/upstream/client.ts is the only way this service talks to the Artcraft
API. Middleware refuses, before anything leaves the Worker: routes not
allowlisted for the client's `use` (tools = read, sign-in/out = auth),
per-request baseUrl overrides to any other origin, and concrete URLs that
do not match the allowlisted template (independent second check). The
credential is attached last, so a rejected request never carries it.
Read clients require a credential; auth clients may sign in without one.

Tests (13): headers and URL of a good request, path substitution, array
query params as repeated keys (what actix_web_lab parses), 401 passed
through as a result not an exception, credential required for read,
sign-in without and sign-out with a credential, and the gate firing —
auth route from read client, read route from auth client, unlisted
path, foreign baseUrl — each with fetch provably not called; path
parameters are percent-encoded so they cannot escape their segment; the
rejection message never contains the credential.

CLAUDE.md layout updated for the upstream module and generated files.

pnpm check: typecheck, eslint, prettier, 77 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:15:40 -07:00
Tanish Manku 4f810e44d6 mcp: UpstreamCredential interface with the session implementation
src/upstream/credential.ts defines what this service holds on a user's
behalf and how it reaches an upstream request. The only kind today is an
Artcraft session (the signed_session JWT from sign-in) sent as the
`session` header; `kind` is stored in grant props from the start so a
backend-issued credential can replace it later without touching tools or
the client (swap point 2 in mcp/CLAUDE.md).

The contract includes non-leaking toString/toJSON: accidental string or
JSON conversion yields describe(), never the secret (the secret lives in
a private field). Sign-in values are rejected if empty, non-ASCII, or
containing whitespace/CRLF (header injection). Unknown prop kinds are an
error, not a fallthrough.

Tests (9): header attached exactly once and nothing else set, replace
semantics, props round-trip, no leak via describe/String/JSON/keys, and
validation firing on empty, injection, unknown kind, missing secret,
non-object props.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:12:08 -07:00
Tanish Manku 44e5774746 mcp: runtime upstream allowlist with use gating
src/upstream/allowlist.ts loads allowlist.json (the same file the spec
snapshot is trimmed to), validates it (get/post only, /v1 paths, no
duplicates), and exposes lookups by OpenAPI template path and by concrete
pathname, plus `isAllowed(route, use)` so `auth` routes (sign-in/out) are
never reachable from `read` callers (tools).

Tests (18): the committed list matches the spec snapshot exactly in both
directions and marks only login/google_sso/logout as auth; lookups by
template and by pathname (one segment per variable, literals not
patterns, wrong method rejected); use gating; and the validation fires on
delete methods, non-/v1 paths, unknown uses, duplicates, and empty lists.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:10:43 -07:00
Tanish Manku adc91b9e57 mcp: generate upstream types and a trimmed spec snapshot from the published OpenAPI document
scripts/gen-api.mjs fetches https://storyteller-docs.netlify.app/api.json
(the source of truth for API shape), trims it to src/upstream/allowlist.json
plus transitively referenced schemas (19 paths / 103 schemas instead of
253 / 859), and emits test/fixtures/api.json and src/upstream/schema.d.ts
via openapi-typescript. Both outputs are committed; `pnpm gen:api`
regenerates them deliberately.

Two discrepancies in the published document are corrected during
generation and recorded in docs/backend-handoff.md:
- path parameters are declared as one object-typed parameter (flattened
  when its properties match the path template);
- POST /v1/login and /v1/accounts/google_sso declare their JSON body a
  second time as a path parameter (dropped as a duplicate of requestBody).
The generator refuses to write anything if it meets a shape it does not
recognise.

Also: .gitattributes normalising to LF, eslint/prettier ignores for the
generated files, Node-script lint block, nested .wrangler ignores.

pnpm check: typecheck, eslint, prettier, 37 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 20:09:03 -07:00
Tanish Manku 0ec3d93437 mcp: prove the guardrails fire, test the misconfigured entry path, single-target fake config
Whole-pass audit of the scaffold found three gaps and closes them:

- Guardrail tests only ever passed. The wrangler.toml mapping and the
  MCP_ENVIRONMENT isolation checks are now pure functions
  (test/helpers/) tested against mutated input: preview→production,
  production→fake, local→production, mislabelled env, shared KV id,
  extra "staging" env, changed entry point, a file reading the binding,
  a file branching on the environment name.
- src/index.ts's misconfiguration path had no test. Added: 500 with the
  exact mismatch message, memoized failure across requests, missing
  bindings reported, sound bindings still serve.
- fake-upstream had an [env.preview] block it did not need (one deploy
  target) which made wrangler warn; now top-level only, script `deploy`.

Also verified out-of-band and recorded in CLAUDE.md as rules 4–6 of the
working method: `pnpm install --frozen-lockfile`, `wrangler deploy
--dry-run` for both Workers (production bundle contains no fake code),
the eslint boundary rejects a server→fake import, and both Workers boot
under `wrangler dev` and answer over HTTP.

pnpm check: typecheck, eslint, prettier, 37 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 19:58:44 -07:00
Tanish Manku 59a7cc5fb4 mcp: scaffold Worker, environment guardrails, fake-upstream skeleton, CI
Cloudflare Worker scaffold for the MCP server (Hono, TypeScript strict,
pnpm 11 via corepack, wrangler 4, vitest 4 in workerd).

- wrangler.toml declares local/preview/production; production alone pins
  https://api.storyteller.ai. Binding types are generated by `wrangler types`.
- src/config.ts parses the bindings and asserts the upstream-host
  invariant per environment; src/index.ts fails every request with a 500
  naming the mismatch if the invariant is violated.
- Guardrail tests: config invariant, wrangler.toml environment mapping,
  MCP_ENVIRONMENT referenced only in config.ts and never branched on,
  /healthz through the Worker entry.
- fake-upstream/: separate Worker (artcraft-api-fake) that will hold the
  ported fake API routes; eslint forbids imports across the boundary in
  both directions.
- .github/workflows/mcp.yml: typecheck, lint, format, test on mcp/**;
  production deploy skips with a notice until Cloudflare secrets exist.

pnpm check: typecheck, eslint, prettier, 28 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 19:50:00 -07:00
Tanish Manku 984e8d3845 mcp: project context, constraints, and one-time infra request
Adds mcp/CLAUDE.md as the standing context for the Artcraft MCP server:
hard constraints (no Rust changes, no webapp changes without approval,
library OAuth server, Workers hosting, read-only M1), the verify-as-you-go
working method, the environment model (bindings not branches, fake
upstream), the architecture seams, verified upstream API facts, client
requirements, security rules, and the API-shape source-of-truth rule.

mcp/docs/infra-request.md batches the one-time Cloudflare, repo-secret,
and Google console asks for the backend team. Root CLAUDE.md gains the
mcp/ entry in the project tree.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
2026-08-24 19:49:59 -07:00
BFlat 915728e9c7 Focus mode drag & drop (#1912)
Drag and drop didn't work while the prompt focus mode was open - the
modal sat on top of the inline promptbox's drop zone, so files just did
nothing (webapp and desktop).

### Changes

- `usePromptBoxDrop` now exposes `fullscreenDropZoneProps` - same
handlers/routing, second ref so the Tauri hit-test follows the
focus-mode modal while it's open
- `PromptFullscreenModal` (shared lib + webapp copy) takes optional
`dropZoneProps` / `dropOverlay` and renders the drop zone + hover
overlay on its panel
- Image / Video / Audio boxes + webapp `PromptBox` pass their existing
drop wiring through - one overlay element now serves both the inline box
and focus mode
- No new drop logic anywhere: same MIME routing, accept/reject toasts,
keyframe handling, and paste behavior as dropping on the inline box
2026-08-19 21:16:04 +07:00
BFlat 58d6d7f72e Fix iOS page-reload crash when adding image prompts + upload flicker (#1911)
On iOS, adding more than 2 image prompts can blow past the browser's
memory limit and force-reload the page, wiping your prompts. We were
storing every uploaded image as a full-res base64 string just to show a
56px thumbnail.
- Thumbnails are now small ~512px previews instead of full-res data URLs
(memory per image goes from ~100MB+ to under 1MB)
- Tapping a thumbnail still shows the original full-res image
- Nothing changes for the backend — uploads and generation still use the
original file + media token
- Fixed the flicker when an upload finishes: the spinner card now turns
into the image card in place, instead of a new card popping in next to
it
- Small cleanups along the way: leaked object URLs in the old prompt
row, and upload spinners that never actually spun

Applies to webapp (desktop + mobile), website, and the desktop app since
they share the upload code.
2026-08-19 20:54:57 +07:00
BFlat b55aa0d2cb Add last viewed badge for previously viewed media (#1910)
for both desktop and webapp
2026-08-18 11:06:28 +07:00
hanashi ce8b7e4637 Seedance 2.5 1080p (#1907) 2026-08-17 12:31:44 -04:00
hanashi 4975440053 rates (#1904) 2026-08-14 00:34:08 -04:00
hanashi f3442f3146 fix lossy margins (#1903) 2026-08-13 05:19:08 -04:00
hanashi ac613b007b unify pricing for legacy endpoint (#1901) 2026-08-13 02:17:07 -04:00
hanashi 47aa6a6c06 no implicit 720p anymore - make this explicit (#1900) 2026-08-13 01:40:51 -04:00
hanashi da6a6c8441 don't send default resolution anymore (#1899) 2026-08-13 01:29:35 -04:00
Hanashi 5cacd89ab8 fix build error 2026-08-12 23:16:27 -04:00