Skip to content

Commit 166bacc

Browse files
MajorTalclaude
andcommitted
fix(sdk): re-point functions.deploy onto unified apply + transfer-accept keys
The gateway deleted POST /projects/v1/admin/:id/functions (live on main), so r.functions.deploy() 404s in prod. Re-point it onto the unified apply engine (a one-function functions.patch.set ReleaseSpec) — the same path the rest of the SDK uses. Auth shifts from the service-key bearer to the standard apply credential (SIWX wallet, or the v3.1.0 project.deploy operator-approval gate). Public signature + FunctionDeployResult shape unchanged; runtime_version and deps_resolved are null on the deploy result (apply returns release-level data — they live on the functions-list record). Wire `deps` end-to-end through the apply engine (capability apply-v1-function-deps, verified on origin/main): public FunctionSpec.deps, the validateSpec field allowlist, normalizeFunction, and functionToWire. Without all four, functions.deploy({ deps }) would silently drop deps at the wire. transfer-accept (#428): AcceptTransferResult gains anon_key/service_key, and accept() persists them via saveProject + setActiveProject (mirroring provision) so the new owner can operate the inherited project with no extra provisioning. Tests: rewrote the SDK + MCP deploy tests onto the apply flow (plan+commit; empty missing_content skips upload, terminal ready skips poll); added transfer-accept persistence tests. Corrected the now-false docs (unified deploy does accept deps; the deploy result no longer surfaces runtime_version / deps_resolved). npm test green (0 fail), build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 154ab0f commit 166bacc

17 files changed

Lines changed: 574 additions & 180 deletions

File tree

cli/lib/functions.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,12 @@ Notes:
8787
export default async (req: Request) => Response
8888
Deploy may require payment if the project lease has expired.
8989
90-
The deploy response includes:
91-
- runtime_version: the bundled @run402/functions version (e.g. "1.48.0")
92-
- deps_resolved: map of each --deps name to the actually-installed
93-
concrete version (e.g. {"lodash":"4.17.21"})
94-
- warnings (optional, top-level, sibling to the record): non-fatal
95-
notes such as bundle-size advisories
90+
Deploy routes through the unified apply engine (functions.patch.set), not
91+
a legacy admin route. The result sets runtime_version and deps_resolved to
92+
null (apply returns release-level data, not per-function build metadata) -
93+
read the resolved versions from 'run402 functions list', whose records
94+
carry both. The result still includes an optional top-level warnings:
95+
string[] for non-fatal notes such as bundle-size advisories.
9696
9797
Examples:
9898
run402 functions deploy prj_abc123 stripe-webhook --file handler.ts

cli/llms-cli.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Manifest format mirrors a v2 `ReleaseSpec`. For editor autocomplete, use top-lev
228228

229229
File entries accept a bare UTF-8 string, `{ "data": "...", "encoding": "utf-8" | "base64", "content_type": "..." }`, or `{ "path": "dist/index.html", "content_type": "text/html" }`. In `--manifest` files, relative paths resolve against the manifest file's directory; in `--spec` / stdin they resolve against the current working directory. Migration entries may use `"sql_path"` or `"sql_file"` instead of inline `"sql"`. The CLI and MCP adapters share the SDK's typed `normalizeDeployManifest` path, so the same JSON can become SDK-native `ReleaseSpec` code without reimplementing base64 or path handling. The adapter is strict: only top-level `$schema` metadata is ignored; unknown fields and no-op specs are rejected before deploy planning, so typos like `"subdomain"`, `"site.replcae"`, `"functions.replace.api.deps"`, or `"functions.replace.api.config.schedule"` do not become partial deploys.
230230

231-
Function specs in unified deploy manifests accept `runtime` (`"node22"`), exactly one code source (`source` for a single module, or `files` plus `entrypoint` for multi-file functions), `config.timeout_seconds`, `config.memory_mb`, and `schedule` as a sibling of `config`. In patch mode, omit `schedule` to leave it unchanged and use `"schedule": null` to remove an existing schedule. Unified deploy manifests do not accept `deps` yet; bundle dependencies into the function files or use `run402 functions deploy --deps` for the standalone function path.
231+
Function specs in unified deploy manifests accept `runtime` (`"node22"`), exactly one code source (`source` for a single module, or `files` plus `entrypoint` for multi-file functions), `config.timeout_seconds`, `config.memory_mb`, and `schedule` as a sibling of `config`. In patch mode, omit `schedule` to leave it unchanged and use `"schedule": null` to remove an existing schedule. Unified deploy manifests accept `deps` (a `string[]` of npm specs) under capability `apply-v1-function-deps`; the gateway installs and bundles them. `run402 functions deploy --deps` builds a one-function `functions.patch.set` and rides this same unified-apply path (the legacy standalone deploy route was removed).
232232

233233
Deploy preflights obvious tier function caps after manifest normalization but before CAS upload or `/apply/v1/plans`: timeout, memory, cron interval, and scheduled-function count. Structured local failures use `code: "BAD_FIELD"` with `details.field`, `details.value`, `details.tier`, the relevant limit e.g. `tier_max` or `min_interval_minutes`, and `details.limit_source` (`tier_status` or `local_static_fallback`). Current authoring caps: prototype 10s / 128 MB / 1 scheduled fn / 15 min, hobby 30s / 256 MB / 3 / 5 min, team 60s / 512 MB / 10 / 1 min. `run402 tier status` shows live function caps and current scheduled usage when the gateway returns them.
234234

@@ -589,7 +589,7 @@ Create a manifest file `app.json` (use the `project_id` from provision):
589589

590590
`project_id` is required (from `run402 projects provision`). All other fields are optional; omitted top-level sections are carried forward from the current release. The adapter is strict: only top-level `$schema` metadata is ignored; typo fields e.g. `subdomain`, `site.replcae`, `functions.replace.my-fn.deps`, or `functions.replace.my-fn.config.schedule` fail before planning.
591591

592-
Function specs accept `runtime: "node22"`, exactly one code source (`source`, or `files` plus `entrypoint`), `config.timeout_seconds`, `config.memory_mb`, a sibling `schedule`, and the v1.51+ auth-gate fields `require_auth` / `require_role` (see "Function-level auth gates" below). Omit `schedule` in patch mode to leave it unchanged; set it to `null` to remove the schedule. Unified deploy manifests do not accept `deps`; bundle dependencies into the function files or use `run402 functions deploy --deps`.
592+
Function specs accept `runtime: "node22"`, exactly one code source (`source`, or `files` plus `entrypoint`), `config.timeout_seconds`, `config.memory_mb`, a sibling `schedule`, and the v1.51+ auth-gate fields `require_auth` / `require_role` (see "Function-level auth gates" below). Omit `schedule` in patch mode to leave it unchanged; set it to `null` to remove the schedule. Unified deploy manifests accept `deps` (npm specs) under capability `apply-v1-function-deps`; `run402 functions deploy --deps` rides this same unified-apply path.
593593

594594
Function-level auth gates (v1.51+). Two optional declarative fields on each function spec:
595595

@@ -890,7 +890,7 @@ One noun, two rails: a **wallet** recipient is a two-party wallet transfer; an *
890890
- `run402 transfer init --to <wallet|email> [--project <id>] [--billing-policy migrate] [--message <text>] [--kysigned <record_id>] [--retain-collaborator developer]` — initiate (owner-only). `--to` routes by recipient kind: a wallet starts a transfer, an email starts a handoff. Returns `{ transfer_id, expires_at, ... }`. `--retain-collaborator developer` (v1.91, **email handoffs only** — rejected with `BAD_FLAG` on the wallet rail; only `developer` is valid) asks to keep a `developer` membership in the recipient's org after handoff; the recipient must accept it at claim time, and omitting it is a full severance (the default). Gateway codes: `INVALID_RETAIN_ROLE`, `RETAIN_SUBJECT_REQUIRED`.
891891
- `run402 transfer preview <transfer_id> [--handoff]` — fetch the preview document (either party); `--handoff` previews an email->org handoff. Lists custom domains, subdomains, function names, secret NAMES (never values), CI bindings that will be revoked on accept, mailbox summary, billing implications.
892892
- `run402 transfer list [--incoming | --outgoing | --handoffs] [--limit N] [--offset N]` — `--incoming` (default) shows wallet transfers OFFERED TO you; `--outgoing` shows transfers you initiated; `--handoffs` shows incoming email->org handoffs. Each entry includes `preview_path`.
893-
- `run402 transfer accept <transfer_id>` — accept a WALLET transfer (recipient). Atomically flips ownership, revokes the previous owner's CI bindings on the project, and stamps a `secrets_rotation_advised` advisory. Secret VALUES are inherited; the response returns `secret_names_inherited[]`.
893+
- `run402 transfer accept <transfer_id>` — accept a WALLET transfer (recipient). Atomically flips ownership, revokes the previous owner's CI bindings on the project, and stamps a `secrets_rotation_advised` advisory. Secret VALUES are inherited; the response returns `secret_names_inherited[]`. The accept response also returns the new owner's project `anon_key` + `service_key` (#428), and the SDK/CLI persist them into the local keystore and set the project active — so you can deploy / set secrets / run SQL on the inherited project with no extra provisioning step.
894894
- `run402 transfer claim <transfer_id> [--into <organization_id>] [--accept-retained-collaborator]` — claim an incoming EMAIL handoff into an org you own (omit `--into` to claim into a brand-new org). The handoff analog of `accept`. `--accept-retained-collaborator` accepts the sender's v1.91 retained-`developer`-membership offer (visible as `retain_collaborator` on `transfer preview`); omit it for a full severance. The claim result carries `retained_collaborator_principal_id` (or `null`).
895895
- `run402 transfer cancel <transfer_id> [--reason <text>] [--handoff]` — cancel (either party); `--handoff` cancels an email->org handoff.
896896

@@ -1040,11 +1040,11 @@ For routed browser 500s, copy `X-Run402-Request-Id` or the JSON `request_id` fro
10401040
- Limits: max 30 entries, max 200 chars per spec.
10411041
- Native binary modules (sharp, canvas, native bcrypt, etc.) are rejected.
10421042

1043-
The deploy / list / update responses include two fields populated under bundling-at-deploy:
1043+
`run402 functions deploy` routes through the unified apply engine, so its **result** sets `runtime_version` and `deps_resolved` to `null` (apply returns release-level data, not per-function build metadata). Read the resolved values from `run402 functions list` (and `update`), whose records carry both fields populated under bundling-at-deploy:
10441044
- `runtime_version` — the bundled `@run402/functions` version (e.g. `"1.48.0"`). Surface this as "Functions runtime version" — never bare "runtime", which already names the Node runtime (e.g. `node22`). `null` for legacy functions.
10451045
- `deps_resolved` — map of each `--deps` name to the -installed concrete version (e.g. `{"date-fns": "3.7.0"}` for a `^3.6.0` spec). Direct deps only; this is not a lockfile. `{}` for an empty `--deps`; `null` for legacy functions.
10461046

1047-
The deploy response also gains an optional top-level `warnings: string[]` (sibling to the function record, not inside it) for non-fatal deploy notes e.g. bundle-size advisories. Omitted or `[]` when there are no warnings.
1047+
The deploy result still includes an optional top-level `warnings: string[]` (sibling to the function record, not inside it) for non-fatal deploy notes e.g. bundle-size advisories. Omitted or `[]` when there are no warnings.
10481048

10491049
#### Function authoring limits by tier
10501050

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-17
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
## Context
2+
3+
Two gateway changes (run402-private, Jun 16-17) landed after the public client was last synced:
4+
5+
- **`d38a2e51`** deleted `POST /projects/v1/admin/:project_id/functions` (~160 lines) and removed it from `openapi.json` + `llms-full.txt`. The commit body self-corrects: *"the route WAS in openapi+llms (the proposal wrongly said absent)."* The gateway kept `deployFunction` in services (used by `/apply/v1` + bundling) and the `/functions/:name/*` management sub-routes + the GET-list. It migrated its own test callers onto a `deployFunctionViaApplyV1` helper: compute SHA-256 of the code, CAS-upload, build a `functions.patch.set` `ReleaseSpec`, POST `/apply/v1/plans` → commit → poll, return the legacy `{name,url,status,schedule}` shape.
6+
- **`ea6bc171` (#428)** added `anon_key`+`service_key` to the transfer-accept 200 body (derived post-commit via `deriveProjectKeys(project_id)` — stateless JWTs, never persisted, same as `POST /projects/v1`). `acceptTransfer` (the service) stays pure; keys are a route-layer concern.
7+
8+
Public state: `r.functions.deploy()` POSTs the deleted route with the service-key bearer; `AcceptTransferResult` is a closed interface with no key fields and `accept()` does a bare request with no keystore write.
9+
10+
## Goals / Non-Goals
11+
12+
**Goals:** restore function deploy (it 404s in prod) by routing through the same `/apply/v1` engine the rest of the SDK uses; give the transfer recipient working keys with no extra step; keep both public method signatures + return types stable.
13+
**Non-Goals:** changing the deploy-spec / apply surface; the handoff (email→org) path (#428 touched only the wallet-transfer accept); re-adding any deleted gateway route.
14+
15+
## Decisions
16+
17+
### Decision 1 — `functions.deploy()` delegates to the apply engine, not a hand-rolled dance
18+
19+
`new Deploy(this.client)` is constructible from the kernel `Client` (exactly what `Run402` does for `_applyEngine = new Deploy(client)`). `deploy()` builds a one-function `ReleaseSpec` and calls `engine.apply(spec, {})`:
20+
21+
```
22+
{ project: projectId,
23+
functions: { patch: { set: { [opts.name]: {
24+
source: { data: opts.code, contentType: "text/javascript; charset=utf-8" },
25+
...(opts.config ? { config: { timeoutSeconds: opts.config.timeout, memoryMb: opts.config.memory } } : {}),
26+
...(opts.deps ? { deps: opts.deps } : {}),
27+
...(opts.schedule !== undefined ? { schedule: opts.schedule } : {}),
28+
} } } } }
29+
```
30+
31+
The engine handles the CAS upload of `source` (a polymorphic `ContentSource` — inline bytes are normalized + uploaded automatically), the plan/commit/poll state machine, and safe-retry. We do NOT reimplement the gateway test helper's manual CAS dance — the SDK already owns it. **Alternative — hand-roll plan/upload/commit in `functions.deploy`** (mirror the gateway test helper): rejected, it duplicates `_applyEngine` and drifts.
32+
33+
**`deps` is preserved — verified against `origin/main`.** The riskiest correctness property: would re-pointing through apply silently drop the documented `deps` option? The local private working tree (`0cda4ebf`, stale) shows apply activation passing `undefined` for deps ("not surfaced through the v2 spec yet") — but that predates `d38a2e51`. On `origin/main` (the prod deploy source) the apply path fully supports function deps via capability **`apply-v1-function-deps`**: `functions.patch.set.<name>.deps: string[]`, validated at `apply-v1.ts:1417` and persisted into the staged `config` JSONB. The gateway's own `deployFunctionViaApplyV1` migration helper sets `fnSpec.deps = args.deps`, and the prod-validated `deps-smoke` test (deps:[lodash], "6/0 against prod") rides this exact path. So `functions.deploy` carries `deps` straight into `FunctionSpec.deps` with no loss. This required adding the missing **`deps?: string[]`** field to the public `FunctionSpec` type (the apply surface was deps-capable on the wire but the SDK type never exposed it).
34+
35+
### Decision 2 — Map `DeployResult → FunctionDeployResult`, best-effort, `runtime_version`/`deps_resolved``null`
36+
37+
`DeployResult` carries `release_id`, `operation_id`, `urls`, `diff`, `warnings` — not per-function build metadata. So the mapping is:
38+
- `name` ← input; `status``"deployed"` (apply reached terminal success); `schedule` ← input echo; `runtime``opts.config?.runtime ?? "node22"`; `timeout`/`memory` ← input config (or tier defaults); `created_at` ← now.
39+
- `url``result.urls[name]` if present, else derived `…/functions/v1/<name>` from the project's site host (best-effort).
40+
- `warnings``result.warnings` mapped to their messages.
41+
- `runtime_version` / `deps_resolved`**`null`**. They were fields of the deleted route's response and are not on the apply result. The gateway's own migration shim returns only `{name,url,status,schedule}`, so this matches; both fields are already typed `?: … | null`. Documented as "not surfaced via deploy; the function record carries them."
42+
43+
### Decision 3 — Auth shifts from the service key to the `project.deploy` gate
44+
45+
The old route took `Authorization: Bearer <service_key>`. `/apply/v1` authorizes via the standard credential (SIWX wallet, or — for a wallet-less human — the operator-approval `project.deploy` gate shipped in v3.1.0). This is correct: deploying a function IS a `project.deploy`. An agent with a wallet is unaffected; a wallet-less human gets the operator-approval flow automatically. `deploy()` still calls `getProject(projectId)` first to fail fast with `ProjectNotFound` on an unknown id (and the spec needs the id), but no longer sends the service key.
46+
47+
### Decision 4 — transfer-accept mirrors `provision`'s persistence
48+
49+
`AcceptTransferResult` gains `anon_key: string` / `service_key: string`. After the accept request returns, `accept()` does the same persistence `provision` does:
50+
51+
```
52+
if (creds.saveProject) await creds.saveProject(result.project_id, { anon_key, service_key });
53+
if (creds.setActiveProject) await creds.setActiveProject(result.project_id);
54+
```
55+
56+
so the new owner's keystore has the project + it's active. Gated on the optional provider methods (sandbox providers without persistence simply skip). The CLI prints the accept result as-is (the `service_key` JWT is identical to what `provision` already prints). The handoff/claim path is untouched (#428 changed only the wallet-transfer accept).
57+
58+
## Risks / Trade-offs
59+
60+
- **`runtime_version`/`deps_resolved` degradation** → documented; both already nullable; matches the gateway shim. A consumer needing them reads the function record (kept GET routes).
61+
- **`url` is best-effort** → derived from `urls[name]` or the site host; a future enrichment could read the materialized route, but is out of scope for restoring deploy.
62+
- **Auth shift could surprise a service-key-only caller** → in practice `functions.deploy` callers are the wallet/agent flow or the CLI (which has a wallet/operator session); the gate is the same one `deploy apply` uses.
63+
- **Persisting `service_key` to the keystore** → identical to `provision`; the new owner legitimately holds it.
64+
65+
## Migration Plan
66+
67+
Additive/behavioral, client-only, non-breaking to the public signatures. Ships in the normal lockstep release (urgent — the function-deploy 404 is live). Rollback = revert; the gateway routes are independent.
68+
69+
## Open Questions
70+
71+
- Whether to enrich `runtime_version`/`deps_resolved` via a post-deploy function-record read — deferred (extra round-trip; both nullable; not needed to restore deploy).

0 commit comments

Comments
 (0)