From 0253728ccd20a238f5b26e7053476798cca9a3a4 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 00:57:50 -0500 Subject: [PATCH 01/20] =?UTF-8?q?feat(cli):=20bos=20import=20=E2=80=94=20d?= =?UTF-8?q?egit-style=20workspace=20import=20from=20an=20upstream=20repo?= =?UTF-8?q?=20path=20(#392)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bos import --from [@ref]|url|dir imports a workspace subtree (e.g. plugins/auth) from an upstream GitHub repo tarball or a local directory, without cloning. Shared files are classified against a per-workspace baseline (.bos/import-snapshot.json, falling back to .bos/sync-snapshot.json) into added / unchanged / updated / preserved-local / preserved-local-edit / merged / conflict buckets; local-only files are never deleted and a diverged package.json is structurally merged instead of overwritten. Unresolvable conflicts — and a migration chain that cannot be applied verbatim (index reuse, content drift, local-only journal entries), which is held back rather than dropped — gate the run: nothing is written without --yes or an interactive confirm, exiting non-zero with a full .bos/import-report.md divergence report (conflict checklist, migration collisions and chain delta, contract route-set delta across both monolithic and split src/contract/** shapes). Successful imports back up overwritten files to .bos/import-backup/, record fresh baselines, and support --dry-run, --no-install, and --json. Closes #392 Refs #393 --- .changeset/bos-import-workspace.md | 5 + .../skills/cli-reference/SKILL.md | 16 +- .../skills/init-upgrade/SKILL.md | 61 +- packages/everything-dev/src/cli.ts | 80 ++ packages/everything-dev/src/cli/import.ts | 981 ++++++++++++++++++ packages/everything-dev/src/cli/init.ts | 27 +- .../everything-dev/src/commands/import.ts | 62 ++ packages/everything-dev/src/contract.meta.ts | 14 + packages/everything-dev/src/contract.ts | 51 + packages/everything-dev/src/plugin.ts | 2 + .../integration/import.workspace.test.ts | 285 +++++ .../tests/unit/commands-structure.test.ts | 2 + .../tests/unit/import-classify.test.ts | 103 ++ .../tests/unit/import-from-spec.test.ts | 82 ++ .../tests/unit/import-migrations.test.ts | 124 +++ .../tests/unit/import-routes.test.ts | 46 + 16 files changed, 1932 insertions(+), 9 deletions(-) create mode 100644 .changeset/bos-import-workspace.md create mode 100644 packages/everything-dev/src/cli/import.ts create mode 100644 packages/everything-dev/src/commands/import.ts create mode 100644 packages/everything-dev/tests/integration/import.workspace.test.ts create mode 100644 packages/everything-dev/tests/unit/import-classify.test.ts create mode 100644 packages/everything-dev/tests/unit/import-from-spec.test.ts create mode 100644 packages/everything-dev/tests/unit/import-migrations.test.ts create mode 100644 packages/everything-dev/tests/unit/import-routes.test.ts diff --git a/.changeset/bos-import-workspace.md b/.changeset/bos-import-workspace.md new file mode 100644 index 000000000..3628e4477 --- /dev/null +++ b/.changeset/bos-import-workspace.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +New `bos import --from [@ref]` command: imports a workspace subtree (e.g. `plugins/auth`) from an upstream GitHub repo or local directory without cloning. Every shared file is classified against a per-workspace baseline (`.bos/import-snapshot.json`, falling back to `.bos/sync-snapshot.json`) into added / unchanged / updated / preserved-local / preserved-local-edit / merged / conflict buckets; local-only files are never deleted and a diverged `package.json` is structurally merged instead of overwritten. Unresolvable conflicts (and a migration chain that cannot be applied verbatim — index reuse, content drift, local-only journal entries — which is held back rather than dropped) gate the run: nothing is written without `--yes` or an interactive confirmation, exiting non-zero with a full `.bos/import-report.md` divergence report covering the conflict checklist, migration chain collisions and delta, and the contract route-set delta. Successful imports back up overwritten files to `.bos/import-backup/`, record new baselines, and support `--dry-run`, `--no-install`, and `--json`. diff --git a/packages/everything-dev/skills/cli-reference/SKILL.md b/packages/everything-dev/skills/cli-reference/SKILL.md index 00da0dd1d..96b9cdaff 100644 --- a/packages/everything-dev/skills/cli-reference/SKILL.md +++ b/packages/everything-dev/skills/cli-reference/SKILL.md @@ -9,7 +9,7 @@ metadata: # CLI Command Reference -19 `bos` commands organised by workflow category. +20 `bos` commands organised by workflow category. ## Development @@ -119,6 +119,20 @@ bos upgrade --no-sync # only upgrade packages, skip template sync → [`init-upgrade`](.) skill for catalog strategy, legacy import rewrites, and upgrade+publish flow. +### `bos import` + +Import a workspace subtree (e.g. `plugins/auth`) from an upstream GitHub repo or a local directory, without cloning. Per-file classification is snapshot-baselined; unresolvable conflicts and a non-appliable migration chain gate the run (exit 1) unless `--yes` or an interactive confirmation. + +**Flags:** `--from ` `--yes` `--dry-run` `--no-install` `--json` + +```bash +bos import plugins/auth --from NEARBuilders/everything-dev@v2.1.0 +bos import plugins/auth --from ./local-checkout --dry-run +bos import plugins/auth --from owner/repo --yes +``` + +→ [`init-upgrade`](.) skill for classification buckets, migration-chain safety, route deltas, and the `.bos/import-report.md` report. + ### `bos status` Show project health: package versions, parent reachability, last sync, env file status, and update availability. diff --git a/packages/everything-dev/skills/init-upgrade/SKILL.md b/packages/everything-dev/skills/init-upgrade/SKILL.md index a063a3c2f..3cd42d7a4 100644 --- a/packages/everything-dev/skills/init-upgrade/SKILL.md +++ b/packages/everything-dev/skills/init-upgrade/SKILL.md @@ -1,13 +1,13 @@ --- name: init-upgrade -description: bos init, bos sync, and bos upgrade workflows — template download, snapshot-based conflict detection, package version bumps, and how init/sync select and own files. Use when scaffolding new projects, syncing upstream changes, or upgrading framework packages. +description: bos init, bos sync, bos import, and bos upgrade workflows — template download, snapshot-based conflict detection, workspace import from upstream repos with divergence reporting, package version bumps, and how init/sync select and own files. Use when scaffolding new projects, syncing upstream changes, importing a workspace subtree from another repo, or upgrading framework packages. metadata: - sources: "packages/everything-dev/src/cli/init.ts,packages/everything-dev/src/cli/sync.ts,packages/everything-dev/src/cli/upgrade.ts,packages/everything-dev/src/cli/snapshot.ts,packages/everything-dev/src/cli.ts,packages/everything-dev/src/merge.ts" + sources: "packages/everything-dev/src/cli/init.ts,packages/everything-dev/src/cli/sync.ts,packages/everything-dev/src/cli/import.ts,packages/everything-dev/src/commands/import.ts,packages/everything-dev/src/cli/upgrade.ts,packages/everything-dev/src/cli/snapshot.ts,packages/everything-dev/src/cli.ts,packages/everything-dev/src/merge.ts" --- > **Config form:** the authored config is `bos.app.ts` (canonical, preferred when both exist). A legacy `bos.config.json` is still supported for older children. Where this doc says `bos.config.json` for the *local authored file*, read "the authored config". The published artifact on FastKV keeps the key name `bos.config.json`. -# bos init, sync, upgrade +# bos init, sync, import, upgrade ## bos init @@ -182,6 +182,58 @@ bos upgrade --dry-run # Preview without making changes Workspace packages use `catalog:` refs so a single version bump in root catalog propagates everywhere. Skips `workspace:*` and `file:` refs. +## bos import + +Imports a workspace subtree (e.g. `plugins/auth`) from an upstream GitHub repo or a local directory — no clone of the full history, just a tarball (or direct local read) of one ref: + +```bash +bos import plugins/auth --from NEARBuilders/everything-dev +bos import plugins/auth --from NEARBuilders/everything-dev@v2.1.0 +bos import plugins/auth --from NEARBuilders/everything-dev@everything-dev@2.0.0-rc.8 +bos import plugins/auth --from https://github.com/owner/repo@develop +bos import plugins/auth --from ./local-checkout --dry-run +bos import plugins/auth --from owner/repo --yes +``` + +**Flags:** `--from ` (required), `--yes`, `--dry-run`, `--no-install`, `--json`. The ref splits on the **first** `@`, so changesets-style tags keep their own `@`. + +### Classification + +Each file shared between the local workspace and the upstream subtree is classified against a baseline: `.bos/import-snapshot.json` (written by previous imports), falling back to `.bos/sync-snapshot.json`. + +| Bucket | Meaning | Action | +|---|---|---| +| added | upstream-only | written | +| unchanged | identical content | nothing (baseline recorded) | +| updated | local matches baseline, upstream moved | overwritten with upstream (backed up first) | +| preserved-local | local-only, upstream lacks it | never deleted | +| preserved-local-edit | upstream matches baseline, local edited | local wins, untouched | +| merged | diverged `package.json` | structured merge — template wins shared keys, local-only keys and deps preserved | +| conflict | both changed since baseline, or no baseline exists | gated | + +Local-only files are never deleted, even when upstream no longer carries them. + +### Gating + +Conflicts — and a migration chain that cannot be applied verbatim (next section) — gate the import: + +- **Non-TTY without `--yes`:** nothing is written, the report is emitted, exit 1. `--yes` applies upstream over conflicting files (local copies backed up to `.bos/import-backup//`) and proceeds. +- **TTY:** an interactive confirm (default No; declining exits 1). + +A diverged `package.json` is never a conflict when both sides parse — it merges instead. + +### Migration chain safety + +When both sides have `src/db/migrations/` journals and the chain cannot be applied verbatim — index reuse with a different tag, same tag hashing differently, an upstream entry below the local chain tail, or local-only journal entries — every migration-chain file in the write set is **held back**: the rest of the workspace imports normally, but `src/db/migrations/**` is untouched, because a verbatim overwrite would drop local journal entries and orphan already-applied hashes. A held chain gates the run even when no other file conflicts. Renumbering/append reconciliation is follow-up work (NEARBuilders/everything-dev#393). + +### Route-set delta + +Both sides' contract surface is scanned for literal `.route({ method, path })` pairs — the monolithic `/src/contract.ts` plus any split `/src/contract/**` files, unioned per side (so a monolithic local contract compares correctly against a re-export + split-file upstream). The report lists ours-only and theirs-only `METHOD path` routes so API drift is visible before you port local routes onto the new upstream contract. + +### Report and baselines + +Every run — dry-run, refused, or applied — writes `.bos/import-report.md`: summary table, conflict checklist with reasons and held markers, migration collisions and chain delta, route delta, bucket lists, and next steps. Written and unchanged files are recorded to `.bos/import-snapshot.json` (and merged into `.bos/sync-snapshot.json` when one exists) so the next import detects drift instead of starting from zero; held-back files are deliberately excluded until reconciliation lands. + ## bos publish See `everything-dev#publish-sync` for the full publish workflow. A quick reference: @@ -206,6 +258,8 @@ Unknown keys go after known keys. See `everything-dev#extends-config` for full o **"Sync reports conflicted files."** Your local edits diverged from the snapshot; upgrade applies the template version and leaves your copy in `.bos/sync-backup/` — diff and merge back, or `bos sync --force` deliberately. +**"Import a workspace from the base platform."** `bos import --from [@ref] --dry-run` first, read `.bos/import-report.md` for the conflict/migration/route picture, then real-run: plain invoke prompts in a TTY, `--yes` applies upstream over conflicts (backed up), and a flagged migration chain stays held until reconciliation. + **"Upgrade framework packages."** `bos upgrade --dry-run` first, then `bos upgrade` — it bumps the root catalog, rewrites workspace refs to `catalog:`, syncs the template, and rewrites legacy imports. ## What comes back when it refuses @@ -214,5 +268,6 @@ Unknown keys go after known keys. See `everything-dev#extends-config` for full o |---|---| | file listed as `conflicted` in sync results | local file modified since the snapshot AND the template changed — take the template version (your copy is backed up at `.bos/sync-backup/`) or re-apply your change on top | | sync keeps skipping my file | app-owned files that diverged from the snapshot are skipped without `--force`; framework-owned files always update when the template changes | +| import exits 1 listing files | unresolvable conflicts (or a held migration chain) — read `.bos/import-report.md`, then re-run with `--yes` to apply upstream (local copies land in `.bos/import-backup/`), or port local changes manually first | | `Circular extends detected while resolving upgrade source` | the upgrade source's extends chain loops — resolve from a non-cyclic parent | | init copies fewer files than expected | `buildInitPatterns` filters to the selected plugins + their routes; pass `--overrides ui,api,host` to widen the scaffold | diff --git a/packages/everything-dev/src/cli.ts b/packages/everything-dev/src/cli.ts index 5933c993f..6721437d0 100644 --- a/packages/everything-dev/src/cli.ts +++ b/packages/everything-dev/src/cli.ts @@ -755,6 +755,86 @@ async function main() { return; } + if (descriptor.key === "import") { + if ((input as any).json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(); + if (result.status === "error") { + console.error(`[CLI] ${result.error || "Unknown error"}`); + if (result.reportPath) console.error(` Report: ${result.reportPath}`); + process.exit(1); + } + if (result.status === "dry-run") { + console.log(colors.cyan(`${icons.ok} Dry run — no files written`)); + } else if (result.status === "imported") { + console.log(colors.green(`${icons.ok} Imported ${result.workspace} from ${result.from}`)); + } else { + console.log(colors.yellow(`${icons.err} Conflicts — nothing written`)); + } + console.log( + ` ${colors.dim("Results:")} ${result.updated.length} updated, ${result.added.length} added, ${result.merged.length} merged, ${result.conflicts.length} conflicted, ${result.preservedLocal.length} local-only preserved, ${result.unchanged.length} unchanged`, + ); + if (result.conflicts.length > 0) { + const heldSet: Set = new Set(result.heldBackMigrations ?? []); + console.log( + ` ${colors.yellow("Conflicts")} (unresolvable — manual-port checklist${ + result.status === "conflicts" + ? "; re-run with --yes to apply upstream" + : result.backupDir + ? `, upstream applied, local copies at ${result.backupDir}` + : "" + }):`, + ); + for (const file of result.conflicts) { + console.log( + ` ${colors.dim(heldSet.has(file) ? `${file} (held — migration chain)` : file)}`, + ); + } + } + if (result.heldBackMigrations?.length > 0) { + console.log( + ` ${colors.yellow("Migration chain held back")} (pending reconciliation #393):`, + ); + for (const file of result.heldBackMigrations) console.log(` ${colors.dim(file)}`); + } + if (result.migrationCollisions.length > 0) { + console.log(` ${colors.yellow("Migration collisions")}:`); + for (const collision of result.migrationCollisions) { + console.log(` ${colors.dim(collision.message)}`); + } + } + if ( + result.routeDelta && + (result.routeDelta.oursOnly.length > 0 || result.routeDelta.theirsOnly.length > 0) + ) { + console.log( + ` ${colors.dim("Route delta:")} ${result.routeDelta.oursOnly.length} ours-only, ${result.routeDelta.theirsOnly.length} theirs-only`, + ); + for (const route of result.routeDelta.oursOnly) + console.log(` ${colors.dim(`ours: ${route}`)}`); + for (const route of result.routeDelta.theirsOnly) + console.log(` ${colors.dim(`theirs: ${route}`)}`); + } + if (result.reportPath) { + console.log(` Report: ${colors.dim(result.reportPath)}`); + } + if (result.status === "conflicts") { + console.log(); + console.error( + `[CLI] ${result.conflicts.length} unresolvable conflict(s)${ + result.migrationCollisions.length > 0 + ? ` and ${result.migrationCollisions.length} migration collision(s)` + : "" + } — nothing was written. Re-run with --yes to apply upstream (local copies are backed up first).`, + ); + process.exit(1); + } + console.log(); + return; + } + if (descriptor.key === "upgrade") { if ((input as any).json) { console.log(JSON.stringify(result, null, 2)); diff --git a/packages/everything-dev/src/cli/import.ts b/packages/everything-dev/src/cli/import.ts new file mode 100644 index 000000000..c2ce23067 --- /dev/null +++ b/packages/everything-dev/src/cli/import.ts @@ -0,0 +1,981 @@ +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { glob } from "glob"; +import type { + ImportMigrationCollision, + ImportOptions, + ImportResult, + ImportRouteDelta, +} from "../contract"; +import { computeSnapshotHash } from "../utils/snapshot-hash"; +import { findWorkspaceRoot } from "../workspace"; +import { downloadTarball, parseGitHubUrl, runPnpmInstall, runTypesGen } from "./init"; +import { readSnapshot, writeSnapshot } from "./snapshot"; +import { mergePackageJson } from "./sync"; + +const SUBTREE_IGNORE = [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/.bos/**", + "**/.every-plugin/**", +]; + +const IMPORT_SNAPSHOT_FILE = "import-snapshot.json"; +const IMPORT_REPORT_FILE = "import-report.md"; + +export const IMPORT_REPORT_PATH = `.bos/${IMPORT_REPORT_FILE}`; + +export type ImportFromSpec = + | { kind: "github"; repo: string; ref?: string } + | { kind: "local"; dir: string }; + +export type ImportFileClass = + | { kind: "added" } + | { kind: "unchanged" } + | { kind: "updated" } + | { kind: "preserved-local" } + | { kind: "preserved-local-edit" } + | { kind: "merged" } + | { kind: "conflict"; reason: "no-baseline" | "both-changed" }; + +export interface MigrationEntry { + idx: number; + tag: string; + sha: string | null; +} + +export interface ImportReportAnalysis { + workspace: string; + from: string; + ref: string; + resolvedSha?: string; + sourceKind: "github" | "local"; + added: string[]; + updated: string[]; + unchanged: string[]; + preservedLocal: string[]; + preservedLocalEdit: string[]; + merged: string[]; + conflicts: Array<{ path: string; reason: "no-baseline" | "both-changed"; held: boolean }>; + routeDelta?: ImportRouteDelta; + migrationCollisions: ImportMigrationCollision[]; + localOnlyMigrations: string[]; + upstreamOnlyMigrations: string[]; + heldBackMigrations: string[]; +} + +export interface ImportDeps { + confirm?: (info: { + workspace: string; + conflictCount: number; + migrationCollisionCount: number; + heldCount: number; + }) => Promise; +} + +function isExistingDir(path: string): boolean { + try { + return existsSync(path) && statSync(path).isDirectory(); + } catch { + return false; + } +} + +function normalizeGithubRepo(spec: string): string | null { + if (parseGitHubUrl(spec)) { + return spec; + } + const match = spec.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/); + if (!match?.[1] || !match[2]) return null; + if (match[1] === "." || match[1] === ".." || match[2] === "." || match[2] === "..") return null; + return `https://github.com/${match[1]}/${match[2]}`; +} + +export function parseFromSpec(spec: string): ImportFromSpec { + const trimmed = spec.trim(); + if (!trimmed) { + throw new Error( + "--from is required: /[@], a GitHub URL, or a local directory", + ); + } + + if (trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) { + if (!isExistingDir(trimmed)) { + throw new Error(`Local directory not found: ${trimmed}`); + } + return { kind: "local", dir: resolve(trimmed) }; + } + + const at = trimmed.indexOf("@"); + if (at > 0) { + const head = trimmed.slice(0, at); + const ref = trimmed.slice(at + 1); + const repo = normalizeGithubRepo(head); + if (repo && ref) { + return { kind: "github", repo, ref }; + } + } + + const repo = normalizeGithubRepo(trimmed); + if (repo) { + return { kind: "github", repo }; + } + + if (isExistingDir(trimmed)) { + return { kind: "local", dir: resolve(trimmed) }; + } + + throw new Error( + `Cannot parse --from source: ${spec}. Expected /[@], a GitHub URL, or a local directory.`, + ); +} + +export function normalizeWorkspacePath(input: string): string { + const trimmed = input.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""); + if (isAbsolute(input)) { + throw new Error(`Workspace path must be repo-relative: ${input}`); + } + if (!trimmed || trimmed === "." || trimmed === "..") { + throw new Error(`Invalid workspace path: ${input}`); + } + const segments = trimmed.split("/"); + if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error(`Invalid workspace path: ${input}`); + } + return trimmed; +} + +export async function listSubtreeFiles(root: string, workspace: string): Promise { + const matches = await glob(`${workspace}/**`, { + cwd: root, + nodir: true, + dot: true, + absolute: false, + ignore: SUBTREE_IGNORE, + }); + const files: string[] = []; + for (const match of matches) { + if (lstatSync(join(root, match)).isFile()) { + files.push(match); + } + } + return files.sort(); +} + +export function classifyImportFile(input: { + localHash: string | null; + upstreamHash: string; + baselineHash: string | undefined; + mergeable: boolean; +}): ImportFileClass { + const { localHash, upstreamHash, baselineHash, mergeable } = input; + if (localHash === null) { + return { kind: "added" }; + } + if (localHash === upstreamHash) { + return { kind: "unchanged" }; + } + if (baselineHash === undefined) { + return mergeable ? { kind: "merged" } : { kind: "conflict", reason: "no-baseline" }; + } + if (localHash === baselineHash) { + return mergeable ? { kind: "merged" } : { kind: "updated" }; + } + if (upstreamHash === baselineHash) { + return { kind: "preserved-local-edit" }; + } + return mergeable ? { kind: "merged" } : { kind: "conflict", reason: "both-changed" }; +} + +export function extractContractRoutes(source: string): string[] { + const routes = new Set(); + const marker = ".route("; + let cursor = source.indexOf(marker); + while (cursor !== -1) { + const open = cursor + marker.length - 1; + let depth = 0; + let end = open; + for (; end < source.length; end++) { + const char = source[end]; + if (char === "(") { + depth++; + } else if (char === ")") { + depth--; + if (depth === 0) break; + } + } + const arg = source.slice(open + 1, end); + const method = arg.match(/\bmethod:\s*["']([A-Za-z]+)["']/)?.[1]; + const path = arg.match(/\bpath:\s*["']([^"']+)["']/)?.[1]; + if (method && path) { + routes.add(`${method} ${path}`); + } + cursor = source.indexOf(marker, cursor + 1); + } + return [...routes].sort(); +} + +export function computeRouteDelta(ours: string[], theirs: string[]): ImportRouteDelta { + const oursSet = new Set(ours); + const theirsSet = new Set(theirs); + return { + oursOnly: ours.filter((route) => !theirsSet.has(route)), + theirsOnly: theirs.filter((route) => !oursSet.has(route)), + }; +} + +async function collectContractRouteSources( + root: string, + workspace: string, +): Promise { + const sources: string[] = []; + const mainPath = join(root, workspace, "src", "contract.ts"); + if (existsSync(mainPath)) { + sources.push(readFileSync(mainPath, "utf-8")); + } + const splitDir = join(root, workspace, "src", "contract"); + if (existsSync(splitDir) && lstatSync(splitDir).isDirectory()) { + const matches = await glob("**/*.{ts,tsx}", { + cwd: splitDir, + nodir: true, + absolute: false, + }); + for (const match of matches.sort()) { + sources.push(readFileSync(join(splitDir, match), "utf-8")); + } + } + return sources.length > 0 ? sources : null; +} + +function extractWorkspaceRoutes(sources: string[] | null): string[] | null { + if (sources === null) { + return null; + } + const routes = new Set(); + for (const source of sources) { + for (const route of extractContractRoutes(source)) { + routes.add(route); + } + } + return [...routes].sort(); +} + +export function loadMigrationEntries(root: string, workspace: string): MigrationEntry[] | null { + const journalPath = join(root, workspace, "src", "db", "migrations", "meta", "_journal.json"); + if (!existsSync(journalPath)) { + return null; + } + let journal: unknown; + try { + journal = JSON.parse(readFileSync(journalPath, "utf-8")); + } catch { + return null; + } + const entries = (journal as { entries?: unknown }).entries; + if (!Array.isArray(entries)) { + return null; + } + const migrationsDir = join(root, workspace, "src", "db", "migrations"); + const result: MigrationEntry[] = []; + for (const raw of entries) { + const entry = raw as { idx?: unknown; tag?: unknown }; + if (typeof entry.idx !== "number" || typeof entry.tag !== "string") { + continue; + } + const sqlPath = join(migrationsDir, `${entry.tag}.sql`); + let sha: string | null = null; + if (existsSync(sqlPath)) { + try { + sha = computeSnapshotHash(readFileSync(sqlPath)); + } catch { + sha = null; + } + } + result.push({ idx: entry.idx, tag: entry.tag, sha }); + } + return result.sort((a, b) => a.idx - b.idx); +} + +export function detectMigrationCollisions( + local: MigrationEntry[], + upstream: MigrationEntry[], +): ImportMigrationCollision[] { + const collisions: ImportMigrationCollision[] = []; + const maxLocalIdx = local.reduce((max, entry) => Math.max(max, entry.idx), -1); + for (const entry of upstream) { + const atIdx = local.find((candidate) => candidate.idx === entry.idx); + if (atIdx) { + if (atIdx.tag !== entry.tag) { + collisions.push({ + kind: "idx-reuse", + idx: entry.idx, + localTag: atIdx.tag, + upstreamTag: entry.tag, + message: `idx ${entry.idx} is ${atIdx.tag} locally but ${entry.tag} upstream`, + }); + } else if (atIdx.sha && entry.sha && atIdx.sha !== entry.sha) { + collisions.push({ + kind: "tag-mismatch", + idx: entry.idx, + localTag: atIdx.tag, + upstreamTag: entry.tag, + message: `${entry.tag} has different content locally than upstream — already-applied hashes would drift`, + }); + } + continue; + } + if (entry.idx <= maxLocalIdx) { + collisions.push({ + kind: "out-of-order", + idx: entry.idx, + upstreamTag: entry.tag, + message: `upstream ${entry.tag} lands at idx ${entry.idx}, below the local chain tail (idx ${maxLocalIdx})`, + }); + } + } + return collisions; +} + +function hashFile(root: string, path: string): string | null { + const fullPath = join(root, path); + if (!existsSync(fullPath)) { + return null; + } + try { + return computeSnapshotHash(readFileSync(fullPath)); + } catch { + return null; + } +} + +function safeProjectPath(projectDir: string, path: string): string { + const dest = join(projectDir, path); + const rel = relative(projectDir, dest); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Refusing to write outside the project: ${path}`); + } + return dest; +} + +function readJson(path: string): Record | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +} + +interface ImportSnapshot { + workspaces: Record< + string, + { from: string; ref: string; timestamp: string; files: Record } + >; +} + +function readImportSnapshot(projectDir: string): ImportSnapshot { + const path = join(projectDir, ".bos", IMPORT_SNAPSHOT_FILE); + if (!existsSync(path)) { + return { workspaces: {} }; + } + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + const workspaces = (parsed as { workspaces?: unknown }).workspaces; + if (workspaces && typeof workspaces === "object" && !Array.isArray(workspaces)) { + return { workspaces: workspaces as ImportSnapshot["workspaces"] }; + } + return { workspaces: {} }; + } catch { + return { workspaces: {} }; + } +} + +function writeImportSnapshot(projectDir: string, snapshot: ImportSnapshot): void { + const dir = join(projectDir, ".bos"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, IMPORT_SNAPSHOT_FILE), `${JSON.stringify(snapshot, null, 2)}\n`); +} + +function writeImportReport(projectDir: string, content: string): string { + const dir = join(projectDir, ".bos"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, IMPORT_REPORT_FILE), content); + return IMPORT_REPORT_PATH; +} + +function backupImportFiles(projectDir: string, paths: string[]): string | undefined { + const existing = paths.filter((path) => existsSync(join(projectDir, path))); + if (existing.length === 0) { + return undefined; + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupDir = join(projectDir, ".bos", "import-backup", stamp); + for (const path of existing) { + const dest = safeProjectPath(backupDir, path); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(projectDir, path), dest); + } + return backupDir; +} + +function listSection(title: string, items: string[], held?: ReadonlySet): string[] { + if (items.length === 0) { + return [`## ${title}`, "", "(none)", ""]; + } + return [ + `## ${title}`, + "", + ...items.map((item) => (held?.has(item) ? `- \`${item}\` (held)` : `- \`${item}\``)), + "", + ]; +} + +export function buildImportReport( + analysis: ImportReportAnalysis, + outcome: { mode: "dry-run" | "applied" | "refused"; backupDir?: string }, +): string { + const lines: string[] = []; + lines.push(`# bos import report — \`${analysis.workspace}\``); + lines.push(""); + const source = + analysis.sourceKind === "local" + ? `\`${analysis.from}\` (local directory)` + : `\`${analysis.from}\`${analysis.ref ? ` @ \`${analysis.ref}\`` : ""}`; + lines.push(`- **Source:** ${source}`); + if (analysis.resolvedSha) { + lines.push(`- **Resolved commit:** \`${analysis.resolvedSha}\``); + } + const modeLabel = + outcome.mode === "dry-run" + ? "Dry run — no files written" + : outcome.mode === "applied" + ? "Applied — upstream written over shared files" + : "Refused — unresolvable conflicts exist, nothing was written"; + lines.push(`- **Mode:** ${modeLabel}`); + lines.push(`- **Generated:** ${new Date().toISOString()}`); + lines.push(""); + + lines.push("## Summary"); + lines.push(""); + lines.push("| bucket | count |"); + lines.push("| --- | --- |"); + lines.push(`| added (upstream-only) | ${analysis.added.length} |`); + lines.push(`| updated (local unchanged, upstream changed) | ${analysis.updated.length} |`); + lines.push(`| merged (structured package.json merge) | ${analysis.merged.length} |`); + lines.push(`| conflicts (unresolvable) | ${analysis.conflicts.length} |`); + lines.push(`| migration collisions | ${analysis.migrationCollisions.length} |`); + lines.push(`| migration chain held back | ${analysis.heldBackMigrations.length} |`); + lines.push(`| local-only (preserved) | ${analysis.preservedLocal.length} |`); + lines.push( + `| local edits preserved (upstream unchanged) | ${analysis.preservedLocalEdit.length} |`, + ); + lines.push(`| unchanged | ${analysis.unchanged.length} |`); + lines.push(""); + + if (analysis.conflicts.length > 0) { + lines.push("## Conflicts — manual-port checklist"); + lines.push(""); + lines.push("| file | reason |"); + lines.push("| --- | --- |"); + for (const conflict of analysis.conflicts) { + const reason = + conflict.reason === "no-baseline" + ? "no import baseline (first import or snapshot gap)" + : "local and upstream both changed since the baseline"; + lines.push( + `| \`${conflict.path}\` | ${reason}${conflict.held ? " — held (migration chain)" : ""} |`, + ); + } + lines.push(""); + const appliedConflicts = analysis.conflicts.filter((conflict) => !conflict.held); + if (outcome.mode === "refused") { + lines.push( + "Nothing was written. Re-run with `--yes` to apply upstream over these files (local copies are backed up first), then port local-only content back onto the new upstream versions.", + ); + } else if (outcome.mode === "applied") { + if (appliedConflicts.length > 0) { + lines.push( + `Upstream was applied over ${appliedConflicts.length} of these file(s)${ + outcome.backupDir ? ` — local copies backed up at \`${outcome.backupDir}\`` : "" + }. Port local-only content back onto the new upstream versions.`, + ); + } + if (analysis.heldBackMigrations.length > 0) { + lines.push( + "Migration-chain conflicts were held back and NOT written — see the held-back section below.", + ); + } + } else { + lines.push( + "A real run with `--yes` would overwrite these with upstream (backing up local copies first); migration-chain files would be held back.", + ); + } + lines.push(""); + } + + if (analysis.migrationCollisions.length > 0) { + lines.push("## Migration chain collisions"); + lines.push(""); + lines.push("| kind | idx | local | upstream | detail |"); + lines.push("| --- | --- | --- | --- | --- |"); + for (const collision of analysis.migrationCollisions) { + lines.push( + `| ${collision.kind} | ${collision.idx} | \`${collision.localTag ?? "—"}\` | \`${collision.upstreamTag ?? "—"}\` | ${collision.message} |`, + ); + } + lines.push(""); + lines.push( + "Migration renumbering is not performed by `bos import` yet — reconciliation lands in NEARBuilders/everything-dev#393.", + ); + lines.push(""); + } + + if (analysis.heldBackMigrations.length > 0) { + lines.push("## Migration chain held back"); + lines.push(""); + for (const path of analysis.heldBackMigrations) lines.push(`- \`${path}\``); + lines.push(""); + lines.push( + "`src/db/migrations/**` was not modified: a verbatim overwrite would drop local journal entries that upstream lacks (or collide on shared indices), leaving applied hashes orphaned. The rest of the workspace imported normally; chain reconciliation lands in NEARBuilders/everything-dev#393.", + ); + lines.push(""); + } + + if (analysis.localOnlyMigrations.length > 0 || analysis.upstreamOnlyMigrations.length > 0) { + lines.push("## Migration chain delta"); + lines.push(""); + if (analysis.localOnlyMigrations.length > 0) { + lines.push("Local-only (preserved, never renumbered):"); + lines.push(""); + for (const tag of analysis.localOnlyMigrations) lines.push(`- \`${tag}\``); + lines.push(""); + } + if (analysis.upstreamOnlyMigrations.length > 0) { + lines.push("Upstream-only:"); + lines.push(""); + for (const tag of analysis.upstreamOnlyMigrations) lines.push(`- \`${tag}\``); + lines.push(""); + } + } + + if (analysis.routeDelta) { + lines.push("## Contract route-set delta"); + lines.push(""); + if (analysis.routeDelta.oursOnly.length === 0 && analysis.routeDelta.theirsOnly.length === 0) { + lines.push("Route sets are identical."); + lines.push(""); + } else { + lines.push( + `Ours-only: ${analysis.routeDelta.oursOnly.length}, theirs-only: ${analysis.routeDelta.theirsOnly.length}.`, + ); + lines.push(""); + for (const route of analysis.routeDelta.oursOnly) lines.push(`- ours only: \`${route}\``); + for (const route of analysis.routeDelta.theirsOnly) lines.push(`- theirs only: \`${route}\``); + lines.push(""); + } + } + + const heldSet = new Set(analysis.heldBackMigrations); + lines.push(...listSection("Added (upstream-only)", analysis.added, heldSet)); + lines.push( + ...listSection("Updated (would be overwritten with upstream)", analysis.updated, heldSet), + ); + lines.push( + ...listSection("Merged (package.json — local-only keys preserved)", analysis.merged, heldSet), + ); + lines.push(...listSection("Local-only (preserved, never deleted)", analysis.preservedLocal)); + lines.push( + ...listSection("Local edits preserved (upstream unchanged)", analysis.preservedLocalEdit), + ); + lines.push( + `## Unchanged`, + "", + `${analysis.unchanged.length} file(s) identical on both sides.`, + "", + ); + + lines.push("## Next steps"); + lines.push(""); + lines.push( + "- `bos types gen` (and a build) regenerates `*.gen.ts` and `types/contract.d.ts` against the imported contract.", + ); + lines.push( + "- If preserved local changes should live upstream, open a PR against the source repo rather than keeping them only locally.", + ); + lines.push(""); + return lines.join("\n"); +} + +async function resolveImportSource(spec: ImportFromSpec): Promise<{ + root: string; + cleanup: () => Promise; + ref: string; + resolvedSha?: string; +}> { + if (spec.kind === "local") { + return { root: spec.dir, cleanup: async () => {}, ref: "local" }; + } + const tarball = await downloadTarball(spec.repo, spec.ref); + return { + root: tarball.dir, + cleanup: tarball.cleanup, + ref: spec.ref ?? tarball.resolvedRef ?? "main", + resolvedSha: tarball.resolvedSha, + }; +} + +export async function importWorkspace( + projectDir: string, + options: ImportOptions, + deps?: ImportDeps, +): Promise { + const errorResult = (workspace: string, ref: string, message: string): ImportResult => ({ + status: "error", + workspace, + from: options.from, + ref, + added: [], + updated: [], + unchanged: [], + preservedLocal: [], + merged: [], + conflicts: [], + heldBackMigrations: [], + migrationCollisions: [], + error: message, + }); + + const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + + let workspace = options.workspace; + let ref = ""; + try { + workspace = normalizeWorkspacePath(options.workspace); + const spec = parseFromSpec(options.from); + const source = await resolveImportSource(spec); + ref = source.ref; + + try { + const upstreamWorkspaceDir = join(source.root, workspace); + if (!existsSync(upstreamWorkspaceDir) || !lstatSync(upstreamWorkspaceDir).isDirectory()) { + throw new Error( + `Workspace "${workspace}" not found in ${options.from}${ + source.ref !== "local" ? ` at ref ${source.ref}` : "" + }`, + ); + } + + const upstreamFiles = await listSubtreeFiles(source.root, workspace); + const localWorkspaceDir = join(projectDir, workspace); + const localFiles = existsSync(localWorkspaceDir) + ? await listSubtreeFiles(projectDir, workspace) + : []; + + const upstreamSet = new Set(upstreamFiles); + const localSet = new Set(localFiles); + + const importSnapshot = readImportSnapshot(projectDir); + const workspaceSnapshot = importSnapshot.workspaces[workspace]; + const syncSnapshot = await readSnapshot(projectDir); + const baselineFor = (path: string): string | undefined => + workspaceSnapshot?.files[path] ?? syncSnapshot?.files[path]; + + const packageJsonPath = `${workspace}/package.json`; + const mergeablePackageJson = (() => { + if (!localSet.has(packageJsonPath) || !upstreamSet.has(packageJsonPath)) return false; + return ( + readJson(join(projectDir, packageJsonPath)) !== null && + readJson(join(source.root, packageJsonPath)) !== null + ); + })(); + + const added: string[] = []; + const updated: string[] = []; + const unchanged: string[] = []; + const preservedLocal: string[] = []; + const preservedLocalEdit: string[] = []; + const merged: string[] = []; + const conflicts: Array<{ + path: string; + reason: "no-baseline" | "both-changed"; + held: boolean; + }> = []; + const upstreamHashByPath = new Map(); + + const allPaths = [...new Set([...localFiles, ...upstreamFiles])].sort(); + for (const path of allPaths) { + if (!upstreamSet.has(path)) { + preservedLocal.push(path); + continue; + } + const localHash = localSet.has(path) ? hashFile(projectDir, path) : null; + const upstreamHash = hashFile(source.root, path); + if (upstreamHash === null) { + throw new Error(`Failed to read upstream file: ${path}`); + } + upstreamHashByPath.set(path, upstreamHash); + const classification = classifyImportFile({ + localHash, + upstreamHash, + baselineHash: baselineFor(path), + mergeable: path === packageJsonPath && mergeablePackageJson, + }); + switch (classification.kind) { + case "added": + added.push(path); + break; + case "unchanged": + unchanged.push(path); + break; + case "updated": + updated.push(path); + break; + case "preserved-local": + preservedLocal.push(path); + break; + case "preserved-local-edit": + preservedLocalEdit.push(path); + break; + case "merged": + merged.push(path); + break; + case "conflict": + conflicts.push({ path, reason: classification.reason, held: false }); + break; + } + } + + let routeDelta: ImportRouteDelta | undefined; + const localRoutes = extractWorkspaceRoutes( + await collectContractRouteSources(projectDir, workspace), + ); + const upstreamRoutes = extractWorkspaceRoutes( + await collectContractRouteSources(source.root, workspace), + ); + if (localRoutes !== null || upstreamRoutes !== null) { + routeDelta = computeRouteDelta(localRoutes ?? [], upstreamRoutes ?? []); + } + + const localMigrations = loadMigrationEntries(projectDir, workspace); + const upstreamMigrations = loadMigrationEntries(source.root, workspace); + const migrationCollisions = + localMigrations && upstreamMigrations + ? detectMigrationCollisions(localMigrations, upstreamMigrations) + : []; + const localTags = new Set(localMigrations?.map((entry) => entry.tag) ?? []); + const upstreamTags = new Set(upstreamMigrations?.map((entry) => entry.tag) ?? []); + const localOnlyMigrations = [...localTags].filter((tag) => !upstreamTags.has(tag)).sort(); + const upstreamOnlyMigrations = [...upstreamTags].filter((tag) => !localTags.has(tag)).sort(); + + const chainPrefix = `${workspace}/src/db/migrations/`; + const chainUnsafe = + localMigrations !== null && + upstreamMigrations !== null && + (migrationCollisions.length > 0 || localOnlyMigrations.length > 0); + const writeCandidates = new Set([ + ...added, + ...updated, + ...merged, + ...conflicts.map((conflict) => conflict.path), + ]); + const heldSet = new Set( + chainUnsafe ? [...writeCandidates].filter((path) => path.startsWith(chainPrefix)) : [], + ); + const heldBackMigrations = [...heldSet].sort(); + for (const conflict of conflicts) { + conflict.held = heldSet.has(conflict.path); + } + + const analysis: ImportReportAnalysis = { + workspace, + from: options.from, + ref: source.ref, + resolvedSha: source.resolvedSha, + sourceKind: spec.kind, + added, + updated, + unchanged, + preservedLocal, + preservedLocalEdit, + merged, + conflicts, + routeDelta, + migrationCollisions, + localOnlyMigrations, + upstreamOnlyMigrations, + heldBackMigrations, + }; + + const resultBase = { + workspace, + from: options.from, + ref: source.ref, + resolvedSha: source.resolvedSha, + added, + updated, + unchanged, + preservedLocal, + merged, + conflicts: conflicts.map((conflict) => conflict.path), + heldBackMigrations, + routeDelta, + migrationCollisions, + }; + + if (options.dryRun) { + const reportPath = writeImportReport( + projectDir, + buildImportReport(analysis, { mode: "dry-run" }), + ); + return { status: "dry-run", ...resultBase, reportPath }; + } + + const gated = conflicts.length > 0 || heldBackMigrations.length > 0; + if (gated && !options.yes) { + let approved = false; + if (deps?.confirm) { + approved = await deps.confirm({ + workspace, + conflictCount: conflicts.length, + migrationCollisionCount: migrationCollisions.length, + heldCount: heldBackMigrations.length, + }); + } + if (!approved) { + const reportPath = writeImportReport( + projectDir, + buildImportReport(analysis, { mode: "refused" }), + ); + return { status: "conflicts", ...resultBase, reportPath }; + } + } + + const isHeld = (path: string): boolean => heldSet.has(path); + + const mergeContents = new Map(); + for (const path of merged) { + const local = readJson(join(projectDir, path)); + const upstream = readJson(join(source.root, path)); + if (!local || !upstream) { + throw new Error(`Failed to merge ${path}: unreadable package.json`); + } + mergeContents.set( + path, + `${JSON.stringify(mergePackageJson(path, local, upstream), null, 2)}\n`, + ); + } + + const overwritePaths = [ + ...updated.filter((path) => !isHeld(path)), + ...conflicts.map((conflict) => conflict.path).filter((path) => !isHeld(path)), + ...merged.filter( + (path) => mergeContents.get(path) !== readFileSync(join(projectDir, path), "utf-8"), + ), + ]; + const backupDir = backupImportFiles(projectDir, overwritePaths); + + const writeUpstream = (path: string) => { + const dest = safeProjectPath(projectDir, path); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(source.root, path), dest); + }; + + for (const path of added) { + if (!isHeld(path)) writeUpstream(path); + } + for (const path of updated) { + if (!isHeld(path)) writeUpstream(path); + } + for (const conflict of conflicts) { + if (!isHeld(conflict.path)) writeUpstream(conflict.path); + } + for (const path of merged) { + const content = mergeContents.get(path); + if (content === undefined || isHeld(path)) continue; + if (content === readFileSync(join(projectDir, path), "utf-8")) continue; + const dest = safeProjectPath(projectDir, path); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, content); + } + + const writtenPaths = [ + ...added, + ...updated, + ...merged, + ...conflicts.map((conflict) => conflict.path), + ].filter((path) => !isHeld(path)); + const recorded: Record = {}; + for (const path of writtenPaths) { + const hash = hashFile(projectDir, path); + if (hash) { + recorded[path] = hash; + } + } + for (const path of [...unchanged, ...preservedLocalEdit]) { + const hash = upstreamHashByPath.get(path); + if (hash) { + recorded[path] = hash; + } + } + + writeImportSnapshot(projectDir, { + workspaces: { + ...importSnapshot.workspaces, + [workspace]: { + from: options.from, + ref: source.ref, + timestamp: new Date().toISOString(), + files: recorded, + }, + }, + }); + + if (syncSnapshot) { + await writeSnapshot(projectDir, { + parentRef: syncSnapshot.parentRef, + files: { ...syncSnapshot.files, ...recorded }, + starter: syncSnapshot.starter, + }); + } + + const reportPath = writeImportReport( + projectDir, + buildImportReport(analysis, { mode: "applied", backupDir }), + ); + + if (!options.noInstall) { + try { + await runPnpmInstall(findWorkspaceRoot(projectDir)?.dir ?? projectDir); + await runTypesGen(projectDir); + } catch (error) { + return { + status: "error", + ...resultBase, + backupDir, + reportPath, + error: `Import applied, but the post-install step failed: ${errorMessage(error)}`, + }; + } + } + + return { status: "imported", ...resultBase, backupDir, reportPath }; + } finally { + await source.cleanup(); + } + } catch (error) { + return errorResult(workspace, ref, errorMessage(error)); + } +} diff --git a/packages/everything-dev/src/cli/init.ts b/packages/everything-dev/src/cli/init.ts index 0f9b2545b..db2b473c4 100644 --- a/packages/everything-dev/src/cli/init.ts +++ b/packages/everything-dev/src/cli/init.ts @@ -490,7 +490,13 @@ function normalizeGitUrl(url: string): string | undefined { export async function downloadTarball( repoUrl: string, -): Promise<{ dir: string; cleanup: () => Promise }> { + ref?: string, +): Promise<{ + dir: string; + cleanup: () => Promise; + resolvedRef?: string; + resolvedSha?: string; +}> { const parsed = parseGitHubUrl(repoUrl); if (!parsed) { throw new Error(`Cannot parse repository URL: ${repoUrl}`); @@ -498,10 +504,12 @@ export async function downloadTarball( const { owner, repo } = parsed; let response: Response | null = null; + let resolvedRef: string | undefined; - for (const branch of ["main", "master"]) { + const candidates = ref ? [ref] : ["main", "master"]; + for (const candidateRef of candidates) { const candidate = await fetchResponse( - `https://api.github.com/repos/${owner}/${repo}/tarball/${branch}`, + `https://api.github.com/repos/${owner}/${repo}/tarball/${encodeURIComponent(candidateRef)}`, { headers: { "User-Agent": "everything-dev" }, redirect: "follow", @@ -510,6 +518,7 @@ export async function downloadTarball( ); if (candidate.ok) { response = candidate; + resolvedRef = candidateRef; break; } if (candidate.status !== 404) { @@ -520,13 +529,19 @@ export async function downloadTarball( } if (!response) { - throw new Error(`GitHub tarball download failed for ${repoUrl}: tried main and master`); + throw new Error( + ref + ? `GitHub tarball download failed for ${repoUrl}: ref not found (${ref})` + : `GitHub tarball download failed for ${repoUrl}: tried main and master`, + ); } if (!response.body) { throw new Error("GitHub tarball download returned empty body"); } + const resolvedSha = response.url.match(/\/([0-9a-f]{40})(?:\.tar\.gz)?\b/)?.[1]; + const tmpDir = mkTmpDir("bos-init-tarball-"); const tarballPath = join(tmpDir, "source.tar.gz"); @@ -545,13 +560,15 @@ export async function downloadTarball( return { dir: extractDir, + resolvedRef, + resolvedSha, cleanup: async () => { rmSync(extractDir, { recursive: true, force: true }); }, }; } -function parseGitHubUrl(url: string): { owner: string; repo: string } | null { +export function parseGitHubUrl(url: string): { owner: string; repo: string } | null { const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/); if (httpsMatch?.[1] && httpsMatch[2]) { return { owner: httpsMatch[1], repo: httpsMatch[2] }; diff --git a/packages/everything-dev/src/commands/import.ts b/packages/everything-dev/src/commands/import.ts new file mode 100644 index 000000000..800737ae3 --- /dev/null +++ b/packages/everything-dev/src/commands/import.ts @@ -0,0 +1,62 @@ +import { dirname } from "node:path"; +import process from "node:process"; +import * as p from "@clack/prompts"; +import type { ImportDeps } from "../cli/import"; +import { importWorkspace } from "../cli/import"; +import { findConfigPath, MISSING_CONFIG_MESSAGE } from "../config"; +import type { ImportOptions, ImportResult } from "../contract"; +import type { BosBuilder } from "./shared"; + +export function registerImport(builder: BosBuilder) { + return { + import: builder.import.handler(async ({ input }): Promise => { + const configPath = findConfigPath(); + if (!configPath) { + return { + status: "error", + workspace: input.workspace, + from: input.from, + ref: "", + added: [], + updated: [], + unchanged: [], + preservedLocal: [], + merged: [], + conflicts: [], + heldBackMigrations: [], + migrationCollisions: [], + error: `${MISSING_CONFIG_MESSAGE} in current directory`, + }; + } + + const options = input as ImportOptions; + const projectDir = dirname(configPath); + const interactive = + !options.yes && !options.dryRun && process.stdin.isTTY && process.stdout.isTTY; + + const confirm = interactive + ? async (info: Parameters>[0]): Promise => { + const collisionNote = + info.migrationCollisionCount > 0 + ? ` + ${info.migrationCollisionCount} migration collision(s)` + : ""; + const heldNote = + info.heldCount > 0 + ? `; ${info.heldCount} migration-chain file(s) will be held back` + : ""; + const answer = await p.confirm({ + message: + info.conflictCount > 0 || info.migrationCollisionCount > 0 + ? `${info.conflictCount} conflicting file(s)${collisionNote} in ${info.workspace} — apply upstream over conflicting files (local copies backed up)${heldNote}?` + : `Local migration entries exist that upstream lacks in ${info.workspace} — import the workspace and hold back the migration chain?`, + initialValue: false, + }); + if (p.isCancel(answer)) return false; + return answer === true; + } + : undefined; + + return importWorkspace(projectDir, options, { confirm }); + }), + }; +} diff --git a/packages/everything-dev/src/contract.meta.ts b/packages/everything-dev/src/contract.meta.ts index 70ac1df85..b86a53193 100644 --- a/packages/everything-dev/src/contract.meta.ts +++ b/packages/everything-dev/src/contract.meta.ts @@ -260,6 +260,20 @@ export const cliCommandMeta = { noInstall: { description: "Skip pnpm install" }, }, }, + import: { + commandPath: ["import"], + summary: "Import a workspace subtree from an upstream repo without cloning", + interactive: true, + fields: { + workspace: { positional: true, description: "Workspace subtree path (e.g. plugins/auth)" }, + from: { + description: "Upstream source: /[@], GitHub URL, or local directory", + }, + yes: { description: "Apply upstream over conflicting files without prompting" }, + dryRun: { description: "Preview classification and report without writing files" }, + noInstall: { description: "Skip pnpm install" }, + }, + }, upgrade: { commandPath: ["upgrade"], summary: "Upgrade framework packages and sync template files", diff --git a/packages/everything-dev/src/contract.ts b/packages/everything-dev/src/contract.ts index 38eabe91e..6cb59e1fe 100644 --- a/packages/everything-dev/src/contract.ts +++ b/packages/everything-dev/src/contract.ts @@ -455,6 +455,48 @@ export const SyncResultSchema = z.object({ error: z.string().optional(), }); +export const ImportOptionsSchema = z.object({ + workspace: z.string(), + from: z.string(), + yes: z.boolean().default(false), + dryRun: z.boolean().default(false), + noInstall: z.boolean().default(false), + json: z.boolean().default(false), +}); + +export const ImportRouteDeltaSchema = z.object({ + oursOnly: z.array(z.string()), + theirsOnly: z.array(z.string()), +}); + +export const ImportMigrationCollisionSchema = z.object({ + kind: z.enum(["idx-reuse", "tag-mismatch", "out-of-order"]), + idx: z.number(), + localTag: z.string().optional(), + upstreamTag: z.string().optional(), + message: z.string(), +}); + +export const ImportResultSchema = z.object({ + status: z.enum(["imported", "dry-run", "conflicts", "error"]), + workspace: z.string(), + from: z.string(), + ref: z.string(), + resolvedSha: z.string().optional(), + added: z.array(z.string()), + updated: z.array(z.string()), + unchanged: z.array(z.string()).default([]), + preservedLocal: z.array(z.string()).default([]), + merged: z.array(z.string()).default([]), + conflicts: z.array(z.string()).default([]), + heldBackMigrations: z.array(z.string()).default([]), + routeDelta: ImportRouteDeltaSchema.optional(), + migrationCollisions: z.array(ImportMigrationCollisionSchema).default([]), + backupDir: z.string().optional(), + reportPath: z.string().optional(), + error: z.string().optional(), +}); + export const UpgradeOptionsSchema = z.object({ dryRun: z.boolean().default(false), noInstall: z.boolean().default(false), @@ -717,6 +759,7 @@ export const commandOptionSchemas = { logout: LogoutOptionsSchema, init: InitOptionsSchema, sync: SyncOptionsSchema, + import: ImportOptionsSchema, upgrade: UpgradeOptionsSchema, typecheck: TypecheckOptionsSchema, mfCheck: MfCheckOptionsSchema, @@ -795,6 +838,10 @@ export const bosContract = oc.router({ .route({ method: "POST", path: "/sync" }) .input(SyncOptionsSchema) .output(SyncResultSchema), + import: oc + .route({ method: "POST", path: "/import" }) + .input(ImportOptionsSchema) + .output(ImportResultSchema), upgrade: oc .route({ method: "POST", path: "/upgrade" }) .input(UpgradeOptionsSchema) @@ -869,6 +916,10 @@ export type OverrideSection = z.infer; export type PhaseTiming = z.infer; export type SyncOptions = z.infer; export type SyncResult = z.infer; +export type ImportOptions = z.infer; +export type ImportResult = z.infer; +export type ImportRouteDelta = z.infer; +export type ImportMigrationCollision = z.infer; export type UpgradeOptions = z.infer; export type UpgradeResult = z.infer; export type StatusResult = z.infer; diff --git a/packages/everything-dev/src/plugin.ts b/packages/everything-dev/src/plugin.ts index 13c95caa7..154f5356b 100644 --- a/packages/everything-dev/src/plugin.ts +++ b/packages/everything-dev/src/plugin.ts @@ -7,6 +7,7 @@ import { registerConfig } from "./commands/config"; import { registerDb } from "./commands/db"; import { registerDeploy } from "./commands/deploy"; import { registerDev } from "./commands/dev"; +import { registerImport } from "./commands/import"; import { registerInit } from "./commands/init"; import { registerKeys } from "./commands/keys"; import { registerOps } from "./commands/ops"; @@ -74,6 +75,7 @@ export default createPlugin({ ...registerSmoke(builder), ...registerKeys(builder), ...registerInit(builder), + ...registerImport(builder), ...registerUpgrade(builder), ...registerDb(builder), ...registerOps(builder), diff --git a/packages/everything-dev/tests/integration/import.workspace.test.ts b/packages/everything-dev/tests/integration/import.workspace.test.ts new file mode 100644 index 000000000..7d4c0500a --- /dev/null +++ b/packages/everything-dev/tests/integration/import.workspace.test.ts @@ -0,0 +1,285 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { IMPORT_REPORT_PATH, importWorkspace } from "../../src/cli/import"; +import type { ImportOptions } from "../../src/contract"; + +const WS = "plugins/demo"; + +const writeTree = (root: string, files: Record) => { + for (const [path, content] of Object.entries(files)) { + const full = join(root, path); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } +}; + +const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; + +const contractSource = (routes: string[]): string => + `import { z } from "zod"; +export const contract = { + health: oc.route({ method: "GET", path: "/health" }).output(z.string()), +${routes.join("\n")} +}; +`; + +const journal = (entries: Array<{ idx: number; tag: string }>) => + json({ + version: 7, + dialect: "postgresql", + entries: entries.map((entry) => ({ ...entry, version: "7" })), + }); + +const upstreamContractRoot = `import { oc } from "@orpc/contract"; +import { upstreamRoutes } from "./contract/upstream.contract"; +export const contract = oc.router({ + ...upstreamRoutes, +}); +`; + +const upstreamContractSplit = `import { z } from "zod"; +import { oc } from "@orpc/contract"; +export const upstreamRoutes = { + health: oc.route({ method: "GET", path: "/health" }).output(z.string()), + create: oc.route({ method: "PUT", path: "/v1/demo/upstream" }).output(z.void()), +}; +`; + +function makeUpstream(): string { + const root = mkdtempSync(join(tmpdir(), "import-upstream-")); + writeTree(root, { + [`${WS}/package.json`]: json({ + name: "@test/demo", + version: "2.0.0", + dependencies: { "left-pad": "^2.0.0", "right-pad": "^1.0.0" }, + }), + [`${WS}/src/index.ts`]: "export const x = 2;\n", + [`${WS}/src/shared.ts`]: "export const shared = true;\n", + [`${WS}/src/upstream-only.ts`]: "export const upstreamOnly = true;\n", + [`${WS}/src/contract.ts`]: upstreamContractRoot, + [`${WS}/src/contract/upstream.contract.ts`]: upstreamContractSplit, + [`${WS}/src/db/migrations/meta/_journal.json`]: journal([ + { idx: 0, tag: "0000_base" }, + { idx: 1, tag: "0001_upstream_diff" }, + ]), + [`${WS}/src/db/migrations/0000_base.sql`]: "CREATE TABLE base (id int);\n", + [`${WS}/src/db/migrations/0001_upstream_diff.sql`]: "CREATE TABLE upstream_diff (id int);\n", + }); + return root; +} + +function makeChild(): string { + const root = mkdtempSync(join(tmpdir(), "import-child-")); + writeTree(root, { + [`${WS}/package.json`]: json({ + name: "@test/demo", + version: "1.0.0", + dependencies: { "left-pad": "^1.0.0", "only-local": "^1.0.0" }, + }), + [`${WS}/src/index.ts`]: "export const x = 1;\n", + [`${WS}/src/shared.ts`]: "export const shared = true;\n", + [`${WS}/src/local-only.ts`]: "export const localOnly = true;\n", + [`${WS}/src/contract.ts`]: contractSource([ + ' local: oc.route({ method: "POST", path: "/v1/demo/local" }).output(z.void()),', + ]), + [`${WS}/src/db/migrations/meta/_journal.json`]: journal([ + { idx: 0, tag: "0000_base" }, + { idx: 1, tag: "0001_local_extra" }, + ]), + [`${WS}/src/db/migrations/0000_base.sql`]: "CREATE TABLE base (id int);\n", + [`${WS}/src/db/migrations/0001_local_extra.sql`]: "CREATE TABLE local_extra (id int);\n", + }); + return root; +} + +describe("bos import — workspace import from a local source", () => { + let upstream: string; + let child: string; + + beforeAll(() => { + upstream = makeUpstream(); + child = makeChild(); + }); + + afterAll(() => { + rmSync(upstream, { recursive: true, force: true }); + rmSync(child, { recursive: true, force: true }); + }); + + const options = (overrides: Partial = {}): ImportOptions => ({ + workspace: `./${WS}/`, + from: upstream, + yes: false, + dryRun: false, + noInstall: true, + json: false, + ...overrides, + }); + + const readChild = (rel: string): string => readFileSync(join(child, rel), "utf-8"); + const childExists = (rel: string): boolean => existsSync(join(child, rel)); + + it("dry-run classifies every bucket, reports deltas, and writes nothing", async () => { + const result = await importWorkspace(child, options({ dryRun: true })); + + expect(result.status).toBe("dry-run"); + expect(result.workspace).toBe(WS); + expect(result.ref).toBe("local"); + expect(result.conflicts).toEqual([ + `${WS}/src/contract.ts`, + `${WS}/src/db/migrations/meta/_journal.json`, + `${WS}/src/index.ts`, + ]); + expect(result.added).toEqual([ + `${WS}/src/contract/upstream.contract.ts`, + `${WS}/src/db/migrations/0001_upstream_diff.sql`, + `${WS}/src/upstream-only.ts`, + ]); + expect(result.preservedLocal).toEqual([ + `${WS}/src/db/migrations/0001_local_extra.sql`, + `${WS}/src/local-only.ts`, + ]); + expect(result.unchanged).toEqual([ + `${WS}/src/db/migrations/0000_base.sql`, + `${WS}/src/shared.ts`, + ]); + expect(result.merged).toEqual([`${WS}/package.json`]); + expect(result.heldBackMigrations).toEqual([ + `${WS}/src/db/migrations/0001_upstream_diff.sql`, + `${WS}/src/db/migrations/meta/_journal.json`, + ]); + expect(result.migrationCollisions).toEqual([ + { + kind: "idx-reuse", + idx: 1, + localTag: "0001_local_extra", + upstreamTag: "0001_upstream_diff", + message: "idx 1 is 0001_local_extra locally but 0001_upstream_diff upstream", + }, + ]); + expect(result.routeDelta).toEqual({ + oursOnly: ["POST /v1/demo/local"], + theirsOnly: ["PUT /v1/demo/upstream"], + }); + + expect(childExists(`${WS}/src/upstream-only.ts`)).toBe(false); + expect(readChild(`${WS}/src/index.ts`)).toContain("x = 1"); + + const report = readChild(IMPORT_REPORT_PATH); + expect(report).toContain("Dry run — no files written"); + expect(report).toContain("Migration chain held back"); + expect(report).toContain("idx-reuse"); + expect(report).toContain("POST /v1/demo/local"); + }); + + it("refuses without --yes while conflicts or a held chain exist", async () => { + const result = await importWorkspace(child, options()); + + expect(result.status).toBe("conflicts"); + expect(childExists(`${WS}/src/upstream-only.ts`)).toBe(false); + expect(readChild(`${WS}/src/index.ts`)).toContain("x = 1"); + expect(readChild(IMPORT_REPORT_PATH)).toContain( + "Refused — unresolvable conflicts exist, nothing was written", + ); + }); + + it("confirms through deps.confirm when provided", async () => { + const seen: number[] = []; + const refused = await importWorkspace(child, options(), { + confirm: async (info) => { + seen.push(info.conflictCount, info.heldCount); + return false; + }, + }); + expect(refused.status).toBe("conflicts"); + expect(seen).toEqual([3, 2]); + + const approved = await importWorkspace(child, options(), { confirm: async () => true }); + expect(approved.status).toBe("imported"); + expect(childExists(`${WS}/src/upstream-only.ts`)).toBe(true); + }); + + it("applies with --yes, merges package.json, and holds the migration chain", async () => { + rmSync(child, { recursive: true, force: true }); + child = makeChild(); + + const result = await importWorkspace(child, options({ yes: true })); + + expect(result.status).toBe("imported"); + expect(readChild(`${WS}/src/index.ts`)).toContain("x = 2"); + expect(readChild(`${WS}/src/contract.ts`)).toContain('from "./contract/upstream.contract"'); + expect(readChild(`${WS}/src/contract.ts`)).not.toContain("/v1/demo/local"); + expect(readChild(`${WS}/src/contract/upstream.contract.ts`)).toContain("/v1/demo/upstream"); + + const pkg = JSON.parse(readChild(`${WS}/package.json`)) as { + version: string; + dependencies: Record; + }; + expect(pkg.version).toBe("1.0.0"); + expect(pkg.dependencies["left-pad"]).toBe("^2.0.0"); + expect(pkg.dependencies["right-pad"]).toBe("^1.0.0"); + expect(pkg.dependencies["only-local"]).toBe("^1.0.0"); + + expect(childExists(`${WS}/src/local-only.ts`)).toBe(true); + expect(childExists(`${WS}/src/db/migrations/0001_upstream_diff.sql`)).toBe(false); + expect(childExists(`${WS}/src/db/migrations/0001_local_extra.sql`)).toBe(true); + const appliedJournal = JSON.parse(readChild(`${WS}/src/db/migrations/meta/_journal.json`)) as { + entries: Array<{ tag: string }>; + }; + expect(appliedJournal.entries.map((entry) => entry.tag)).toEqual([ + "0000_base", + "0001_local_extra", + ]); + + expect(result.backupDir).toBeTypeOf("string"); + const backupIndex = readFileSync( + join(result.backupDir as string, `${WS}/src/index.ts`), + "utf-8", + ); + expect(backupIndex).toContain("x = 1"); + + const snapshot = JSON.parse(readChild(".bos/import-snapshot.json")) as { + workspaces: Record }>; + }; + const files = snapshot.workspaces[WS]?.files ?? {}; + expect(files[`${WS}/src/index.ts`]).toBeTypeOf("string"); + expect(files[`${WS}/package.json`]).toBeTypeOf("string"); + expect(files[`${WS}/src/shared.ts`]).toBeTypeOf("string"); + expect(files[`${WS}/src/db/migrations/meta/_journal.json`]).toBeUndefined(); + expect(files[`${WS}/src/db/migrations/0001_upstream_diff.sql`]).toBeUndefined(); + expect(files[`${WS}/src/local-only.ts`]).toBeUndefined(); + + const report = readChild(IMPORT_REPORT_PATH); + expect(report).toContain("Applied — upstream written over shared files"); + expect(report).toContain("Migration chain held back"); + }); + + it("converges on a second run except for the held journal", async () => { + const result = await importWorkspace(child, options({ dryRun: true })); + + expect(result.status).toBe("dry-run"); + expect(result.conflicts).toEqual([`${WS}/src/db/migrations/meta/_journal.json`]); + expect(result.unchanged).toEqual([ + `${WS}/src/contract.ts`, + `${WS}/src/contract/upstream.contract.ts`, + `${WS}/src/db/migrations/0000_base.sql`, + `${WS}/src/index.ts`, + `${WS}/src/shared.ts`, + `${WS}/src/upstream-only.ts`, + ]); + expect(result.merged).toEqual([`${WS}/package.json`]); + expect(result.routeDelta).toEqual({ oursOnly: [], theirsOnly: [] }); + expect(result.heldBackMigrations).toEqual([ + `${WS}/src/db/migrations/0001_upstream_diff.sql`, + `${WS}/src/db/migrations/meta/_journal.json`, + ]); + }); + + it("errors loudly when the workspace is missing from the source", async () => { + const result = await importWorkspace(child, options({ workspace: "plugins/absent" })); + expect(result.status).toBe("error"); + expect(result.error).toContain("not found"); + }); +}); diff --git a/packages/everything-dev/tests/unit/commands-structure.test.ts b/packages/everything-dev/tests/unit/commands-structure.test.ts index d5ca52256..6c4f0a965 100644 --- a/packages/everything-dev/tests/unit/commands-structure.test.ts +++ b/packages/everything-dev/tests/unit/commands-structure.test.ts @@ -4,6 +4,7 @@ import { registerConfig } from "../../src/commands/config"; import { registerDb } from "../../src/commands/db"; import { registerDeploy } from "../../src/commands/deploy"; import { registerDev } from "../../src/commands/dev"; +import { registerImport } from "../../src/commands/import"; import { registerInit } from "../../src/commands/init"; import { registerKeys } from "../../src/commands/keys"; import { registerOps } from "../../src/commands/ops"; @@ -23,6 +24,7 @@ const registerFns = [ registerSmoke, registerKeys, registerInit, + registerImport, registerUpgrade, registerDb, registerOps, diff --git a/packages/everything-dev/tests/unit/import-classify.test.ts b/packages/everything-dev/tests/unit/import-classify.test.ts new file mode 100644 index 000000000..691441569 --- /dev/null +++ b/packages/everything-dev/tests/unit/import-classify.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { classifyImportFile } from "../../src/cli/import"; + +describe("bos import — classifyImportFile", () => { + it("classifies a missing local file as added", () => { + expect( + classifyImportFile({ + localHash: null, + upstreamHash: "u", + baselineHash: undefined, + mergeable: false, + }), + ).toEqual({ kind: "added" }); + }); + + it("classifies identical content as unchanged", () => { + expect( + classifyImportFile({ + localHash: "same", + upstreamHash: "same", + baselineHash: "old", + mergeable: false, + }), + ).toEqual({ kind: "unchanged" }); + }); + + it("reports a no-baseline conflict when the baseline is unknown", () => { + expect( + classifyImportFile({ + localHash: "local", + upstreamHash: "up", + baselineHash: undefined, + mergeable: false, + }), + ).toEqual({ kind: "conflict", reason: "no-baseline" }); + }); + + it("merges package.json on first import even without a baseline", () => { + expect( + classifyImportFile({ + localHash: "local", + upstreamHash: "up", + baselineHash: undefined, + mergeable: true, + }), + ).toEqual({ kind: "merged" }); + }); + + it("updates when local matches the baseline and upstream moved", () => { + expect( + classifyImportFile({ + localHash: "base", + upstreamHash: "new", + baselineHash: "base", + mergeable: false, + }), + ).toEqual({ kind: "updated" }); + }); + + it("merges instead of raw overwriting when local matches a previously merged baseline", () => { + expect( + classifyImportFile({ + localHash: "merged-state", + upstreamHash: "up", + baselineHash: "merged-state", + mergeable: true, + }), + ).toEqual({ kind: "merged" }); + }); + + it("preserves local edits when upstream matches the baseline", () => { + expect( + classifyImportFile({ + localHash: "edited", + upstreamHash: "base", + baselineHash: "base", + mergeable: false, + }), + ).toEqual({ kind: "preserved-local-edit" }); + }); + + it("reports a both-changed conflict", () => { + expect( + classifyImportFile({ + localHash: "edited", + upstreamHash: "moved", + baselineHash: "base", + mergeable: false, + }), + ).toEqual({ kind: "conflict", reason: "both-changed" }); + }); + + it("merges rather than conflicts when a mergeable file changed on both sides", () => { + expect( + classifyImportFile({ + localHash: "edited", + upstreamHash: "moved", + baselineHash: "base", + mergeable: true, + }), + ).toEqual({ kind: "merged" }); + }); +}); diff --git a/packages/everything-dev/tests/unit/import-from-spec.test.ts b/packages/everything-dev/tests/unit/import-from-spec.test.ts new file mode 100644 index 000000000..e539920c0 --- /dev/null +++ b/packages/everything-dev/tests/unit/import-from-spec.test.ts @@ -0,0 +1,82 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { normalizeWorkspacePath, parseFromSpec } from "../../src/cli/import"; + +describe("bos import — parseFromSpec", () => { + it("parses owner/repo shorthand into a github URL", () => { + expect(parseFromSpec("NEARBuilders/everything-dev")).toEqual({ + kind: "github", + repo: "https://github.com/NEARBuilders/everything-dev", + }); + }); + + it("parses owner/repo@ref", () => { + expect(parseFromSpec("NEARBuilders/everything-dev@v1.2.3")).toEqual({ + kind: "github", + repo: "https://github.com/NEARBuilders/everything-dev", + ref: "v1.2.3", + }); + }); + + it("splits on the first @ so changesets tags keep their own @", () => { + expect(parseFromSpec("NEARBuilders/everything-dev@everything-dev@2.0.0-rc.8")).toEqual({ + kind: "github", + repo: "https://github.com/NEARBuilders/everything-dev", + ref: "everything-dev@2.0.0-rc.8", + }); + }); + + it("parses a full GitHub URL", () => { + expect(parseFromSpec("https://github.com/NEARBuilders/everything-dev")).toEqual({ + kind: "github", + repo: "https://github.com/NEARBuilders/everything-dev", + }); + }); + + it("parses a GitHub URL with a ref", () => { + expect(parseFromSpec("https://github.com/NEARBuilders/everything-dev@develop")).toEqual({ + kind: "github", + repo: "https://github.com/NEARBuilders/everything-dev", + ref: "develop", + }); + }); + + it("accepts an existing local directory", () => { + const dir = join(import.meta.dirname, ".."); + expect(parseFromSpec(dir)).toEqual({ kind: "local", dir }); + }); + + it("rejects an absolute path that does not exist", () => { + expect(() => parseFromSpec("/nonexistent/upstream-source-xyz")).toThrow( + "Local directory not found", + ); + }); + + it("requires a source", () => { + expect(() => parseFromSpec(" ")).toThrow("--from is required"); + }); + + it("rejects an unparseable source", () => { + expect(() => parseFromSpec("not a source")).toThrow("Cannot parse --from source"); + }); +}); + +describe("bos import — normalizeWorkspacePath", () => { + it("normalizes ./ prefix and trailing slashes", () => { + expect(normalizeWorkspacePath("./plugins/auth/")).toBe("plugins/auth"); + }); + + it("rejects absolute paths", () => { + expect(() => normalizeWorkspacePath("/abs/workspace")).toThrow("repo-relative"); + }); + + it("rejects traversal segments", () => { + expect(() => normalizeWorkspacePath("../up")).toThrow("Invalid workspace path"); + expect(() => normalizeWorkspacePath("a/../b")).toThrow("Invalid workspace path"); + }); + + it("rejects empty and dot paths", () => { + expect(() => normalizeWorkspacePath(".")).toThrow("Invalid workspace path"); + expect(() => normalizeWorkspacePath("")).toThrow("Invalid workspace path"); + }); +}); diff --git a/packages/everything-dev/tests/unit/import-migrations.test.ts b/packages/everything-dev/tests/unit/import-migrations.test.ts new file mode 100644 index 000000000..93ea6c5a2 --- /dev/null +++ b/packages/everything-dev/tests/unit/import-migrations.test.ts @@ -0,0 +1,124 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { detectMigrationCollisions, loadMigrationEntries } from "../../src/cli/import"; + +const WS = "plugins/demo"; + +describe("bos import — loadMigrationEntries", () => { + let root: string; + + const writeChainFile = (projectRoot: string, rel: string, content: string) => { + const full = join(projectRoot, WS, "src", "db", "migrations", rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + }; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "import-migrations-")); + writeChainFile( + root, + "meta/_journal.json", + JSON.stringify({ + version: 7, + dialect: "postgresql", + entries: [ + { idx: 0, tag: "0000_base", version: "7" }, + { idx: 1, tag: "0001_local_extra", version: "7" }, + ], + }), + ); + writeChainFile(root, "0000_base.sql", "CREATE TABLE base (id int);\n"); + writeChainFile(root, "0001_local_extra.sql", "CREATE TABLE local_extra (id int);\n"); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("loads journal entries sorted by idx with sql content hashes", () => { + const entries = loadMigrationEntries(root, WS); + expect(entries).not.toBeNull(); + expect(entries?.map((entry) => [entry.idx, entry.tag])).toEqual([ + [0, "0000_base"], + [1, "0001_local_extra"], + ]); + expect(entries?.[0].sha).toBeTypeOf("string"); + expect(entries?.[1].sha).toBeTypeOf("string"); + }); + + it("returns null when the journal is missing", () => { + expect(loadMigrationEntries(root, "plugins/absent")).toBeNull(); + }); + + it("returns null when the journal is unreadable", () => { + const bad = mkdtempSync(join(tmpdir(), "import-migrations-bad-")); + writeChainFile(bad, "meta/_journal.json", "{ not json"); + try { + expect(loadMigrationEntries(bad, WS)).toBeNull(); + } finally { + rmSync(bad, { recursive: true, force: true }); + } + }); +}); + +describe("bos import — detectMigrationCollisions", () => { + const local = [ + { idx: 0, tag: "0000_base", sha: "aaa" }, + { idx: 1, tag: "0001_local_extra", sha: "bbb" }, + { idx: 2, tag: "0002_more", sha: "ccc" }, + ]; + + it("flags idx reuse when the same idx carries a different tag", () => { + const collisions = detectMigrationCollisions(local, [ + { idx: 1, tag: "0001_upstream_diff", sha: "zzz" }, + ]); + expect(collisions).toEqual([ + { + kind: "idx-reuse", + idx: 1, + localTag: "0001_local_extra", + upstreamTag: "0001_upstream_diff", + message: "idx 1 is 0001_local_extra locally but 0001_upstream_diff upstream", + }, + ]); + }); + + it("flags content drift when an identical tag+idx hashes differently", () => { + const collisions = detectMigrationCollisions( + [{ idx: 0, tag: "0000_base", sha: "aaa" }], + [{ idx: 0, tag: "0000_base", sha: "different" }], + ); + expect(collisions.map((collision) => collision.kind)).toEqual(["tag-mismatch"]); + }); + + it("skips the content check when a side has no hash", () => { + const collisions = detectMigrationCollisions( + [{ idx: 0, tag: "0000_base", sha: null }], + [{ idx: 0, tag: "0000_base", sha: "different" }], + ); + expect(collisions).toEqual([]); + }); + + it("accepts matching shared entries and appended upstream entries", () => { + const collisions = detectMigrationCollisions( + [{ idx: 0, tag: "0000_base", sha: "aaa" }], + [ + { idx: 0, tag: "0000_base", sha: "aaa" }, + { idx: 1, tag: "0001_upstream", sha: "ddd" }, + ], + ); + expect(collisions).toEqual([]); + }); + + it("flags an upstream entry landing below the local chain tail", () => { + const gapped = [ + { idx: 0, tag: "0000_base", sha: "a" }, + { idx: 2, tag: "0002_tail", sha: "c" }, + ]; + const collisions = detectMigrationCollisions(gapped, [{ idx: 1, tag: "0001_new", sha: "b" }]); + expect(collisions.map((collision) => collision.kind)).toEqual(["out-of-order"]); + expect(collisions[0].message).toContain("below the local chain tail"); + }); +}); diff --git a/packages/everything-dev/tests/unit/import-routes.test.ts b/packages/everything-dev/tests/unit/import-routes.test.ts new file mode 100644 index 000000000..3fa96206e --- /dev/null +++ b/packages/everything-dev/tests/unit/import-routes.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { computeRouteDelta, extractContractRoutes } from "../../src/cli/import"; + +describe("bos import — extractContractRoutes", () => { + const source = ` +const contract = { + health: oc.route({ method: "GET", path: "/health" }).output(z.string()), + create: oc.route({ method: "POST", path: "/v1/demo/local" }).output(z.void()), + nested: oc.route({ + method: "PUT", + path: "/v1/demo/upstream", + meta: { description: "keeps balanced parens (like this)" }, + }), + computed: oc.route({ method: methodVar, path: dynamicPath }), + duplicate: oc.route({ method: "GET", path: "/health" }), +}; +`; + + it("extracts literal method/path pairs sorted, skipping computed routes", () => { + expect(extractContractRoutes(source)).toEqual([ + "GET /health", + "POST /v1/demo/local", + "PUT /v1/demo/upstream", + ]); + }); + + it("returns an empty list for source without routes", () => { + expect(extractContractRoutes("export const x = 1;")).toEqual([]); + }); +}); + +describe("bos import — computeRouteDelta", () => { + it("splits ours-only and theirs-only routes", () => { + expect(computeRouteDelta(["GET /a", "POST /b"], ["GET /a", "PUT /c"])).toEqual({ + oursOnly: ["POST /b"], + theirsOnly: ["PUT /c"], + }); + }); + + it("returns empty deltas for identical route sets", () => { + expect(computeRouteDelta(["GET /a"], ["GET /a"])).toEqual({ + oursOnly: [], + theirsOnly: [], + }); + }); +}); From ae65e5d737989c9e13f0d812da4de3ff9d5c030f Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:14:55 -0500 Subject: [PATCH 02/20] chore(skills): reconcile better-near-auth skill library_version to 2.0.0-rc.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset release bumped packages/better-near-auth to 2.0.0-rc.1 (better-auth 1.6.25 → 1.7.7 internal upgrade — no documented API changes), so the six skills' library_version markers drift and the Check Skills workflow fails with needsReview on every PR merge commit. Reviewed via 'intent stale packages/better-near-auth': version bump only, no content regeneration needed. --- packages/better-near-auth/skills/auth-plugin/SKILL.md | 2 +- packages/better-near-auth/skills/client/SKILL.md | 2 +- packages/better-near-auth/skills/relay/SKILL.md | 2 +- packages/better-near-auth/skills/siwn/SKILL.md | 2 +- packages/better-near-auth/skills/subaccount/SKILL.md | 2 +- packages/better-near-auth/skills/tanstack/SKILL.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/better-near-auth/skills/auth-plugin/SKILL.md b/packages/better-near-auth/skills/auth-plugin/SKILL.md index 0f52621fb..d4b0a35e8 100644 --- a/packages/better-near-auth/skills/auth-plugin/SKILL.md +++ b/packages/better-near-auth/skills/auth-plugin/SKILL.md @@ -19,7 +19,7 @@ requires: metadata: type: composition library: better-near-auth - library_version: "2.0.0-rc.0" + library_version: "2.0.0-rc.1" sources: - "elliotBraem/better-near-auth:examples/auth.everything.dev/bos.config.json" - "elliotBraem/better-near-auth:examples/auth.everything.dev/plugins/auth/src/contract.ts" diff --git a/packages/better-near-auth/skills/client/SKILL.md b/packages/better-near-auth/skills/client/SKILL.md index 94972adda..7dd248361 100644 --- a/packages/better-near-auth/skills/client/SKILL.md +++ b/packages/better-near-auth/skills/client/SKILL.md @@ -12,7 +12,7 @@ description: > metadata: type: core library: better-near-auth - library_version: "2.0.0-rc.0" + library_version: "2.0.0-rc.1" sources: - "elliotBraem/better-near-auth:src/client.ts" - "elliotBraem/better-near-auth:src/types.ts" diff --git a/packages/better-near-auth/skills/relay/SKILL.md b/packages/better-near-auth/skills/relay/SKILL.md index 80daef274..f71c06811 100644 --- a/packages/better-near-auth/skills/relay/SKILL.md +++ b/packages/better-near-auth/skills/relay/SKILL.md @@ -8,7 +8,7 @@ description: > metadata: type: core library: better-near-auth - library_version: "2.0.0-rc.0" + library_version: "2.0.0-rc.1" sources: - "elliotBraem/better-near-auth:src/index.ts" - "elliotBraem/better-near-auth:src/utils.ts" diff --git a/packages/better-near-auth/skills/siwn/SKILL.md b/packages/better-near-auth/skills/siwn/SKILL.md index a39e6e569..0399d0203 100644 --- a/packages/better-near-auth/skills/siwn/SKILL.md +++ b/packages/better-near-auth/skills/siwn/SKILL.md @@ -9,7 +9,7 @@ description: > metadata: type: core library: better-near-auth - library_version: "2.0.0-rc.0" + library_version: "2.0.0-rc.1" sources: - "elliotBraem/better-near-auth:src/index.ts" - "elliotBraem/better-near-auth:src/profile.ts" diff --git a/packages/better-near-auth/skills/subaccount/SKILL.md b/packages/better-near-auth/skills/subaccount/SKILL.md index b7cde7467..96f41f648 100644 --- a/packages/better-near-auth/skills/subaccount/SKILL.md +++ b/packages/better-near-auth/skills/subaccount/SKILL.md @@ -11,7 +11,7 @@ description: > metadata: type: core library: better-near-auth - library_version: "2.0.0-rc.0" + library_version: "2.0.0-rc.1" sources: - "elliotBraem/better-near-auth:src/index.ts" - "elliotBraem/better-near-auth:src/types.ts" diff --git a/packages/better-near-auth/skills/tanstack/SKILL.md b/packages/better-near-auth/skills/tanstack/SKILL.md index 3da3f1ee9..1c3313083 100644 --- a/packages/better-near-auth/skills/tanstack/SKILL.md +++ b/packages/better-near-auth/skills/tanstack/SKILL.md @@ -13,7 +13,7 @@ requires: metadata: type: framework library: better-near-auth - library_version: "2.0.0-rc.0" + library_version: "2.0.0-rc.1" sources: - "elliotBraem/better-near-auth:src/client.ts" - "elliotBraem/better-near-auth:examples/auth.everything.dev/ui/src/lib/auth.ts" From 0b8dbd4218e3ba9dbed6678cdce4a2d89981621d Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:29:06 -0500 Subject: [PATCH 03/20] docs: boot-chain contract, apps/ index, AGENTS.md apps-tier taxonomy (#399-#401) --- AGENTS.md | 11 +++++ apps/README.md | 14 ++++++ apps/dev/README.md | 4 +- docs/architecture/boot-chain.md | 78 +++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 apps/README.md create mode 100644 docs/architecture/boot-chain.md diff --git a/AGENTS.md b/AGENTS.md index f5018b202..055fa64a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,6 +398,7 @@ Run your own host instance on your own NEAR account while inheriting the base pl ### Making Changes - **Host Changes**: Edit `host/src/` when changing runtime resolution, auth wiring, SSR, proxying, or plugin mounting +- **App Changes**: Edit `apps//` when changing a standalone service — apps are not plugins: no `bos.app.ts` registration, not composed via `pluginsClient`, own deploy lifecycle. See `apps/README.md` for the index - **UI Changes**: Edit `ui/src/` files → hot reload automatically - **API Changes**: Edit `api/src/` files → hot reload automatically - **CLI/Scaffolding Changes**: Edit `packages/everything-dev/` when changing init/dev/publish flows or child-project scaffolding @@ -435,6 +436,16 @@ compose with the `.use()` builder without an explicit context generic (the auth plugin's `plugins/auth/src/handlers/admin-users.ts` passes `createAuthMiddleware(builder)` to make it compose; the proposals plugin's local middleware is the alternative pattern). +### Apps tier (vs plugins vs packages) + +Directory taxonomy — where a deployable unit belongs: + +- **`packages/`** — runtime libraries and clients: imported by other code, never deployed alone +- **`plugins/`** — composed plugins registered in `bos.app.ts`, loaded via Module Federation and composed in-process via `pluginsClient` +- **`apps/`** — standalone services with their own deploy lifecycle, not registered in `bos.app.ts` (e.g. `apps/upload` → Railway service). Index and roadmap: `apps/README.md` (per ADR 0027, more apps absorb current plugins/UI over time) + +Every app runs the universal image and boots through the provider-generic chain documented in `docs/architecture/boot-chain.md` — identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV → CDN bundles → Module Federation. Read that doc before touching the boot path (`resolver.ts`, `orchestrator.ts`, `fastkv.ts`, `host/src/program.ts`); it is the contract those files implement. A hosting provider is anything that can `docker run` the image; Railway is the current instance only, and the `deployImageToRailway` deploy-train tail is optional (gated on `RAILWAY_TOKEN`, skipped loudly without it). + ### Plugin Architecture Business logic is organized into independent plugins loaded via Module Federation. A plugin entry in the authored config can be **remote-only** (no `development: local:…` key) — the host/API consume it via `pluginsClient` and HTTP, and types resolve from the deployed manifest (see "Generated types" below). Plugin source does not need to live in this repo. diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 000000000..904f88f60 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,14 @@ +# Apps + +Independently deployed services live here — not registered in `bos.app.ts`, each with its own deploy lifecycle. Composed plugins registered in `bos.app.ts` live in `plugins/`. Runtime libraries and clients live in `packages/`. + +| App | Purpose | Deployment | Status | +|---|---|---|---| +| [`upload/`](./upload) | Bundle write/control surface — receives deploy-train uploads, stores to R2, serves the inspection API (ADR 0027 phase 0) | Railway service `upload` (any Docker host — [boot-chain contract](../docs/architecture/boot-chain.md)) | live | +| [`_/`](./_) | Host app — api and ui mount through it (`/$path.$accountId/$domain`) | universal image | planned | +| [`auth/`](./auth) | Auth app — admin dashboard, org/team management, API-key minting, login (absorbs `plugins/auth` per ADR 0027) | universal image | planned | +| [`build/`](./build) | Reference starter app — bare ui+api, ships skills via TanStack Intents + mattpocock/skills (what `bos init` scaffolds from, per ADR 0027) | — | planned | +| [`dev/`](./dev) | Development app — per-project sidecar + dashboard (ADR 0027) | — | planned | +| `www/` | Production everything.dev app — `ui/` + `api/` move here (ADR 0027 §1) | universal image | planned — dir not yet created | + +Every app runs on the universal image and boots through the same [boot-chain contract](../docs/architecture/boot-chain.md): identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV config → CDN bundles → Module Federation runtime. A hosting provider is anything that can run that Docker image — Railway is the current instance, not the architecture. diff --git a/apps/dev/README.md b/apps/dev/README.md index 292efbd7e..824c761ed 100644 --- a/apps/dev/README.md +++ b/apps/dev/README.md @@ -1,3 +1,3 @@ -# build.everything.dev +# dev.everything.dev -Starter app with ui and api \ No newline at end of file +Starter app with ui and api — per-project sidecar + dashboard (ADR 0027) \ No newline at end of file diff --git a/docs/architecture/boot-chain.md b/docs/architecture/boot-chain.md new file mode 100644 index 000000000..c083a32ed --- /dev/null +++ b/docs/architecture/boot-chain.md @@ -0,0 +1,78 @@ +# The boot chain — the universal image's provider contract + +The production platform boots through one chain: **Docker image → identity → FastKV config → CDN bundles → Module Federation runtime**. The chain contains zero hosting-provider references. Any host that can run a Docker container boots the platform identically; Railway is one instance of that contract, not part of the architecture. + +This document is the contract. Changes to the boot path (anything referenced below) must keep it true or update this file in the same PR. + +## The chain + +``` +docker run + │ CMD: node ./node_modules/everything-dev/dist/cli.mjs start --no-interactive --port ${PORT:-3000} + │ (Dockerfile:159 — the image carries no config and no staged dists) + ▼ +identity resolution + │ packages/everything-dev/src/start-config-source.ts:12 + │ precedence: --config-path → BOS_ACCOUNT/BOS_GATEWAY → config found on disk + ▼ +FastKV config fetch + │ walkExtendsChain (packages/everything-dev/src/resolution/session.ts:469) + │ GET https://kv.{test,main}.fastnear.com/v0/latest///apps///bos.config.json + │ (packages/everything-dev/src/fastkv.ts:17, :40) + │ policy: 10s timeout, 3 retries, capped exponential backoff with jitter; + │ 5xx/network retried, 4xx not (packages/everything-dev/src/http-client.ts:38) + │ failure is loud: StartRemoteConfigMissing / StartFetchFailed → exit 1 + ▼ +CDN bundle fetch + │ installGlobalBundleFetch patches globalThis.fetch — every boot-time byte + │ (manifest discovery, remoteEntry, SSR composition) crosses one seam + │ (packages/everything-dev/src/resolver.ts:313; installed in cli.ts:233 and host/server.ts:38) + │ policy: 30s per-attempt deadline, 4 retries, capped exponential (resolver.ts:215) + │ no implicit cache: BOS_BUNDLE_CACHE_DIR is an operator opt-in only; + │ a CDN outage fails loud rather than serving stale bytes (resolver.ts:393) + ▼ +Module Federation host boot (spawnRemoteHost, packages/everything-dev/src/orchestrator.ts:221) + │ 1. fetch host mf-manifest.json from the CDN base URL — 10s (orchestrator.ts:103) + │ 2. registerRemote + loadRemoteModule("host/Server") — 60s (orchestrator.ts:104) + │ 3. runServer({ config, port, env }); await ready — 120s (orchestrator.ts:105) + │ overall boot deadline 180s (orchestrator.ts:106) + ▼ +host program (host/src/program.ts:297 runServer, :56 createStartServer) + │ boot SSR composition before /health; failed composition in production + │ exits 1 so the deploy rolls back + ▼ +two-phase plugin composition (host/src/services/plugins.ts:598) + │ plugins load in-process in dependency-DAG order building pluginsClient; + │ the API entry receives the finished map (no HTTP roundtrips); + │ failed plugins degrade instead of crashing the host + ▼ +request handling + per-request tenant resolution: hostname → TenantBinding via + GET /api/tenants/bindings (host/src/services/binding-resolver.ts:57, + 30s cache, stale-serve on failure) → tenant config walk, must extend the + active base runtime; overrides touch only ui and plugins..ui with + SRI verification; allowSsr gates tenant SSR + (host/src/services/tenant-runtime.ts:388) +``` + +## The hosting-provider contract + +A hosting provider is anything that can: + +1. **Run the image** — `node` runtime, one process (`CMD` above); the start stack loads host, api, and plugins in-process through Module Federation, so the runtime needs only node. +2. **Provide identity** — `BOS_ACCOUNT` and `BOS_GATEWAY` env vars (or a mounted config: `--config-path`, or `bos.config.json` at `/app`). +3. **Provide secrets via env** — database URLs, `BETTER_AUTH_SECRET`, storage credentials, etc. +4. **Map one TCP port** — `PORT` (default 3000). +5. **Probe `/health`** — HTTP healthcheck reflecting plugin/SSR/self-probe status. + +That is the whole surface. Fly Machines, GCP Cloud Run, AWS ECS/Fargate, Kubernetes, a VPS with `docker run`, and Railway all satisfy it unchanged. + +## What is deliberately not part of the contract + +- **The deploy train's last leg.** `deployImageToRailway` (`packages/everything-dev/src/image-deploy.ts`) is optional and gated on `RAILWAY_TOKEN` + `ci.railway.service`; skipped loudly otherwise. Image push (GHCR), bundle upload (R2), and FastKV publish are provider-independent. +- **Databases.** Postgres arrives via `*_DATABASE_URL` env; the image provisions nothing. +- **Storage cache tiers.** `BOS_BUNDLE_DIR` (staged namespace) and `BOS_BUNDLE_CACHE_DIR` (stale-if-error cache) are operator opt-ins via `docker run -e`; the published image sets neither (CDN-only, fail-loud). + +## Future work: infrastructure as Effects + +The platform's Cloudflare resources (R2 bucket, `cdn.everything.dev` custom domain, zone/DNS) are managed by hand today. The planned follow-up is an [Alchemy](https://alchemy.run) stack — Infrastructure as Effects, Apache-2.0, Effect-native (the same Effect v4 this repo runs) — under `infra/`, following Alchemy's file layout (`alchemy.run.ts` composition root, one file per Resource, stages). See issue #402. Railway has no Alchemy construct; it stays CLI-managed. From 0f539571ec32201d08be39b40a36982c72dee1a2 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:33:12 -0500 Subject: [PATCH 04/20] Revert "docs: boot-chain contract, apps/ index, AGENTS.md apps-tier taxonomy (#399-#401)" This reverts commit 8e74126c5d2559cadb129c736f5de453b6b7b565. --- AGENTS.md | 11 ----- apps/README.md | 14 ------ apps/dev/README.md | 4 +- docs/architecture/boot-chain.md | 78 --------------------------------- 4 files changed, 2 insertions(+), 105 deletions(-) delete mode 100644 apps/README.md delete mode 100644 docs/architecture/boot-chain.md diff --git a/AGENTS.md b/AGENTS.md index 055fa64a4..f5018b202 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,7 +398,6 @@ Run your own host instance on your own NEAR account while inheriting the base pl ### Making Changes - **Host Changes**: Edit `host/src/` when changing runtime resolution, auth wiring, SSR, proxying, or plugin mounting -- **App Changes**: Edit `apps//` when changing a standalone service — apps are not plugins: no `bos.app.ts` registration, not composed via `pluginsClient`, own deploy lifecycle. See `apps/README.md` for the index - **UI Changes**: Edit `ui/src/` files → hot reload automatically - **API Changes**: Edit `api/src/` files → hot reload automatically - **CLI/Scaffolding Changes**: Edit `packages/everything-dev/` when changing init/dev/publish flows or child-project scaffolding @@ -436,16 +435,6 @@ compose with the `.use()` builder without an explicit context generic (the auth plugin's `plugins/auth/src/handlers/admin-users.ts` passes `createAuthMiddleware(builder)` to make it compose; the proposals plugin's local middleware is the alternative pattern). -### Apps tier (vs plugins vs packages) - -Directory taxonomy — where a deployable unit belongs: - -- **`packages/`** — runtime libraries and clients: imported by other code, never deployed alone -- **`plugins/`** — composed plugins registered in `bos.app.ts`, loaded via Module Federation and composed in-process via `pluginsClient` -- **`apps/`** — standalone services with their own deploy lifecycle, not registered in `bos.app.ts` (e.g. `apps/upload` → Railway service). Index and roadmap: `apps/README.md` (per ADR 0027, more apps absorb current plugins/UI over time) - -Every app runs the universal image and boots through the provider-generic chain documented in `docs/architecture/boot-chain.md` — identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV → CDN bundles → Module Federation. Read that doc before touching the boot path (`resolver.ts`, `orchestrator.ts`, `fastkv.ts`, `host/src/program.ts`); it is the contract those files implement. A hosting provider is anything that can `docker run` the image; Railway is the current instance only, and the `deployImageToRailway` deploy-train tail is optional (gated on `RAILWAY_TOKEN`, skipped loudly without it). - ### Plugin Architecture Business logic is organized into independent plugins loaded via Module Federation. A plugin entry in the authored config can be **remote-only** (no `development: local:…` key) — the host/API consume it via `pluginsClient` and HTTP, and types resolve from the deployed manifest (see "Generated types" below). Plugin source does not need to live in this repo. diff --git a/apps/README.md b/apps/README.md deleted file mode 100644 index 904f88f60..000000000 --- a/apps/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Apps - -Independently deployed services live here — not registered in `bos.app.ts`, each with its own deploy lifecycle. Composed plugins registered in `bos.app.ts` live in `plugins/`. Runtime libraries and clients live in `packages/`. - -| App | Purpose | Deployment | Status | -|---|---|---|---| -| [`upload/`](./upload) | Bundle write/control surface — receives deploy-train uploads, stores to R2, serves the inspection API (ADR 0027 phase 0) | Railway service `upload` (any Docker host — [boot-chain contract](../docs/architecture/boot-chain.md)) | live | -| [`_/`](./_) | Host app — api and ui mount through it (`/$path.$accountId/$domain`) | universal image | planned | -| [`auth/`](./auth) | Auth app — admin dashboard, org/team management, API-key minting, login (absorbs `plugins/auth` per ADR 0027) | universal image | planned | -| [`build/`](./build) | Reference starter app — bare ui+api, ships skills via TanStack Intents + mattpocock/skills (what `bos init` scaffolds from, per ADR 0027) | — | planned | -| [`dev/`](./dev) | Development app — per-project sidecar + dashboard (ADR 0027) | — | planned | -| `www/` | Production everything.dev app — `ui/` + `api/` move here (ADR 0027 §1) | universal image | planned — dir not yet created | - -Every app runs on the universal image and boots through the same [boot-chain contract](../docs/architecture/boot-chain.md): identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV config → CDN bundles → Module Federation runtime. A hosting provider is anything that can run that Docker image — Railway is the current instance, not the architecture. diff --git a/apps/dev/README.md b/apps/dev/README.md index 824c761ed..292efbd7e 100644 --- a/apps/dev/README.md +++ b/apps/dev/README.md @@ -1,3 +1,3 @@ -# dev.everything.dev +# build.everything.dev -Starter app with ui and api — per-project sidecar + dashboard (ADR 0027) \ No newline at end of file +Starter app with ui and api \ No newline at end of file diff --git a/docs/architecture/boot-chain.md b/docs/architecture/boot-chain.md deleted file mode 100644 index c083a32ed..000000000 --- a/docs/architecture/boot-chain.md +++ /dev/null @@ -1,78 +0,0 @@ -# The boot chain — the universal image's provider contract - -The production platform boots through one chain: **Docker image → identity → FastKV config → CDN bundles → Module Federation runtime**. The chain contains zero hosting-provider references. Any host that can run a Docker container boots the platform identically; Railway is one instance of that contract, not part of the architecture. - -This document is the contract. Changes to the boot path (anything referenced below) must keep it true or update this file in the same PR. - -## The chain - -``` -docker run - │ CMD: node ./node_modules/everything-dev/dist/cli.mjs start --no-interactive --port ${PORT:-3000} - │ (Dockerfile:159 — the image carries no config and no staged dists) - ▼ -identity resolution - │ packages/everything-dev/src/start-config-source.ts:12 - │ precedence: --config-path → BOS_ACCOUNT/BOS_GATEWAY → config found on disk - ▼ -FastKV config fetch - │ walkExtendsChain (packages/everything-dev/src/resolution/session.ts:469) - │ GET https://kv.{test,main}.fastnear.com/v0/latest///apps///bos.config.json - │ (packages/everything-dev/src/fastkv.ts:17, :40) - │ policy: 10s timeout, 3 retries, capped exponential backoff with jitter; - │ 5xx/network retried, 4xx not (packages/everything-dev/src/http-client.ts:38) - │ failure is loud: StartRemoteConfigMissing / StartFetchFailed → exit 1 - ▼ -CDN bundle fetch - │ installGlobalBundleFetch patches globalThis.fetch — every boot-time byte - │ (manifest discovery, remoteEntry, SSR composition) crosses one seam - │ (packages/everything-dev/src/resolver.ts:313; installed in cli.ts:233 and host/server.ts:38) - │ policy: 30s per-attempt deadline, 4 retries, capped exponential (resolver.ts:215) - │ no implicit cache: BOS_BUNDLE_CACHE_DIR is an operator opt-in only; - │ a CDN outage fails loud rather than serving stale bytes (resolver.ts:393) - ▼ -Module Federation host boot (spawnRemoteHost, packages/everything-dev/src/orchestrator.ts:221) - │ 1. fetch host mf-manifest.json from the CDN base URL — 10s (orchestrator.ts:103) - │ 2. registerRemote + loadRemoteModule("host/Server") — 60s (orchestrator.ts:104) - │ 3. runServer({ config, port, env }); await ready — 120s (orchestrator.ts:105) - │ overall boot deadline 180s (orchestrator.ts:106) - ▼ -host program (host/src/program.ts:297 runServer, :56 createStartServer) - │ boot SSR composition before /health; failed composition in production - │ exits 1 so the deploy rolls back - ▼ -two-phase plugin composition (host/src/services/plugins.ts:598) - │ plugins load in-process in dependency-DAG order building pluginsClient; - │ the API entry receives the finished map (no HTTP roundtrips); - │ failed plugins degrade instead of crashing the host - ▼ -request handling - per-request tenant resolution: hostname → TenantBinding via - GET /api/tenants/bindings (host/src/services/binding-resolver.ts:57, - 30s cache, stale-serve on failure) → tenant config walk, must extend the - active base runtime; overrides touch only ui and plugins..ui with - SRI verification; allowSsr gates tenant SSR - (host/src/services/tenant-runtime.ts:388) -``` - -## The hosting-provider contract - -A hosting provider is anything that can: - -1. **Run the image** — `node` runtime, one process (`CMD` above); the start stack loads host, api, and plugins in-process through Module Federation, so the runtime needs only node. -2. **Provide identity** — `BOS_ACCOUNT` and `BOS_GATEWAY` env vars (or a mounted config: `--config-path`, or `bos.config.json` at `/app`). -3. **Provide secrets via env** — database URLs, `BETTER_AUTH_SECRET`, storage credentials, etc. -4. **Map one TCP port** — `PORT` (default 3000). -5. **Probe `/health`** — HTTP healthcheck reflecting plugin/SSR/self-probe status. - -That is the whole surface. Fly Machines, GCP Cloud Run, AWS ECS/Fargate, Kubernetes, a VPS with `docker run`, and Railway all satisfy it unchanged. - -## What is deliberately not part of the contract - -- **The deploy train's last leg.** `deployImageToRailway` (`packages/everything-dev/src/image-deploy.ts`) is optional and gated on `RAILWAY_TOKEN` + `ci.railway.service`; skipped loudly otherwise. Image push (GHCR), bundle upload (R2), and FastKV publish are provider-independent. -- **Databases.** Postgres arrives via `*_DATABASE_URL` env; the image provisions nothing. -- **Storage cache tiers.** `BOS_BUNDLE_DIR` (staged namespace) and `BOS_BUNDLE_CACHE_DIR` (stale-if-error cache) are operator opt-ins via `docker run -e`; the published image sets neither (CDN-only, fail-loud). - -## Future work: infrastructure as Effects - -The platform's Cloudflare resources (R2 bucket, `cdn.everything.dev` custom domain, zone/DNS) are managed by hand today. The planned follow-up is an [Alchemy](https://alchemy.run) stack — Infrastructure as Effects, Apache-2.0, Effect-native (the same Effect v4 this repo runs) — under `infra/`, following Alchemy's file layout (`alchemy.run.ts` composition root, one file per Resource, stages). See issue #402. Railway has no Alchemy construct; it stays CLI-managed. From bad67ebb4e9842048e79e8b76acfc7501184fa52 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:41:40 -0500 Subject: [PATCH 05/20] feat(cli): bos import reconciles diverging migration chains (#393) Applied local journal entries are kept verbatim; colliding upstream migrations are renumbered and appended after the local tail (.sql + meta/*_snapshot.json renamed, journal rebuilt with monotonic when). Repeat imports converge via SQL content-hash matching. ImportResult gains migrationReconciliation (kept/appended/contentMatched); the chain is held back only for unresolvable content drift or an unreadable journal. Appends gate the run behind --yes/confirm like conflicts. --- .changeset/import-migration-reconciliation.md | 5 + .../skills/cli-reference/SKILL.md | 4 +- .../skills/init-upgrade/SKILL.md | 12 +- packages/everything-dev/src/cli.ts | 26 +- packages/everything-dev/src/cli/import.ts | 365 ++++++++++++++---- .../everything-dev/src/commands/import.ts | 8 +- packages/everything-dev/src/contract.ts | 20 +- .../import-migration-chain.test.ts | 155 ++++++++ .../integration/import.workspace.test.ts | 309 ++++++++++----- .../tests/unit/import-migrations.test.ts | 156 ++++++-- 10 files changed, 838 insertions(+), 222 deletions(-) create mode 100644 .changeset/import-migration-reconciliation.md create mode 100644 packages/everything-dev/tests/integration/import-migration-chain.test.ts diff --git a/.changeset/import-migration-reconciliation.md b/.changeset/import-migration-reconciliation.md new file mode 100644 index 000000000..b5f6b5c46 --- /dev/null +++ b/.changeset/import-migration-reconciliation.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +`bos import` now reconciles diverging migration chains instead of holding them back (NEARBuilders/everything-dev#393). Local journal entries already applied to the database are kept verbatim — never renumbered or rehashed — while colliding upstream migrations are renumbered and appended after the local tail: upstream's `0011_team-member-role` with a local tail at idx 12 becomes `0013_team-member-role` (`.sql` renamed, `meta/*_snapshot.json` renamed, `meta/_journal.json` rebuilt carrying upstream's `when`/`version`). Repeat imports converge: an upstream migration whose SQL content hash matches a locally imported one is recognized by content and not appended twice. The import report and console show the kept, appended (from→to), and content-matched sets plus what the next boot will apply versus what is already applied, and warn that renamed snapshots record upstream intermediate schema states (review `drizzle-kit generate` output against the merged chain). The chain is still held back only for unresolvable drift — content drift on an already-applied migration or an unreadable journal. Appended migrations gate the run behind `--yes`/interactive confirmation like conflicts; `ImportResult` gains a `migrationReconciliation` field (`kept`/`appended`/`contentMatched`) and `migrationCollisions` is now unresolvable-only (`tag-mismatch`). diff --git a/packages/everything-dev/skills/cli-reference/SKILL.md b/packages/everything-dev/skills/cli-reference/SKILL.md index 96b9cdaff..ca1d2db91 100644 --- a/packages/everything-dev/skills/cli-reference/SKILL.md +++ b/packages/everything-dev/skills/cli-reference/SKILL.md @@ -121,7 +121,7 @@ bos upgrade --no-sync # only upgrade packages, skip template sync ### `bos import` -Import a workspace subtree (e.g. `plugins/auth`) from an upstream GitHub repo or a local directory, without cloning. Per-file classification is snapshot-baselined; unresolvable conflicts and a non-appliable migration chain gate the run (exit 1) unless `--yes` or an interactive confirmation. +Import a workspace subtree (e.g. `plugins/auth`) from an upstream GitHub repo or a local directory, without cloning. Per-file classification is snapshot-baselined; diverging migration chains are reconciled — applied local entries are kept verbatim, colliding upstream migrations are renumbered and appended, journal rebuilt. Unresolvable conflicts (content drift on an applied migration, unreadable journal) hold the chain and gate the run (exit 1) unless `--yes` or an interactive confirmation. **Flags:** `--from ` `--yes` `--dry-run` `--no-install` `--json` @@ -131,7 +131,7 @@ bos import plugins/auth --from ./local-checkout --dry-run bos import plugins/auth --from owner/repo --yes ``` -→ [`init-upgrade`](.) skill for classification buckets, migration-chain safety, route deltas, and the `.bos/import-report.md` report. +→ [`init-upgrade`](.) skill for classification buckets, migration-chain reconciliation, route deltas, and the `.bos/import-report.md` report. ### `bos status` diff --git a/packages/everything-dev/skills/init-upgrade/SKILL.md b/packages/everything-dev/skills/init-upgrade/SKILL.md index 3cd42d7a4..34d1ee041 100644 --- a/packages/everything-dev/skills/init-upgrade/SKILL.md +++ b/packages/everything-dev/skills/init-upgrade/SKILL.md @@ -222,9 +222,15 @@ Conflicts — and a migration chain that cannot be applied verbatim (next sectio A diverged `package.json` is never a conflict when both sides parse — it merges instead. -### Migration chain safety +### Migration chain reconciliation -When both sides have `src/db/migrations/` journals and the chain cannot be applied verbatim — index reuse with a different tag, same tag hashing differently, an upstream entry below the local chain tail, or local-only journal entries — every migration-chain file in the write set is **held back**: the rest of the workspace imports normally, but `src/db/migrations/**` is untouched, because a verbatim overwrite would drop local journal entries and orphan already-applied hashes. A held chain gates the run even when no other file conflicts. Renumbering/append reconciliation is follow-up work (NEARBuilders/everything-dev#393). +When both sides have `src/db/migrations/` journals, the chain is **reconciled** instead of overwritten: + +- **Applied entries win.** Every local journal entry is presumed applied to the database and is kept verbatim — never renumbered or rehashed. +- **Colliding upstream migrations are renumbered and appended.** An upstream entry that shares an idx or lands below the local tail but is not present locally is renumbered after the local tail in upstream order: upstream `0011_team-member-role` with a local tail at idx 12 becomes `0013_team-member-role`. The `.sql` file is copied to the new name, `meta/_snapshot.json` is renamed to match, and `meta/_journal.json` is rebuilt (local entries verbatim + appended entries carrying upstream's `when`/`version`). +- **Content matching keeps repeat imports convergent.** An upstream entry whose SQL content hash matches a local entry (e.g. one imported under a renumbered tag earlier) is treated as already imported — nothing is appended twice. +- **Report.** The import report and console output show the kept set (applied), the appended set (`fromTag → toTag`, new idx), and what the next boot will apply versus what is already applied. Renamed snapshots record upstream intermediate schema states — review `drizzle-kit generate` output against the merged chain before generating new migrations. +- **Held back.** Only unresolvable drift holds the chain now: a shared tag whose SQL content differs locally (already-applied hashes would drift), or an unreadable journal. Held chain files are not written; the rest of the workspace imports normally. A held chain gates the run even when no other file conflicts. ### Route-set delta @@ -232,7 +238,7 @@ Both sides' contract surface is scanned for literal `.route({ method, path })` p ### Report and baselines -Every run — dry-run, refused, or applied — writes `.bos/import-report.md`: summary table, conflict checklist with reasons and held markers, migration collisions and chain delta, route delta, bucket lists, and next steps. Written and unchanged files are recorded to `.bos/import-snapshot.json` (and merged into `.bos/sync-snapshot.json` when one exists) so the next import detects drift instead of starting from zero; held-back files are deliberately excluded until reconciliation lands. +Every run — dry-run, refused, or applied — writes `.bos/import-report.md`: summary table, conflict checklist with reasons and held markers, the migration-chain reconciliation plan (kept/appended/held), route delta, bucket lists, and next steps. Written and unchanged files are recorded to `.bos/import-snapshot.json` (and merged into `.bos/sync-snapshot.json` when one exists) so the next import detects drift instead of starting from zero. ## bos publish diff --git a/packages/everything-dev/src/cli.ts b/packages/everything-dev/src/cli.ts index 6721437d0..f8e15fe69 100644 --- a/packages/everything-dev/src/cli.ts +++ b/packages/everything-dev/src/cli.ts @@ -794,17 +794,35 @@ async function main() { } } if (result.heldBackMigrations?.length > 0) { - console.log( - ` ${colors.yellow("Migration chain held back")} (pending reconciliation #393):`, - ); + console.log(` ${colors.yellow("Migration chain held back")}:`); for (const file of result.heldBackMigrations) console.log(` ${colors.dim(file)}`); } if (result.migrationCollisions.length > 0) { - console.log(` ${colors.yellow("Migration collisions")}:`); + console.log(` ${colors.yellow("Unresolvable migration collisions")}:`); for (const collision of result.migrationCollisions) { console.log(` ${colors.dim(collision.message)}`); } } + if (result.migrationReconciliation && result.migrationReconciliation.appended.length > 0) { + console.log( + ` ${colors.green("Migration chain reconciled")}: ${result.migrationReconciliation.kept.length} applied migration(s) kept, ${result.migrationReconciliation.appended.length} appended:`, + ); + for (const append of result.migrationReconciliation.appended) { + console.log( + ` ${colors.dim(`${append.fromTag} → ${append.toTag} (idx ${append.idx})`)}`, + ); + } + console.log( + ` ${colors.dim("Next boot applies the appended migration(s); renamed snapshots should be reviewed against `drizzle-kit generate` before generating new migrations.")}`, + ); + } else if ( + result.migrationReconciliation && + result.migrationReconciliation.contentMatched.length > 0 + ) { + console.log( + ` ${colors.dim("Migration chain:")} every upstream migration is already applied (kept: ${result.migrationReconciliation.kept.length}, matched by content: ${result.migrationReconciliation.contentMatched.length})`, + ); + } if ( result.routeDelta && (result.routeDelta.oursOnly.length > 0 || result.routeDelta.theirsOnly.length > 0) diff --git a/packages/everything-dev/src/cli/import.ts b/packages/everything-dev/src/cli/import.ts index c2ce23067..acd39c599 100644 --- a/packages/everything-dev/src/cli/import.ts +++ b/packages/everything-dev/src/cli/import.ts @@ -68,8 +68,7 @@ export interface ImportReportAnalysis { conflicts: Array<{ path: string; reason: "no-baseline" | "both-changed"; held: boolean }>; routeDelta?: ImportRouteDelta; migrationCollisions: ImportMigrationCollision[]; - localOnlyMigrations: string[]; - upstreamOnlyMigrations: string[]; + migrationReconciliation: MigrationReconciliation | null; heldBackMigrations: string[]; } @@ -79,6 +78,7 @@ export interface ImportDeps { conflictCount: number; migrationCollisionCount: number; heldCount: number; + appendedCount: number; }) => Promise; } @@ -270,7 +270,7 @@ function extractWorkspaceRoutes(sources: string[] | null): string[] | null { } export function loadMigrationEntries(root: string, workspace: string): MigrationEntry[] | null { - const journalPath = join(root, workspace, "src", "db", "migrations", "meta", "_journal.json"); + const journalPath = journalPathFor(root, workspace); if (!existsSync(journalPath)) { return null; } @@ -305,44 +305,131 @@ export function loadMigrationEntries(root: string, workspace: string): Migration return result.sort((a, b) => a.idx - b.idx); } -export function detectMigrationCollisions( +interface RawMigrationJournal { + journal: Record; + entries: Record[]; +} + +function journalPathFor(root: string, workspace: string): string { + return join(root, workspace, "src", "db", "migrations", "meta", "_journal.json"); +} + +function loadRawJournal(root: string, workspace: string): RawMigrationJournal | null { + const journalPath = journalPathFor(root, workspace); + if (!existsSync(journalPath)) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(journalPath, "utf-8")); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const entries = (parsed as { entries?: unknown }).entries; + if (!Array.isArray(entries)) { + return null; + } + return { + journal: parsed as Record, + entries: entries as Record[], + }; +} + +function buildMergedJournal( + localRoot: string, + upstreamRoot: string, + workspace: string, + appended: MigrationAppend[], +): string | null { + const localRaw = loadRawJournal(localRoot, workspace); + const upstreamRaw = loadRawJournal(upstreamRoot, workspace); + if (!localRaw || !upstreamRaw) { + return null; + } + const upstreamEntryByTag = new Map>(); + for (const entry of upstreamRaw.entries) { + if (typeof entry.tag === "string") { + upstreamEntryByTag.set(entry.tag, entry); + } + } + const maxLocalWhen = localRaw.entries.reduce( + (max, entry) => Math.max(max, typeof entry.when === "number" ? entry.when : 0), + 0, + ); + let appendedWhen = maxLocalWhen; + const appendedEntries = appended.map((append) => { + const raw = upstreamEntryByTag.get(append.fromTag); + const when = + typeof raw?.when === "number" && raw.when > appendedWhen ? raw.when : ++appendedWhen; + return { ...(raw ?? {}), idx: append.idx, tag: append.toTag, when }; + }); + const merged = { ...localRaw.journal, entries: [...localRaw.entries, ...appendedEntries] }; + return `${JSON.stringify(merged, null, 2)}\n`; +} + +export interface MigrationAppend { + idx: number; + fromTag: string; + toTag: string; +} + +export interface MigrationReconciliation { + kept: string[]; + appended: MigrationAppend[]; + contentMatched: string[]; + unresolvable: ImportMigrationCollision[]; +} + +const MIGRATION_TAG_PATTERN = /^(\d+)_(.+)$/; + +function migrationTagFor(idx: number, fromTag: string): string { + const suffix = fromTag.match(MIGRATION_TAG_PATTERN)?.[2]; + return suffix ? `${String(idx).padStart(4, "0")}_${suffix}` : fromTag; +} + +function snapshotPrefixOf(tag: string): string | null { + const match = tag.match(MIGRATION_TAG_PATTERN); + return match?.[1] ? match[1].padStart(4, "0") : null; +} + +export function reconcileMigrationChains( local: MigrationEntry[], upstream: MigrationEntry[], -): ImportMigrationCollision[] { - const collisions: ImportMigrationCollision[] = []; - const maxLocalIdx = local.reduce((max, entry) => Math.max(max, entry.idx), -1); - for (const entry of upstream) { - const atIdx = local.find((candidate) => candidate.idx === entry.idx); - if (atIdx) { - if (atIdx.tag !== entry.tag) { - collisions.push({ - kind: "idx-reuse", - idx: entry.idx, - localTag: atIdx.tag, - upstreamTag: entry.tag, - message: `idx ${entry.idx} is ${atIdx.tag} locally but ${entry.tag} upstream`, - }); - } else if (atIdx.sha && entry.sha && atIdx.sha !== entry.sha) { - collisions.push({ +): MigrationReconciliation { + const localTags = new Set(local.map((entry) => entry.tag)); + const unresolvable: ImportMigrationCollision[] = []; + const appended: MigrationAppend[] = []; + const contentMatched: string[] = []; + let nextIdx = local.reduce((max, entry) => Math.max(max, entry.idx), -1) + 1; + for (const upstreamEntry of upstream) { + if (localTags.has(upstreamEntry.tag)) { + const localEntry = local.find((candidate) => candidate.tag === upstreamEntry.tag); + if (localEntry?.sha && upstreamEntry.sha && localEntry.sha !== upstreamEntry.sha) { + unresolvable.push({ kind: "tag-mismatch", - idx: entry.idx, - localTag: atIdx.tag, - upstreamTag: entry.tag, - message: `${entry.tag} has different content locally than upstream — already-applied hashes would drift`, + idx: upstreamEntry.idx, + localTag: localEntry.tag, + upstreamTag: upstreamEntry.tag, + message: `${upstreamEntry.tag} has different content locally than upstream — already-applied hashes would drift`, }); } continue; } - if (entry.idx <= maxLocalIdx) { - collisions.push({ - kind: "out-of-order", - idx: entry.idx, - upstreamTag: entry.tag, - message: `upstream ${entry.tag} lands at idx ${entry.idx}, below the local chain tail (idx ${maxLocalIdx})`, - }); + if (upstreamEntry.sha && local.some((candidate) => candidate.sha === upstreamEntry.sha)) { + contentMatched.push(upstreamEntry.tag); + continue; } + appended.push({ + idx: nextIdx, + fromTag: upstreamEntry.tag, + toTag: migrationTagFor(nextIdx, upstreamEntry.tag), + }); + nextIdx++; } - return collisions; + return { kept: local.map((entry) => entry.tag), appended, contentMatched, unresolvable }; } function hashFile(root: string, path: string): string | null { @@ -475,7 +562,10 @@ export function buildImportReport( lines.push(`| updated (local unchanged, upstream changed) | ${analysis.updated.length} |`); lines.push(`| merged (structured package.json merge) | ${analysis.merged.length} |`); lines.push(`| conflicts (unresolvable) | ${analysis.conflicts.length} |`); - lines.push(`| migration collisions | ${analysis.migrationCollisions.length} |`); + lines.push(`| migration collisions (unresolvable) | ${analysis.migrationCollisions.length} |`); + lines.push( + `| migrations appended (renumbered from upstream) | ${analysis.migrationReconciliation?.appended.length ?? 0} |`, + ); lines.push(`| migration chain held back | ${analysis.heldBackMigrations.length} |`); lines.push(`| local-only (preserved) | ${analysis.preservedLocal.length} |`); lines.push( @@ -525,8 +615,54 @@ export function buildImportReport( lines.push(""); } + if (analysis.migrationReconciliation) { + const reconciliation = analysis.migrationReconciliation; + lines.push("## Migration chain reconciliation"); + lines.push(""); + lines.push("Applied locally — kept verbatim, never renumbered or rehashed:"); + lines.push(""); + if (reconciliation.kept.length === 0) { + lines.push("(no local journal entries)"); + lines.push(""); + } else { + for (const tag of reconciliation.kept) lines.push(`- \`${tag}\``); + lines.push(""); + } + if (reconciliation.appended.length > 0) { + lines.push("Appended — colliding upstream migrations renumbered after the local tail:"); + lines.push(""); + lines.push("| upstream tag | new tag | new idx |"); + lines.push("| --- | --- | --- |"); + for (const append of reconciliation.appended) { + lines.push(`| \`${append.fromTag}\` | \`${append.toTag}\` | ${append.idx} |`); + } + lines.push(""); + lines.push( + `Next boot will apply the ${reconciliation.appended.length} appended migration(s) above; the kept set is already applied to the database.`, + ); + lines.push(""); + } else { + lines.push( + "Nothing to append: every upstream migration is already applied (by tag or by content).", + ); + lines.push(""); + } + if (reconciliation.contentMatched.length > 0) { + lines.push("Already imported under another tag (matched by SQL content hash):"); + lines.push(""); + for (const tag of reconciliation.contentMatched) lines.push(`- \`${tag}\``); + lines.push(""); + } + if (reconciliation.appended.length > 0) { + lines.push( + "Snapshot-chain note: renamed snapshot files record upstream intermediate schema states; review `drizzle-kit generate` output against the merged chain before generating new migrations.", + ); + lines.push(""); + } + } + if (analysis.migrationCollisions.length > 0) { - lines.push("## Migration chain collisions"); + lines.push("## Migration chain collisions — unresolvable"); lines.push(""); lines.push("| kind | idx | local | upstream | detail |"); lines.push("| --- | --- | --- | --- | --- |"); @@ -536,10 +672,6 @@ export function buildImportReport( ); } lines.push(""); - lines.push( - "Migration renumbering is not performed by `bos import` yet — reconciliation lands in NEARBuilders/everything-dev#393.", - ); - lines.push(""); } if (analysis.heldBackMigrations.length > 0) { @@ -548,28 +680,11 @@ export function buildImportReport( for (const path of analysis.heldBackMigrations) lines.push(`- \`${path}\``); lines.push(""); lines.push( - "`src/db/migrations/**` was not modified: a verbatim overwrite would drop local journal entries that upstream lacks (or collide on shared indices), leaving applied hashes orphaned. The rest of the workspace imported normally; chain reconciliation lands in NEARBuilders/everything-dev#393.", + "`src/db/migrations/**` was not modified: reconciliation could not produce a safe merged chain (content drift on an already-applied migration, or an unreadable journal). Resolve the collisions above, then re-import. The rest of the workspace imported normally.", ); lines.push(""); } - if (analysis.localOnlyMigrations.length > 0 || analysis.upstreamOnlyMigrations.length > 0) { - lines.push("## Migration chain delta"); - lines.push(""); - if (analysis.localOnlyMigrations.length > 0) { - lines.push("Local-only (preserved, never renumbered):"); - lines.push(""); - for (const tag of analysis.localOnlyMigrations) lines.push(`- \`${tag}\``); - lines.push(""); - } - if (analysis.upstreamOnlyMigrations.length > 0) { - lines.push("Upstream-only:"); - lines.push(""); - for (const tag of analysis.upstreamOnlyMigrations) lines.push(`- \`${tag}\``); - lines.push(""); - } - } - if (analysis.routeDelta) { lines.push("## Contract route-set delta"); lines.push(""); @@ -702,6 +817,72 @@ export async function importWorkspace( ); })(); + const chainPrefix = `${workspace}/src/db/migrations/`; + const chainJournalPath = `${chainPrefix}meta/_journal.json`; + const localMigrations = loadMigrationEntries(projectDir, workspace); + const upstreamMigrations = loadMigrationEntries(source.root, workspace); + const localJournalExists = existsSync(join(projectDir, chainJournalPath)); + const reconciliation = + localMigrations && upstreamMigrations + ? reconcileMigrationChains(localMigrations, upstreamMigrations) + : null; + + let chainUnsafe: boolean; + if (reconciliation) { + chainUnsafe = reconciliation.unresolvable.length > 0; + } else if (localMigrations === null && localJournalExists) { + chainUnsafe = true; + } else if (localMigrations !== null && upstreamMigrations === null) { + chainUnsafe = true; + } else { + chainUnsafe = false; + } + + const migrationRemap = new Map(); + const migrationSkipped = new Set(); + if (reconciliation && !chainUnsafe) { + const appendedByFromTag = new Map( + reconciliation.appended.map((append) => [append.fromTag, append]), + ); + const contentMatchedTags = new Set(reconciliation.contentMatched); + const upstreamTagBySnapshotPrefix = new Map(); + for (const entry of upstreamMigrations ?? []) { + const prefix = snapshotPrefixOf(entry.tag); + if (prefix) upstreamTagBySnapshotPrefix.set(prefix, entry.tag); + } + for (const path of upstreamFiles) { + if (!path.startsWith(chainPrefix) || path === chainJournalPath) continue; + const relInMigrations = path.slice(chainPrefix.length); + if (relInMigrations.endsWith(".sql")) { + const tag = relInMigrations.slice(0, -".sql".length); + const append = appendedByFromTag.get(tag); + if (append) { + migrationRemap.set(path, `${chainPrefix}${append.toTag}.sql`); + } else if (contentMatchedTags.has(tag)) { + migrationSkipped.add(path); + } + } else if ( + relInMigrations.startsWith("meta/") && + relInMigrations.endsWith("_snapshot.json") + ) { + const prefix = relInMigrations.slice( + "meta/".length, + relInMigrations.length - "_snapshot.json".length, + ); + const tag = upstreamTagBySnapshotPrefix.get(prefix); + const append = tag ? appendedByFromTag.get(tag) : undefined; + if (append) { + migrationRemap.set( + path, + `${chainPrefix}meta/${String(append.idx).padStart(4, "0")}_snapshot.json`, + ); + } else if (tag && contentMatchedTags.has(tag)) { + migrationSkipped.add(path); + } + } + } + } + const added: string[] = []; const updated: string[] = []; const unchanged: string[] = []; @@ -721,6 +902,8 @@ export async function importWorkspace( preservedLocal.push(path); continue; } + if (reconciliation && path === chainJournalPath) continue; + if (migrationRemap.has(path) || migrationSkipped.has(path)) continue; const localHash = localSet.has(path) ? hashFile(projectDir, path) : null; const upstreamHash = hashFile(source.root, path); if (upstreamHash === null) { @@ -769,22 +952,8 @@ export async function importWorkspace( routeDelta = computeRouteDelta(localRoutes ?? [], upstreamRoutes ?? []); } - const localMigrations = loadMigrationEntries(projectDir, workspace); - const upstreamMigrations = loadMigrationEntries(source.root, workspace); - const migrationCollisions = - localMigrations && upstreamMigrations - ? detectMigrationCollisions(localMigrations, upstreamMigrations) - : []; - const localTags = new Set(localMigrations?.map((entry) => entry.tag) ?? []); - const upstreamTags = new Set(upstreamMigrations?.map((entry) => entry.tag) ?? []); - const localOnlyMigrations = [...localTags].filter((tag) => !upstreamTags.has(tag)).sort(); - const upstreamOnlyMigrations = [...upstreamTags].filter((tag) => !localTags.has(tag)).sort(); + const migrationCollisions = reconciliation?.unresolvable ?? []; - const chainPrefix = `${workspace}/src/db/migrations/`; - const chainUnsafe = - localMigrations !== null && - upstreamMigrations !== null && - (migrationCollisions.length > 0 || localOnlyMigrations.length > 0); const writeCandidates = new Set([ ...added, ...updated, @@ -794,6 +963,9 @@ export async function importWorkspace( const heldSet = new Set( chainUnsafe ? [...writeCandidates].filter((path) => path.startsWith(chainPrefix)) : [], ); + if (chainUnsafe && reconciliation) { + heldSet.add(chainJournalPath); + } const heldBackMigrations = [...heldSet].sort(); for (const conflict of conflicts) { conflict.held = heldSet.has(conflict.path); @@ -814,8 +986,7 @@ export async function importWorkspace( conflicts, routeDelta, migrationCollisions, - localOnlyMigrations, - upstreamOnlyMigrations, + migrationReconciliation: reconciliation, heldBackMigrations, }; @@ -833,6 +1004,7 @@ export async function importWorkspace( heldBackMigrations, routeDelta, migrationCollisions, + migrationReconciliation: reconciliation ?? undefined, }; if (options.dryRun) { @@ -843,7 +1015,8 @@ export async function importWorkspace( return { status: "dry-run", ...resultBase, reportPath }; } - const gated = conflicts.length > 0 || heldBackMigrations.length > 0; + const appendedCount = reconciliation?.appended.length ?? 0; + const gated = conflicts.length > 0 || heldBackMigrations.length > 0 || appendedCount > 0; if (gated && !options.yes) { let approved = false; if (deps?.confirm) { @@ -852,6 +1025,7 @@ export async function importWorkspace( conflictCount: conflicts.length, migrationCollisionCount: migrationCollisions.length, heldCount: heldBackMigrations.length, + appendedCount, }); } if (!approved) { @@ -878,12 +1052,36 @@ export async function importWorkspace( ); } + const reconciliationActive = reconciliation !== null && !chainUnsafe; + const journalRebuildContent = + reconciliationActive && reconciliation.appended.length > 0 + ? buildMergedJournal(projectDir, source.root, workspace, reconciliation.appended) + : null; + const journalChanged = + journalRebuildContent !== null && + journalRebuildContent !== readFileSync(join(projectDir, chainJournalPath), "utf-8"); + + const reconciliationOverwrites: Array<{ upstreamPath: string; localPath: string }> = []; + if (reconciliationActive) { + for (const [upstreamPath, localPath] of migrationRemap) { + const upstreamContent = readFileSync(join(source.root, upstreamPath), "utf-8"); + if ( + !existsSync(join(projectDir, localPath)) || + readFileSync(join(projectDir, localPath), "utf-8") !== upstreamContent + ) { + reconciliationOverwrites.push({ upstreamPath, localPath }); + } + } + } + const overwritePaths = [ ...updated.filter((path) => !isHeld(path)), ...conflicts.map((conflict) => conflict.path).filter((path) => !isHeld(path)), ...merged.filter( (path) => mergeContents.get(path) !== readFileSync(join(projectDir, path), "utf-8"), ), + ...(journalChanged ? [chainJournalPath] : []), + ...reconciliationOverwrites.map((write) => write.localPath), ]; const backupDir = backupImportFiles(projectDir, overwritePaths); @@ -910,6 +1108,16 @@ export async function importWorkspace( mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, content); } + for (const { upstreamPath, localPath } of reconciliationOverwrites) { + const dest = safeProjectPath(projectDir, localPath); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(source.root, upstreamPath), dest); + } + if (journalChanged && journalRebuildContent) { + const dest = safeProjectPath(projectDir, chainJournalPath); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, journalRebuildContent); + } const writtenPaths = [ ...added, @@ -924,6 +1132,15 @@ export async function importWorkspace( recorded[path] = hash; } } + if (journalChanged && journalRebuildContent) { + recorded[chainJournalPath] = computeSnapshotHash(journalRebuildContent); + } + for (const { localPath } of reconciliationOverwrites) { + const hash = hashFile(projectDir, localPath); + if (hash) { + recorded[localPath] = hash; + } + } for (const path of [...unchanged, ...preservedLocalEdit]) { const hash = upstreamHashByPath.get(path); if (hash) { diff --git a/packages/everything-dev/src/commands/import.ts b/packages/everything-dev/src/commands/import.ts index 800737ae3..48fe236ee 100644 --- a/packages/everything-dev/src/commands/import.ts +++ b/packages/everything-dev/src/commands/import.ts @@ -38,17 +38,21 @@ export function registerImport(builder: BosBuilder) { ? async (info: Parameters>[0]): Promise => { const collisionNote = info.migrationCollisionCount > 0 - ? ` + ${info.migrationCollisionCount} migration collision(s)` + ? ` + ${info.migrationCollisionCount} unresolvable migration collision(s)` : ""; const heldNote = info.heldCount > 0 ? `; ${info.heldCount} migration-chain file(s) will be held back` : ""; + const appendedNote = + info.appendedCount > 0 + ? `; ${info.appendedCount} upstream migration(s) will be renumbered and appended to the local chain` + : ""; const answer = await p.confirm({ message: info.conflictCount > 0 || info.migrationCollisionCount > 0 ? `${info.conflictCount} conflicting file(s)${collisionNote} in ${info.workspace} — apply upstream over conflicting files (local copies backed up)${heldNote}?` - : `Local migration entries exist that upstream lacks in ${info.workspace} — import the workspace and hold back the migration chain?`, + : `Migration chain reconciliation in ${info.workspace}: apply upstream migrations${appendedNote}${heldNote}?`, initialValue: false, }); if (p.isCancel(answer)) return false; diff --git a/packages/everything-dev/src/contract.ts b/packages/everything-dev/src/contract.ts index 6cb59e1fe..1463223a1 100644 --- a/packages/everything-dev/src/contract.ts +++ b/packages/everything-dev/src/contract.ts @@ -470,13 +470,28 @@ export const ImportRouteDeltaSchema = z.object({ }); export const ImportMigrationCollisionSchema = z.object({ - kind: z.enum(["idx-reuse", "tag-mismatch", "out-of-order"]), + kind: z.literal("tag-mismatch"), idx: z.number(), localTag: z.string().optional(), upstreamTag: z.string().optional(), message: z.string(), }); +export const ImportMigrationAppendSchema = z.object({ + idx: z.number(), + fromTag: z.string(), + toTag: z.string(), +}); + +export const ImportMigrationReconciliationSchema = z.object({ + /** local journal tags kept verbatim (already applied, never renumbered) */ + kept: z.array(z.string()), + /** upstream migrations renumbered and appended after the local tail */ + appended: z.array(ImportMigrationAppendSchema), + /** upstream migrations matched to local entries by SQL content hash */ + contentMatched: z.array(z.string()), +}); + export const ImportResultSchema = z.object({ status: z.enum(["imported", "dry-run", "conflicts", "error"]), workspace: z.string(), @@ -492,6 +507,7 @@ export const ImportResultSchema = z.object({ heldBackMigrations: z.array(z.string()).default([]), routeDelta: ImportRouteDeltaSchema.optional(), migrationCollisions: z.array(ImportMigrationCollisionSchema).default([]), + migrationReconciliation: ImportMigrationReconciliationSchema.optional(), backupDir: z.string().optional(), reportPath: z.string().optional(), error: z.string().optional(), @@ -920,6 +936,8 @@ export type ImportOptions = z.infer; export type ImportResult = z.infer; export type ImportRouteDelta = z.infer; export type ImportMigrationCollision = z.infer; +export type ImportMigrationAppend = z.infer; +export type ImportMigrationReconciliation = z.infer; export type UpgradeOptions = z.infer; export type UpgradeResult = z.infer; export type StatusResult = z.infer; diff --git a/packages/everything-dev/tests/integration/import-migration-chain.test.ts b/packages/everything-dev/tests/integration/import-migration-chain.test.ts new file mode 100644 index 000000000..aaefa6419 --- /dev/null +++ b/packages/everything-dev/tests/integration/import-migration-chain.test.ts @@ -0,0 +1,155 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { Cause, Effect, Exit } from "effect"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { importWorkspace } from "../../src/cli/import"; +import type { ImportOptions } from "../../src/contract"; +import { detectDrift, loadMigrations, type MigrationStorage, runMigrations } from "../../src/db"; + +const WS = "plugins/demo"; +const M = `${WS}/src/db/migrations`; +const JOURNAL: MigrationStorage = { + schema: "drizzle", + table: "__drizzle_migrations", + slug: "test", +} as const; + +const writeTree = (root: string, files: Record) => { + for (const [path, content] of Object.entries(files)) { + const full = join(root, path); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } +}; + +const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; + +const sql = (table: string) => `CREATE TABLE ${table} (id int);\n`; + +const SHARED_TABLES = Array.from( + { length: 11 }, + (_, idx) => `shared_${String(idx).padStart(2, "0")}`, +); + +const journalFor = (tags: string[]): string => + json({ + version: "7", + dialect: "postgresql", + entries: tags.map((tag, idx) => ({ + idx, + version: "7", + when: 1_700_000_000_000 + idx, + tag, + breakpoints: true, + })), + }); + +function makeChild(): string { + const root = mkdtempSync(join(tmpdir(), "import-chain-child-")); + const files: Record = { + [`${M}/meta/_journal.json`]: journalFor([ + ...SHARED_TABLES.map((table) => `00${SHARED_TABLES.indexOf(table)}_shared`), + "0011_default-community-teams", + "0012_onboarding-redemption-new-member", + ]), + }; + SHARED_TABLES.forEach((table, idx) => { + files[`${M}/00${idx}_shared.sql`] = sql(table); + }); + files[`${M}/0011_default-community-teams.sql`] = sql("default_community_teams"); + files[`${M}/0012_onboarding-redemption-new-member.sql`] = sql("onboarding_redemption_new_member"); + writeTree(root, files); + return root; +} + +function makeUpstream(): string { + const root = mkdtempSync(join(tmpdir(), "import-chain-upstream-")); + const files: Record = { + [`${M}/meta/_journal.json`]: journalFor([ + ...SHARED_TABLES.map((table) => `00${SHARED_TABLES.indexOf(table)}_shared`), + "0011_team-member-role", + "0012_gifted_nicolaos", + "0013_sleepy_komodo", + ]), + }; + SHARED_TABLES.forEach((table, idx) => { + files[`${M}/00${idx}_shared.sql`] = sql(table); + }); + files[`${M}/0011_team-member-role.sql`] = sql("team_member_role"); + files[`${M}/0012_gifted_nicolaos.sql`] = sql("gifted_nicolaos"); + files[`${M}/0013_sleepy_komodo.sql`] = sql("sleepy_komodo"); + writeTree(root, files); + return root; +} + +function makeDb() { + return drizzle(new PGlite()); +} + +async function runMigration(effect: Effect.Effect): Promise { + const exit = await Effect.runPromiseExit(effect); + if (Exit.isSuccess(exit)) return exit.value; + throw Cause.squash(exit.cause); +} + +describe("bos import — migration chain applies cleanly after reconciliation", () => { + let upstream: string; + let child: string; + + beforeAll(() => { + upstream = makeUpstream(); + child = makeChild(); + }); + + afterAll(() => { + rmSync(upstream, { recursive: true, force: true }); + rmSync(child, { recursive: true, force: true }); + }); + + it("applies exactly the appended migrations and reports healthy drift", async () => { + const db = makeDb(); + + const before = await runMigration( + loadMigrations({ fromDir: join(child, WS, "src", "db") }).pipe( + Effect.flatMap((loaded) => runMigrations(db, loaded.migrations, { journal: JOURNAL })), + ), + ); + expect(before.applied).toBe(13); + + const options: ImportOptions = { + workspace: WS, + from: upstream, + yes: true, + dryRun: false, + noInstall: true, + json: false, + }; + const result = await importWorkspace(child, options); + expect(result.status).toBe("imported"); + expect(result.migrationReconciliation?.kept).toHaveLength(13); + expect(result.migrationReconciliation?.appended).toEqual([ + { idx: 13, fromTag: "0011_team-member-role", toTag: "0013_team-member-role" }, + { idx: 14, fromTag: "0012_gifted_nicolaos", toTag: "0014_gifted_nicolaos" }, + { idx: 15, fromTag: "0013_sleepy_komodo", toTag: "0015_sleepy_komodo" }, + ]); + expect(result.heldBackMigrations).toEqual([]); + + const after = await runMigration( + loadMigrations({ fromDir: join(child, WS, "src", "db") }).pipe( + Effect.flatMap((loaded) => runMigrations(db, loaded.migrations, { journal: JOURNAL })), + ), + ); + expect(after.applied).toBe(3); + expect(after.total).toBe(16); + + const drift = await runMigration( + loadMigrations({ fromDir: join(child, WS, "src", "db") }).pipe( + Effect.flatMap((loaded) => detectDrift(db, loaded.migrations, JOURNAL)), + ), + ); + expect(drift.status).toBe("healthy"); + }); +}); diff --git a/packages/everything-dev/tests/integration/import.workspace.test.ts b/packages/everything-dev/tests/integration/import.workspace.test.ts index 7d4c0500a..7b92783a5 100644 --- a/packages/everything-dev/tests/integration/import.workspace.test.ts +++ b/packages/everything-dev/tests/integration/import.workspace.test.ts @@ -6,6 +6,7 @@ import { IMPORT_REPORT_PATH, importWorkspace } from "../../src/cli/import"; import type { ImportOptions } from "../../src/contract"; const WS = "plugins/demo"; +const M = `${WS}/src/db/migrations`; const writeTree = (root: string, files: Record) => { for (const [path, content] of Object.entries(files)) { @@ -25,13 +26,25 @@ ${routes.join("\n")} }; `; -const journal = (entries: Array<{ idx: number; tag: string }>) => +const journalEntry = (idx: number, tag: string, when: number) => ({ + idx, + version: "7", + when, + tag, + breakpoints: true, +}); + +const sql = (table: string) => `CREATE TABLE ${table} (id int);\n`; + +const journalFor = (tags: Array<[number, string]>): string => json({ - version: 7, + version: "7", dialect: "postgresql", - entries: entries.map((entry) => ({ ...entry, version: "7" })), + entries: tags.map(([idx, tag]) => journalEntry(idx, tag, 1_700_000_000_000 + idx)), }); +const snapshotFile = (id: string): string => json({ version: "7", dialect: "postgresql", id }); + const upstreamContractRoot = `import { oc } from "@orpc/contract"; import { upstreamRoutes } from "./contract/upstream.contract"; export const contract = oc.router({ @@ -60,12 +73,26 @@ function makeUpstream(): string { [`${WS}/src/upstream-only.ts`]: "export const upstreamOnly = true;\n", [`${WS}/src/contract.ts`]: upstreamContractRoot, [`${WS}/src/contract/upstream.contract.ts`]: upstreamContractSplit, - [`${WS}/src/db/migrations/meta/_journal.json`]: journal([ - { idx: 0, tag: "0000_base" }, - { idx: 1, tag: "0001_upstream_diff" }, + [`${M}/meta/_journal.json`]: journalFor([ + [0, "0000_base"], + [1, "0001_shared_one"], + [2, "0002_shared_two"], + [3, "0003_upstream-new"], + [4, "0004_upstream-more"], + [5, "0005_upstream-tail"], ]), - [`${WS}/src/db/migrations/0000_base.sql`]: "CREATE TABLE base (id int);\n", - [`${WS}/src/db/migrations/0001_upstream_diff.sql`]: "CREATE TABLE upstream_diff (id int);\n", + [`${M}/0000_base.sql`]: sql("base"), + [`${M}/0001_shared_one.sql`]: sql("shared_one"), + [`${M}/0002_shared_two.sql`]: sql("shared_two"), + [`${M}/0003_upstream-new.sql`]: sql("upstream_new"), + [`${M}/0004_upstream-more.sql`]: sql("upstream_more"), + [`${M}/0005_upstream-tail.sql`]: sql("upstream_tail"), + [`${M}/meta/0000_snapshot.json`]: snapshotFile("shared-0"), + [`${M}/meta/0001_snapshot.json`]: snapshotFile("shared-1"), + [`${M}/meta/0002_snapshot.json`]: snapshotFile("shared-2"), + [`${M}/meta/0003_snapshot.json`]: snapshotFile("upstream-3"), + [`${M}/meta/0004_snapshot.json`]: snapshotFile("upstream-4"), + [`${M}/meta/0005_snapshot.json`]: snapshotFile("upstream-5"), }); return root; } @@ -84,17 +111,28 @@ function makeChild(): string { [`${WS}/src/contract.ts`]: contractSource([ ' local: oc.route({ method: "POST", path: "/v1/demo/local" }).output(z.void()),', ]), - [`${WS}/src/db/migrations/meta/_journal.json`]: journal([ - { idx: 0, tag: "0000_base" }, - { idx: 1, tag: "0001_local_extra" }, + [`${M}/meta/_journal.json`]: journalFor([ + [0, "0000_base"], + [1, "0001_shared_one"], + [2, "0002_shared_two"], + [3, "0003_local-custom"], + [4, "0004_local-extra"], ]), - [`${WS}/src/db/migrations/0000_base.sql`]: "CREATE TABLE base (id int);\n", - [`${WS}/src/db/migrations/0001_local_extra.sql`]: "CREATE TABLE local_extra (id int);\n", + [`${M}/0000_base.sql`]: sql("base"), + [`${M}/0001_shared_one.sql`]: sql("shared_one"), + [`${M}/0002_shared_two.sql`]: sql("shared_two"), + [`${M}/0003_local-custom.sql`]: sql("local_custom"), + [`${M}/0004_local-extra.sql`]: sql("local_extra"), + [`${M}/meta/0000_snapshot.json`]: snapshotFile("shared-0"), + [`${M}/meta/0001_snapshot.json`]: snapshotFile("shared-1"), + [`${M}/meta/0002_snapshot.json`]: snapshotFile("shared-2"), + [`${M}/meta/0003_snapshot.json`]: snapshotFile("local-3"), + [`${M}/meta/0004_snapshot.json`]: snapshotFile("local-4"), }); return root; } -describe("bos import — workspace import from a local source", () => { +describe("bos import — migration-chain reconciliation", () => { let upstream: string; let child: string; @@ -121,160 +159,241 @@ describe("bos import — workspace import from a local source", () => { const readChild = (rel: string): string => readFileSync(join(child, rel), "utf-8"); const childExists = (rel: string): boolean => existsSync(join(child, rel)); - it("dry-run classifies every bucket, reports deltas, and writes nothing", async () => { + it("dry-run reports kept/appended/renumbered sets and writes nothing", async () => { const result = await importWorkspace(child, options({ dryRun: true })); expect(result.status).toBe("dry-run"); - expect(result.workspace).toBe(WS); - expect(result.ref).toBe("local"); - expect(result.conflicts).toEqual([ - `${WS}/src/contract.ts`, - `${WS}/src/db/migrations/meta/_journal.json`, - `${WS}/src/index.ts`, - ]); + expect(result.conflicts).toEqual([`${WS}/src/contract.ts`, `${WS}/src/index.ts`]); expect(result.added).toEqual([ `${WS}/src/contract/upstream.contract.ts`, - `${WS}/src/db/migrations/0001_upstream_diff.sql`, `${WS}/src/upstream-only.ts`, ]); expect(result.preservedLocal).toEqual([ - `${WS}/src/db/migrations/0001_local_extra.sql`, + `${M}/0003_local-custom.sql`, + `${M}/0004_local-extra.sql`, `${WS}/src/local-only.ts`, ]); expect(result.unchanged).toEqual([ - `${WS}/src/db/migrations/0000_base.sql`, + `${M}/0000_base.sql`, + `${M}/0001_shared_one.sql`, + `${M}/0002_shared_two.sql`, + `${M}/meta/0000_snapshot.json`, + `${M}/meta/0001_snapshot.json`, + `${M}/meta/0002_snapshot.json`, `${WS}/src/shared.ts`, ]); - expect(result.merged).toEqual([`${WS}/package.json`]); - expect(result.heldBackMigrations).toEqual([ - `${WS}/src/db/migrations/0001_upstream_diff.sql`, - `${WS}/src/db/migrations/meta/_journal.json`, - ]); - expect(result.migrationCollisions).toEqual([ - { - kind: "idx-reuse", - idx: 1, - localTag: "0001_local_extra", - upstreamTag: "0001_upstream_diff", - message: "idx 1 is 0001_local_extra locally but 0001_upstream_diff upstream", - }, - ]); - expect(result.routeDelta).toEqual({ - oursOnly: ["POST /v1/demo/local"], - theirsOnly: ["PUT /v1/demo/upstream"], + expect(result.heldBackMigrations).toEqual([]); + expect(result.migrationCollisions).toEqual([]); + expect(result.migrationReconciliation).toEqual({ + kept: [ + "0000_base", + "0001_shared_one", + "0002_shared_two", + "0003_local-custom", + "0004_local-extra", + ], + appended: [ + { idx: 5, fromTag: "0003_upstream-new", toTag: "0005_upstream-new" }, + { idx: 6, fromTag: "0004_upstream-more", toTag: "0006_upstream-more" }, + { idx: 7, fromTag: "0005_upstream-tail", toTag: "0007_upstream-tail" }, + ], + contentMatched: [], + unresolvable: [], }); expect(childExists(`${WS}/src/upstream-only.ts`)).toBe(false); expect(readChild(`${WS}/src/index.ts`)).toContain("x = 1"); + expect(readChild(`${M}/meta/_journal.json`)).toContain("0003_local-custom"); const report = readChild(IMPORT_REPORT_PATH); expect(report).toContain("Dry run — no files written"); - expect(report).toContain("Migration chain held back"); - expect(report).toContain("idx-reuse"); - expect(report).toContain("POST /v1/demo/local"); + expect(report).toContain("## Migration chain reconciliation"); + expect(report).toContain("`0003_upstream-new` | `0005_upstream-new` | 5"); + expect(report).toContain("Next boot will apply the 3 appended migration(s)"); + expect(report).toContain("Snapshot-chain note"); }); - it("refuses without --yes while conflicts or a held chain exist", async () => { + it("refuses without --yes while conflicts or appended migrations exist", async () => { const result = await importWorkspace(child, options()); expect(result.status).toBe("conflicts"); expect(childExists(`${WS}/src/upstream-only.ts`)).toBe(false); - expect(readChild(`${WS}/src/index.ts`)).toContain("x = 1"); + expect(readChild(`${M}/meta/_journal.json`)).toContain("0003_local-custom"); expect(readChild(IMPORT_REPORT_PATH)).toContain( "Refused — unresolvable conflicts exist, nothing was written", ); }); - it("confirms through deps.confirm when provided", async () => { - const seen: number[] = []; + it("confirms through deps.confirm with the appended count", async () => { + const seen: Array<{ conflictCount: number; appendedCount: number; heldCount: number }> = []; const refused = await importWorkspace(child, options(), { confirm: async (info) => { - seen.push(info.conflictCount, info.heldCount); + seen.push({ + conflictCount: info.conflictCount, + appendedCount: info.appendedCount, + heldCount: info.heldCount, + }); return false; }, }); expect(refused.status).toBe("conflicts"); - expect(seen).toEqual([3, 2]); + expect(seen).toEqual([{ conflictCount: 2, appendedCount: 3, heldCount: 0 }]); const approved = await importWorkspace(child, options(), { confirm: async () => true }); expect(approved.status).toBe("imported"); expect(childExists(`${WS}/src/upstream-only.ts`)).toBe(true); }); - it("applies with --yes, merges package.json, and holds the migration chain", async () => { + it("applies with --yes: renumbered files, rebuilt journal, preserved applied entries", async () => { rmSync(child, { recursive: true, force: true }); child = makeChild(); const result = await importWorkspace(child, options({ yes: true })); expect(result.status).toBe("imported"); - expect(readChild(`${WS}/src/index.ts`)).toContain("x = 2"); - expect(readChild(`${WS}/src/contract.ts`)).toContain('from "./contract/upstream.contract"'); - expect(readChild(`${WS}/src/contract.ts`)).not.toContain("/v1/demo/local"); - expect(readChild(`${WS}/src/contract/upstream.contract.ts`)).toContain("/v1/demo/upstream"); - const pkg = JSON.parse(readChild(`${WS}/package.json`)) as { - version: string; - dependencies: Record; - }; - expect(pkg.version).toBe("1.0.0"); - expect(pkg.dependencies["left-pad"]).toBe("^2.0.0"); - expect(pkg.dependencies["right-pad"]).toBe("^1.0.0"); - expect(pkg.dependencies["only-local"]).toBe("^1.0.0"); + expect(childExists(`${M}/0003_upstream-new.sql`)).toBe(false); + expect(childExists(`${M}/0005_upstream-new.sql`)).toBe(true); + expect(childExists(`${M}/0006_upstream-more.sql`)).toBe(true); + expect(childExists(`${M}/0007_upstream-tail.sql`)).toBe(true); + expect(readChild(`${M}/0005_upstream-new.sql`)).toBe(sql("upstream_new")); + expect(childExists(`${M}/meta/0005_snapshot.json`)).toBe(true); + expect(readChild(`${M}/meta/0005_snapshot.json`)).toContain("upstream-3"); + expect(childExists(`${M}/meta/0007_snapshot.json`)).toBe(true); + expect(readChild(`${M}/meta/0007_snapshot.json`)).toContain("upstream-5"); + + expect(readChild(`${M}/0003_local-custom.sql`)).toBe(sql("local_custom")); + expect(readChild(`${M}/meta/0003_snapshot.json`)).toContain("local-3"); - expect(childExists(`${WS}/src/local-only.ts`)).toBe(true); - expect(childExists(`${WS}/src/db/migrations/0001_upstream_diff.sql`)).toBe(false); - expect(childExists(`${WS}/src/db/migrations/0001_local_extra.sql`)).toBe(true); - const appliedJournal = JSON.parse(readChild(`${WS}/src/db/migrations/meta/_journal.json`)) as { - entries: Array<{ tag: string }>; + const rebuilt = JSON.parse(readChild(`${M}/meta/_journal.json`)) as { + version: string; + dialect: string; + entries: Array<{ idx: number; tag: string; when: number; breakpoints: boolean }>; }; - expect(appliedJournal.entries.map((entry) => entry.tag)).toEqual([ - "0000_base", - "0001_local_extra", + expect(rebuilt.version).toBe("7"); + expect(rebuilt.dialect).toBe("postgresql"); + expect(rebuilt.entries).toEqual([ + journalEntry(0, "0000_base", 1_700_000_000_000), + journalEntry(1, "0001_shared_one", 1_700_000_000_001), + journalEntry(2, "0002_shared_two", 1_700_000_000_002), + journalEntry(3, "0003_local-custom", 1_700_000_000_003), + journalEntry(4, "0004_local-extra", 1_700_000_000_004), + journalEntry(5, "0005_upstream-new", 1_700_000_000_005), + journalEntry(6, "0006_upstream-more", 1_700_000_000_006), + journalEntry(7, "0007_upstream-tail", 1_700_000_000_007), ]); expect(result.backupDir).toBeTypeOf("string"); - const backupIndex = readFileSync( - join(result.backupDir as string, `${WS}/src/index.ts`), + const backupJournal = readFileSync( + join(result.backupDir as string, `${M}/meta/_journal.json`), "utf-8", ); - expect(backupIndex).toContain("x = 1"); + expect(backupJournal).toContain("0003_local-custom"); + expect(JSON.parse(backupJournal)).toEqual({ + version: "7", + dialect: "postgresql", + entries: [ + journalEntry(0, "0000_base", 1_700_000_000_000), + journalEntry(1, "0001_shared_one", 1_700_000_000_001), + journalEntry(2, "0002_shared_two", 1_700_000_000_002), + journalEntry(3, "0003_local-custom", 1_700_000_000_003), + journalEntry(4, "0004_local-extra", 1_700_000_000_004), + ], + }); + + const pkg = JSON.parse(readChild(`${WS}/package.json`)) as { + version: string; + dependencies: Record; + }; + expect(pkg.version).toBe("1.0.0"); + expect(pkg.dependencies["left-pad"]).toBe("^2.0.0"); + expect(pkg.dependencies["only-local"]).toBe("^1.0.0"); const snapshot = JSON.parse(readChild(".bos/import-snapshot.json")) as { workspaces: Record }>; }; const files = snapshot.workspaces[WS]?.files ?? {}; - expect(files[`${WS}/src/index.ts`]).toBeTypeOf("string"); - expect(files[`${WS}/package.json`]).toBeTypeOf("string"); - expect(files[`${WS}/src/shared.ts`]).toBeTypeOf("string"); - expect(files[`${WS}/src/db/migrations/meta/_journal.json`]).toBeUndefined(); - expect(files[`${WS}/src/db/migrations/0001_upstream_diff.sql`]).toBeUndefined(); - expect(files[`${WS}/src/local-only.ts`]).toBeUndefined(); + expect(files[`${M}/meta/_journal.json`]).toBeTypeOf("string"); + expect(files[`${M}/0005_upstream-new.sql`]).toBeTypeOf("string"); + expect(files[`${M}/0007_upstream-tail.sql`]).toBeTypeOf("string"); + expect(files[`${M}/0003_upstream-new.sql`]).toBeUndefined(); + expect(files[`${M}/0005_upstream-tail.sql`]).toBeUndefined(); const report = readChild(IMPORT_REPORT_PATH); expect(report).toContain("Applied — upstream written over shared files"); - expect(report).toContain("Migration chain held back"); + expect(report).toContain("## Migration chain reconciliation"); }); - it("converges on a second run except for the held journal", async () => { + it("converges fully on a second run — appended migrations match by content", async () => { const result = await importWorkspace(child, options({ dryRun: true })); expect(result.status).toBe("dry-run"); - expect(result.conflicts).toEqual([`${WS}/src/db/migrations/meta/_journal.json`]); - expect(result.unchanged).toEqual([ - `${WS}/src/contract.ts`, - `${WS}/src/contract/upstream.contract.ts`, - `${WS}/src/db/migrations/0000_base.sql`, - `${WS}/src/index.ts`, - `${WS}/src/shared.ts`, - `${WS}/src/upstream-only.ts`, + expect(result.conflicts).toEqual([]); + expect(result.added).toEqual([]); + expect(result.heldBackMigrations).toEqual([]); + expect(result.migrationCollisions).toEqual([]); + expect(result.migrationReconciliation).toEqual({ + kept: [ + "0000_base", + "0001_shared_one", + "0002_shared_two", + "0003_local-custom", + "0004_local-extra", + "0005_upstream-new", + "0006_upstream-more", + "0007_upstream-tail", + ], + appended: [], + contentMatched: ["0003_upstream-new", "0004_upstream-more", "0005_upstream-tail"], + unresolvable: [], + }); + expect(childExists(`${M}/0003_upstream-new.sql`)).toBe(false); + expect(readChild(`${M}/meta/_journal.json`)).toContain("0007_upstream-tail"); + }); + + it("holds the chain when a shared migration hashes differently", async () => { + rmSync(child, { recursive: true, force: true }); + child = makeChild(); + const drifted = makeUpstream(); + writeTree(drifted, { [`${M}/0001_shared_one.sql`]: sql("shared_one_drifted") }); + + const result = await importWorkspace(child, options({ from: drifted, dryRun: true })); + + expect(result.migrationCollisions).toEqual([ + { + kind: "tag-mismatch", + idx: 1, + localTag: "0001_shared_one", + upstreamTag: "0001_shared_one", + message: + "0001_shared_one has different content locally than upstream — already-applied hashes would drift", + }, ]); - expect(result.merged).toEqual([`${WS}/package.json`]); - expect(result.routeDelta).toEqual({ oursOnly: [], theirsOnly: [] }); expect(result.heldBackMigrations).toEqual([ - `${WS}/src/db/migrations/0001_upstream_diff.sql`, - `${WS}/src/db/migrations/meta/_journal.json`, + `${M}/0001_shared_one.sql`, + `${M}/0003_upstream-new.sql`, + `${M}/0004_upstream-more.sql`, + `${M}/0005_upstream-tail.sql`, + `${M}/meta/0003_snapshot.json`, + `${M}/meta/0004_snapshot.json`, + `${M}/meta/0005_snapshot.json`, + `${M}/meta/_journal.json`, ]); + expect(result.migrationReconciliation?.appended).toEqual([ + { idx: 5, fromTag: "0003_upstream-new", toTag: "0005_upstream-new" }, + { idx: 6, fromTag: "0004_upstream-more", toTag: "0006_upstream-more" }, + { idx: 7, fromTag: "0005_upstream-tail", toTag: "0007_upstream-tail" }, + ]); + + const report = readChild(IMPORT_REPORT_PATH); + expect(report).toContain("## Migration chain held back"); + expect(report).toContain("reconciliation could not produce a safe merged chain"); + + const applied = await importWorkspace(child, options({ from: drifted, yes: true })); + expect(applied.status).toBe("imported"); + expect(readChild(`${M}/0001_shared_one.sql`)).toBe(sql("shared_one")); + expect(readChild(`${M}/meta/_journal.json`)).toContain("0003_local-custom"); + expect(childExists(`${M}/0005_upstream-new.sql`)).toBe(false); }); it("errors loudly when the workspace is missing from the source", async () => { diff --git a/packages/everything-dev/tests/unit/import-migrations.test.ts b/packages/everything-dev/tests/unit/import-migrations.test.ts index 93ea6c5a2..b4321bf96 100644 --- a/packages/everything-dev/tests/unit/import-migrations.test.ts +++ b/packages/everything-dev/tests/unit/import-migrations.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { detectMigrationCollisions, loadMigrationEntries } from "../../src/cli/import"; +import { loadMigrationEntries, reconcileMigrationChains } from "../../src/cli/import"; const WS = "plugins/demo"; @@ -63,62 +63,136 @@ describe("bos import — loadMigrationEntries", () => { }); }); -describe("bos import — detectMigrationCollisions", () => { - const local = [ - { idx: 0, tag: "0000_base", sha: "aaa" }, - { idx: 1, tag: "0001_local_extra", sha: "bbb" }, - { idx: 2, tag: "0002_more", sha: "ccc" }, - ]; +describe("bos import — reconcileMigrationChains", () => { + const entry = (idx: number, tag: string, sha: string | null) => ({ idx, tag, sha }); - it("flags idx reuse when the same idx carries a different tag", () => { - const collisions = detectMigrationCollisions(local, [ - { idx: 1, tag: "0001_upstream_diff", sha: "zzz" }, + it("renumbers colliding upstream migrations after the local tail (issue #393 scenario)", () => { + const shared = Array.from({ length: 11 }, (_, idx) => + entry(idx, `000${idx}_shared`, `s${idx}`), + ); + const local = [ + ...shared, + entry(11, "0011_default-community-teams", "l11"), + entry(12, "0012_onboarding-redemption-new-member", "l12"), + ]; + const upstream = [ + ...shared, + entry(11, "0011_team-member-role", "u11"), + entry(12, "0012_gifted_nicolaos", "u12"), + entry(13, "0013_sleepy_komodo", "u13"), + ]; + + const plan = reconcileMigrationChains(local, upstream); + + expect(plan.kept).toEqual(local.map((item) => item.tag)); + expect(plan.unresolvable).toEqual([]); + expect(plan.appended).toEqual([ + { idx: 13, fromTag: "0011_team-member-role", toTag: "0013_team-member-role" }, + { idx: 14, fromTag: "0012_gifted_nicolaos", toTag: "0014_gifted_nicolaos" }, + { idx: 15, fromTag: "0013_sleepy_komodo", toTag: "0015_sleepy_komodo" }, ]); - expect(collisions).toEqual([ + }); + + it("treats identical chains as a no-op", () => { + const chain = [entry(0, "0000_base", "a"), entry(1, "0001_tail", "b")]; + const plan = reconcileMigrationChains(chain, chain); + expect(plan.kept).toEqual(["0000_base", "0001_tail"]); + expect(plan.appended).toEqual([]); + expect(plan.unresolvable).toEqual([]); + }); + + it("keeps local-only entries without appending anything when upstream adds nothing new", () => { + const plan = reconcileMigrationChains( + [entry(0, "0000_base", "a"), entry(1, "0001_local_extra", "b")], + [entry(0, "0000_base", "a")], + ); + expect(plan.kept).toEqual(["0000_base", "0001_local_extra"]); + expect(plan.appended).toEqual([]); + expect(plan.unresolvable).toEqual([]); + }); + + it("keeps an upstream tag that moved to a different idx when content is identical", () => { + const plan = reconcileMigrationChains( + [entry(0, "0000_base", "a"), entry(1, "0001_extra", "b")], + [entry(0, "0000_base", "a"), entry(3, "0001_extra", "b"), entry(4, "0004_new", "c")], + ); + expect(plan.appended).toEqual([{ idx: 2, fromTag: "0004_new", toTag: "0002_new" }]); + expect(plan.unresolvable).toEqual([]); + }); + + it("matches previously renumbered migrations by content hash on repeat imports", () => { + const plan = reconcileMigrationChains( + [ + entry(0, "0000_base", "a"), + entry(1, "0013_team-member-role", "u11"), + entry(2, "0014_gifted_nicolaos", "u12"), + ], + [ + entry(0, "0000_base", "a"), + entry(1, "0011_team-member-role", "u11"), + entry(2, "0012_gifted_nicolaos", "u12"), + ], + ); + expect(plan.appended).toEqual([]); + expect(plan.unresolvable).toEqual([]); + expect(plan.kept).toEqual(["0000_base", "0013_team-member-role", "0014_gifted_nicolaos"]); + }); + + it("flags content drift on a shared tag as unresolvable", () => { + const plan = reconcileMigrationChains( + [entry(0, "0000_base", "a"), entry(1, "0001_same", "local-hash")], + [ + entry(0, "0000_base", "a"), + entry(1, "0001_same", "upstream-hash"), + entry(2, "0002_new", "c"), + ], + ); + expect(plan.unresolvable).toEqual([ { - kind: "idx-reuse", + kind: "tag-mismatch", idx: 1, - localTag: "0001_local_extra", - upstreamTag: "0001_upstream_diff", - message: "idx 1 is 0001_local_extra locally but 0001_upstream_diff upstream", + localTag: "0001_same", + upstreamTag: "0001_same", + message: + "0001_same has different content locally than upstream — already-applied hashes would drift", }, ]); + expect(plan.appended).toEqual([{ idx: 2, fromTag: "0002_new", toTag: "0002_new" }]); }); - it("flags content drift when an identical tag+idx hashes differently", () => { - const collisions = detectMigrationCollisions( - [{ idx: 0, tag: "0000_base", sha: "aaa" }], - [{ idx: 0, tag: "0000_base", sha: "different" }], + it("flags content drift even when the shared tag sits at a different idx", () => { + const plan = reconcileMigrationChains( + [entry(0, "0000_base", "a"), entry(1, "0001_same", "local-hash")], + [entry(0, "0000_base", "a"), entry(5, "0001_same", "upstream-hash")], ); - expect(collisions.map((collision) => collision.kind)).toEqual(["tag-mismatch"]); + expect(plan.unresolvable).toHaveLength(1); + expect(plan.unresolvable[0].kind).toBe("tag-mismatch"); + expect(plan.appended).toEqual([]); }); - it("skips the content check when a side has no hash", () => { - const collisions = detectMigrationCollisions( - [{ idx: 0, tag: "0000_base", sha: null }], - [{ idx: 0, tag: "0000_base", sha: "different" }], + it("skips content comparison when a side has no hash", () => { + const plan = reconcileMigrationChains( + [entry(0, "0000_base", "a"), { idx: 1, tag: "0001_same", sha: null }], + [entry(0, "0000_base", "a"), entry(1, "0001_same", "upstream-hash")], ); - expect(collisions).toEqual([]); + expect(plan.unresolvable).toEqual([]); + expect(plan.appended).toEqual([]); }); - it("accepts matching shared entries and appended upstream entries", () => { - const collisions = detectMigrationCollisions( - [{ idx: 0, tag: "0000_base", sha: "aaa" }], - [ - { idx: 0, tag: "0000_base", sha: "aaa" }, - { idx: 1, tag: "0001_upstream", sha: "ddd" }, - ], + it("keeps tags without a numeric prefix unchanged when appended", () => { + const plan = reconcileMigrationChains( + [entry(0, "0000_base", "a")], + [entry(0, "0000_base", "a"), entry(1, "weird_migration", "b")], ); - expect(collisions).toEqual([]); + expect(plan.appended).toEqual([ + { idx: 1, fromTag: "weird_migration", toTag: "weird_migration" }, + ]); }); - it("flags an upstream entry landing below the local chain tail", () => { - const gapped = [ - { idx: 0, tag: "0000_base", sha: "a" }, - { idx: 2, tag: "0002_tail", sha: "c" }, - ]; - const collisions = detectMigrationCollisions(gapped, [{ idx: 1, tag: "0001_new", sha: "b" }]); - expect(collisions.map((collision) => collision.kind)).toEqual(["out-of-order"]); - expect(collisions[0].message).toContain("below the local chain tail"); + it("handles an empty upstream chain", () => { + const plan = reconcileMigrationChains([entry(0, "0000_base", "a")], []); + expect(plan.kept).toEqual(["0000_base"]); + expect(plan.appended).toEqual([]); + expect(plan.unresolvable).toEqual([]); }); }); From 80e6cd834c288cace1c004e1fb23a5e908100656 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:29:06 -0500 Subject: [PATCH 06/20] docs: boot-chain contract, apps/ index, AGENTS.md apps-tier taxonomy (#399-#401) --- AGENTS.md | 11 +++++ apps/README.md | 14 ++++++ apps/dev/README.md | 4 +- docs/architecture/boot-chain.md | 78 +++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 apps/README.md create mode 100644 docs/architecture/boot-chain.md diff --git a/AGENTS.md b/AGENTS.md index f5018b202..055fa64a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,6 +398,7 @@ Run your own host instance on your own NEAR account while inheriting the base pl ### Making Changes - **Host Changes**: Edit `host/src/` when changing runtime resolution, auth wiring, SSR, proxying, or plugin mounting +- **App Changes**: Edit `apps//` when changing a standalone service — apps are not plugins: no `bos.app.ts` registration, not composed via `pluginsClient`, own deploy lifecycle. See `apps/README.md` for the index - **UI Changes**: Edit `ui/src/` files → hot reload automatically - **API Changes**: Edit `api/src/` files → hot reload automatically - **CLI/Scaffolding Changes**: Edit `packages/everything-dev/` when changing init/dev/publish flows or child-project scaffolding @@ -435,6 +436,16 @@ compose with the `.use()` builder without an explicit context generic (the auth plugin's `plugins/auth/src/handlers/admin-users.ts` passes `createAuthMiddleware(builder)` to make it compose; the proposals plugin's local middleware is the alternative pattern). +### Apps tier (vs plugins vs packages) + +Directory taxonomy — where a deployable unit belongs: + +- **`packages/`** — runtime libraries and clients: imported by other code, never deployed alone +- **`plugins/`** — composed plugins registered in `bos.app.ts`, loaded via Module Federation and composed in-process via `pluginsClient` +- **`apps/`** — standalone services with their own deploy lifecycle, not registered in `bos.app.ts` (e.g. `apps/upload` → Railway service). Index and roadmap: `apps/README.md` (per ADR 0027, more apps absorb current plugins/UI over time) + +Every app runs the universal image and boots through the provider-generic chain documented in `docs/architecture/boot-chain.md` — identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV → CDN bundles → Module Federation. Read that doc before touching the boot path (`resolver.ts`, `orchestrator.ts`, `fastkv.ts`, `host/src/program.ts`); it is the contract those files implement. A hosting provider is anything that can `docker run` the image; Railway is the current instance only, and the `deployImageToRailway` deploy-train tail is optional (gated on `RAILWAY_TOKEN`, skipped loudly without it). + ### Plugin Architecture Business logic is organized into independent plugins loaded via Module Federation. A plugin entry in the authored config can be **remote-only** (no `development: local:…` key) — the host/API consume it via `pluginsClient` and HTTP, and types resolve from the deployed manifest (see "Generated types" below). Plugin source does not need to live in this repo. diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 000000000..904f88f60 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,14 @@ +# Apps + +Independently deployed services live here — not registered in `bos.app.ts`, each with its own deploy lifecycle. Composed plugins registered in `bos.app.ts` live in `plugins/`. Runtime libraries and clients live in `packages/`. + +| App | Purpose | Deployment | Status | +|---|---|---|---| +| [`upload/`](./upload) | Bundle write/control surface — receives deploy-train uploads, stores to R2, serves the inspection API (ADR 0027 phase 0) | Railway service `upload` (any Docker host — [boot-chain contract](../docs/architecture/boot-chain.md)) | live | +| [`_/`](./_) | Host app — api and ui mount through it (`/$path.$accountId/$domain`) | universal image | planned | +| [`auth/`](./auth) | Auth app — admin dashboard, org/team management, API-key minting, login (absorbs `plugins/auth` per ADR 0027) | universal image | planned | +| [`build/`](./build) | Reference starter app — bare ui+api, ships skills via TanStack Intents + mattpocock/skills (what `bos init` scaffolds from, per ADR 0027) | — | planned | +| [`dev/`](./dev) | Development app — per-project sidecar + dashboard (ADR 0027) | — | planned | +| `www/` | Production everything.dev app — `ui/` + `api/` move here (ADR 0027 §1) | universal image | planned — dir not yet created | + +Every app runs on the universal image and boots through the same [boot-chain contract](../docs/architecture/boot-chain.md): identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV config → CDN bundles → Module Federation runtime. A hosting provider is anything that can run that Docker image — Railway is the current instance, not the architecture. diff --git a/apps/dev/README.md b/apps/dev/README.md index 292efbd7e..824c761ed 100644 --- a/apps/dev/README.md +++ b/apps/dev/README.md @@ -1,3 +1,3 @@ -# build.everything.dev +# dev.everything.dev -Starter app with ui and api \ No newline at end of file +Starter app with ui and api — per-project sidecar + dashboard (ADR 0027) \ No newline at end of file diff --git a/docs/architecture/boot-chain.md b/docs/architecture/boot-chain.md new file mode 100644 index 000000000..c083a32ed --- /dev/null +++ b/docs/architecture/boot-chain.md @@ -0,0 +1,78 @@ +# The boot chain — the universal image's provider contract + +The production platform boots through one chain: **Docker image → identity → FastKV config → CDN bundles → Module Federation runtime**. The chain contains zero hosting-provider references. Any host that can run a Docker container boots the platform identically; Railway is one instance of that contract, not part of the architecture. + +This document is the contract. Changes to the boot path (anything referenced below) must keep it true or update this file in the same PR. + +## The chain + +``` +docker run + │ CMD: node ./node_modules/everything-dev/dist/cli.mjs start --no-interactive --port ${PORT:-3000} + │ (Dockerfile:159 — the image carries no config and no staged dists) + ▼ +identity resolution + │ packages/everything-dev/src/start-config-source.ts:12 + │ precedence: --config-path → BOS_ACCOUNT/BOS_GATEWAY → config found on disk + ▼ +FastKV config fetch + │ walkExtendsChain (packages/everything-dev/src/resolution/session.ts:469) + │ GET https://kv.{test,main}.fastnear.com/v0/latest///apps///bos.config.json + │ (packages/everything-dev/src/fastkv.ts:17, :40) + │ policy: 10s timeout, 3 retries, capped exponential backoff with jitter; + │ 5xx/network retried, 4xx not (packages/everything-dev/src/http-client.ts:38) + │ failure is loud: StartRemoteConfigMissing / StartFetchFailed → exit 1 + ▼ +CDN bundle fetch + │ installGlobalBundleFetch patches globalThis.fetch — every boot-time byte + │ (manifest discovery, remoteEntry, SSR composition) crosses one seam + │ (packages/everything-dev/src/resolver.ts:313; installed in cli.ts:233 and host/server.ts:38) + │ policy: 30s per-attempt deadline, 4 retries, capped exponential (resolver.ts:215) + │ no implicit cache: BOS_BUNDLE_CACHE_DIR is an operator opt-in only; + │ a CDN outage fails loud rather than serving stale bytes (resolver.ts:393) + ▼ +Module Federation host boot (spawnRemoteHost, packages/everything-dev/src/orchestrator.ts:221) + │ 1. fetch host mf-manifest.json from the CDN base URL — 10s (orchestrator.ts:103) + │ 2. registerRemote + loadRemoteModule("host/Server") — 60s (orchestrator.ts:104) + │ 3. runServer({ config, port, env }); await ready — 120s (orchestrator.ts:105) + │ overall boot deadline 180s (orchestrator.ts:106) + ▼ +host program (host/src/program.ts:297 runServer, :56 createStartServer) + │ boot SSR composition before /health; failed composition in production + │ exits 1 so the deploy rolls back + ▼ +two-phase plugin composition (host/src/services/plugins.ts:598) + │ plugins load in-process in dependency-DAG order building pluginsClient; + │ the API entry receives the finished map (no HTTP roundtrips); + │ failed plugins degrade instead of crashing the host + ▼ +request handling + per-request tenant resolution: hostname → TenantBinding via + GET /api/tenants/bindings (host/src/services/binding-resolver.ts:57, + 30s cache, stale-serve on failure) → tenant config walk, must extend the + active base runtime; overrides touch only ui and plugins..ui with + SRI verification; allowSsr gates tenant SSR + (host/src/services/tenant-runtime.ts:388) +``` + +## The hosting-provider contract + +A hosting provider is anything that can: + +1. **Run the image** — `node` runtime, one process (`CMD` above); the start stack loads host, api, and plugins in-process through Module Federation, so the runtime needs only node. +2. **Provide identity** — `BOS_ACCOUNT` and `BOS_GATEWAY` env vars (or a mounted config: `--config-path`, or `bos.config.json` at `/app`). +3. **Provide secrets via env** — database URLs, `BETTER_AUTH_SECRET`, storage credentials, etc. +4. **Map one TCP port** — `PORT` (default 3000). +5. **Probe `/health`** — HTTP healthcheck reflecting plugin/SSR/self-probe status. + +That is the whole surface. Fly Machines, GCP Cloud Run, AWS ECS/Fargate, Kubernetes, a VPS with `docker run`, and Railway all satisfy it unchanged. + +## What is deliberately not part of the contract + +- **The deploy train's last leg.** `deployImageToRailway` (`packages/everything-dev/src/image-deploy.ts`) is optional and gated on `RAILWAY_TOKEN` + `ci.railway.service`; skipped loudly otherwise. Image push (GHCR), bundle upload (R2), and FastKV publish are provider-independent. +- **Databases.** Postgres arrives via `*_DATABASE_URL` env; the image provisions nothing. +- **Storage cache tiers.** `BOS_BUNDLE_DIR` (staged namespace) and `BOS_BUNDLE_CACHE_DIR` (stale-if-error cache) are operator opt-ins via `docker run -e`; the published image sets neither (CDN-only, fail-loud). + +## Future work: infrastructure as Effects + +The platform's Cloudflare resources (R2 bucket, `cdn.everything.dev` custom domain, zone/DNS) are managed by hand today. The planned follow-up is an [Alchemy](https://alchemy.run) stack — Infrastructure as Effects, Apache-2.0, Effect-native (the same Effect v4 this repo runs) — under `infra/`, following Alchemy's file layout (`alchemy.run.ts` composition root, one file per Resource, stages). See issue #402. Railway has no Alchemy construct; it stays CLI-managed. From 0ace6b849b2946e1c04568c616cc8888defe8182 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:33:12 -0500 Subject: [PATCH 07/20] Revert "docs: boot-chain contract, apps/ index, AGENTS.md apps-tier taxonomy (#399-#401)" This reverts commit 8e74126c5d2559cadb129c736f5de453b6b7b565. --- AGENTS.md | 11 ----- apps/README.md | 14 ------ apps/dev/README.md | 4 +- docs/architecture/boot-chain.md | 78 --------------------------------- 4 files changed, 2 insertions(+), 105 deletions(-) delete mode 100644 apps/README.md delete mode 100644 docs/architecture/boot-chain.md diff --git a/AGENTS.md b/AGENTS.md index 055fa64a4..f5018b202 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,7 +398,6 @@ Run your own host instance on your own NEAR account while inheriting the base pl ### Making Changes - **Host Changes**: Edit `host/src/` when changing runtime resolution, auth wiring, SSR, proxying, or plugin mounting -- **App Changes**: Edit `apps//` when changing a standalone service — apps are not plugins: no `bos.app.ts` registration, not composed via `pluginsClient`, own deploy lifecycle. See `apps/README.md` for the index - **UI Changes**: Edit `ui/src/` files → hot reload automatically - **API Changes**: Edit `api/src/` files → hot reload automatically - **CLI/Scaffolding Changes**: Edit `packages/everything-dev/` when changing init/dev/publish flows or child-project scaffolding @@ -436,16 +435,6 @@ compose with the `.use()` builder without an explicit context generic (the auth plugin's `plugins/auth/src/handlers/admin-users.ts` passes `createAuthMiddleware(builder)` to make it compose; the proposals plugin's local middleware is the alternative pattern). -### Apps tier (vs plugins vs packages) - -Directory taxonomy — where a deployable unit belongs: - -- **`packages/`** — runtime libraries and clients: imported by other code, never deployed alone -- **`plugins/`** — composed plugins registered in `bos.app.ts`, loaded via Module Federation and composed in-process via `pluginsClient` -- **`apps/`** — standalone services with their own deploy lifecycle, not registered in `bos.app.ts` (e.g. `apps/upload` → Railway service). Index and roadmap: `apps/README.md` (per ADR 0027, more apps absorb current plugins/UI over time) - -Every app runs the universal image and boots through the provider-generic chain documented in `docs/architecture/boot-chain.md` — identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV → CDN bundles → Module Federation. Read that doc before touching the boot path (`resolver.ts`, `orchestrator.ts`, `fastkv.ts`, `host/src/program.ts`); it is the contract those files implement. A hosting provider is anything that can `docker run` the image; Railway is the current instance only, and the `deployImageToRailway` deploy-train tail is optional (gated on `RAILWAY_TOKEN`, skipped loudly without it). - ### Plugin Architecture Business logic is organized into independent plugins loaded via Module Federation. A plugin entry in the authored config can be **remote-only** (no `development: local:…` key) — the host/API consume it via `pluginsClient` and HTTP, and types resolve from the deployed manifest (see "Generated types" below). Plugin source does not need to live in this repo. diff --git a/apps/README.md b/apps/README.md deleted file mode 100644 index 904f88f60..000000000 --- a/apps/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Apps - -Independently deployed services live here — not registered in `bos.app.ts`, each with its own deploy lifecycle. Composed plugins registered in `bos.app.ts` live in `plugins/`. Runtime libraries and clients live in `packages/`. - -| App | Purpose | Deployment | Status | -|---|---|---|---| -| [`upload/`](./upload) | Bundle write/control surface — receives deploy-train uploads, stores to R2, serves the inspection API (ADR 0027 phase 0) | Railway service `upload` (any Docker host — [boot-chain contract](../docs/architecture/boot-chain.md)) | live | -| [`_/`](./_) | Host app — api and ui mount through it (`/$path.$accountId/$domain`) | universal image | planned | -| [`auth/`](./auth) | Auth app — admin dashboard, org/team management, API-key minting, login (absorbs `plugins/auth` per ADR 0027) | universal image | planned | -| [`build/`](./build) | Reference starter app — bare ui+api, ships skills via TanStack Intents + mattpocock/skills (what `bos init` scaffolds from, per ADR 0027) | — | planned | -| [`dev/`](./dev) | Development app — per-project sidecar + dashboard (ADR 0027) | — | planned | -| `www/` | Production everything.dev app — `ui/` + `api/` move here (ADR 0027 §1) | universal image | planned — dir not yet created | - -Every app runs on the universal image and boots through the same [boot-chain contract](../docs/architecture/boot-chain.md): identity (`BOS_ACCOUNT`/`BOS_GATEWAY`) → FastKV config → CDN bundles → Module Federation runtime. A hosting provider is anything that can run that Docker image — Railway is the current instance, not the architecture. diff --git a/apps/dev/README.md b/apps/dev/README.md index 824c761ed..292efbd7e 100644 --- a/apps/dev/README.md +++ b/apps/dev/README.md @@ -1,3 +1,3 @@ -# dev.everything.dev +# build.everything.dev -Starter app with ui and api — per-project sidecar + dashboard (ADR 0027) \ No newline at end of file +Starter app with ui and api \ No newline at end of file diff --git a/docs/architecture/boot-chain.md b/docs/architecture/boot-chain.md deleted file mode 100644 index c083a32ed..000000000 --- a/docs/architecture/boot-chain.md +++ /dev/null @@ -1,78 +0,0 @@ -# The boot chain — the universal image's provider contract - -The production platform boots through one chain: **Docker image → identity → FastKV config → CDN bundles → Module Federation runtime**. The chain contains zero hosting-provider references. Any host that can run a Docker container boots the platform identically; Railway is one instance of that contract, not part of the architecture. - -This document is the contract. Changes to the boot path (anything referenced below) must keep it true or update this file in the same PR. - -## The chain - -``` -docker run - │ CMD: node ./node_modules/everything-dev/dist/cli.mjs start --no-interactive --port ${PORT:-3000} - │ (Dockerfile:159 — the image carries no config and no staged dists) - ▼ -identity resolution - │ packages/everything-dev/src/start-config-source.ts:12 - │ precedence: --config-path → BOS_ACCOUNT/BOS_GATEWAY → config found on disk - ▼ -FastKV config fetch - │ walkExtendsChain (packages/everything-dev/src/resolution/session.ts:469) - │ GET https://kv.{test,main}.fastnear.com/v0/latest///apps///bos.config.json - │ (packages/everything-dev/src/fastkv.ts:17, :40) - │ policy: 10s timeout, 3 retries, capped exponential backoff with jitter; - │ 5xx/network retried, 4xx not (packages/everything-dev/src/http-client.ts:38) - │ failure is loud: StartRemoteConfigMissing / StartFetchFailed → exit 1 - ▼ -CDN bundle fetch - │ installGlobalBundleFetch patches globalThis.fetch — every boot-time byte - │ (manifest discovery, remoteEntry, SSR composition) crosses one seam - │ (packages/everything-dev/src/resolver.ts:313; installed in cli.ts:233 and host/server.ts:38) - │ policy: 30s per-attempt deadline, 4 retries, capped exponential (resolver.ts:215) - │ no implicit cache: BOS_BUNDLE_CACHE_DIR is an operator opt-in only; - │ a CDN outage fails loud rather than serving stale bytes (resolver.ts:393) - ▼ -Module Federation host boot (spawnRemoteHost, packages/everything-dev/src/orchestrator.ts:221) - │ 1. fetch host mf-manifest.json from the CDN base URL — 10s (orchestrator.ts:103) - │ 2. registerRemote + loadRemoteModule("host/Server") — 60s (orchestrator.ts:104) - │ 3. runServer({ config, port, env }); await ready — 120s (orchestrator.ts:105) - │ overall boot deadline 180s (orchestrator.ts:106) - ▼ -host program (host/src/program.ts:297 runServer, :56 createStartServer) - │ boot SSR composition before /health; failed composition in production - │ exits 1 so the deploy rolls back - ▼ -two-phase plugin composition (host/src/services/plugins.ts:598) - │ plugins load in-process in dependency-DAG order building pluginsClient; - │ the API entry receives the finished map (no HTTP roundtrips); - │ failed plugins degrade instead of crashing the host - ▼ -request handling - per-request tenant resolution: hostname → TenantBinding via - GET /api/tenants/bindings (host/src/services/binding-resolver.ts:57, - 30s cache, stale-serve on failure) → tenant config walk, must extend the - active base runtime; overrides touch only ui and plugins..ui with - SRI verification; allowSsr gates tenant SSR - (host/src/services/tenant-runtime.ts:388) -``` - -## The hosting-provider contract - -A hosting provider is anything that can: - -1. **Run the image** — `node` runtime, one process (`CMD` above); the start stack loads host, api, and plugins in-process through Module Federation, so the runtime needs only node. -2. **Provide identity** — `BOS_ACCOUNT` and `BOS_GATEWAY` env vars (or a mounted config: `--config-path`, or `bos.config.json` at `/app`). -3. **Provide secrets via env** — database URLs, `BETTER_AUTH_SECRET`, storage credentials, etc. -4. **Map one TCP port** — `PORT` (default 3000). -5. **Probe `/health`** — HTTP healthcheck reflecting plugin/SSR/self-probe status. - -That is the whole surface. Fly Machines, GCP Cloud Run, AWS ECS/Fargate, Kubernetes, a VPS with `docker run`, and Railway all satisfy it unchanged. - -## What is deliberately not part of the contract - -- **The deploy train's last leg.** `deployImageToRailway` (`packages/everything-dev/src/image-deploy.ts`) is optional and gated on `RAILWAY_TOKEN` + `ci.railway.service`; skipped loudly otherwise. Image push (GHCR), bundle upload (R2), and FastKV publish are provider-independent. -- **Databases.** Postgres arrives via `*_DATABASE_URL` env; the image provisions nothing. -- **Storage cache tiers.** `BOS_BUNDLE_DIR` (staged namespace) and `BOS_BUNDLE_CACHE_DIR` (stale-if-error cache) are operator opt-ins via `docker run -e`; the published image sets neither (CDN-only, fail-loud). - -## Future work: infrastructure as Effects - -The platform's Cloudflare resources (R2 bucket, `cdn.everything.dev` custom domain, zone/DNS) are managed by hand today. The planned follow-up is an [Alchemy](https://alchemy.run) stack — Infrastructure as Effects, Apache-2.0, Effect-native (the same Effect v4 this repo runs) — under `infra/`, following Alchemy's file layout (`alchemy.run.ts` composition root, one file per Resource, stages). See issue #402. Railway has no Alchemy construct; it stays CLI-managed. From eb06832e85699567c9a3a0eeb4fb4e678acc5ebe Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 01:55:32 -0500 Subject: [PATCH 08/20] feat(auth): agentConnect plugin for the NEAR Intents Agent API Give every signed-in user an owner-controlled agent account on NEAR Intents: the owner signs the rules once (nep413/nep366 via the connected wallet, webauthn via passkey), agents hold revocable ngt_ grant tokens that only act inside the owner-signed policy. - better-near-auth: server plugin agentConnect (dormant without a naa_ key) wrapping @near-intents-agent-api/sdk behind /agent/* endpoints with session-agent-intent ownership checks, persisted idempotency keys, and encrypted-at-rest grant tokens; client plugin exposes client.agent.* with byte-for-byte intent signing and a one-call signAndSubmitIntent; SDK wire types re-exported, not re-declared - plugins/auth: register agentConnect; drizzle tables agent_account, owner_intent, agent_grant (+ generated migration) - ui/auth: register agentConnectClient; bos.app.ts declares the NEAR_INTENTS_AGENT_API_KEY secret --- .changeset/agent-connect-plugin.md | 5 + bos.app.ts | 1 + packages/better-near-auth/package.json | 4 + packages/better-near-auth/src/agent-api.ts | 38 + .../src/agent-connect-client.ts | 200 ++ .../src/agent-connect.test.ts | 292 +++ .../better-near-auth/src/agent-connect.ts | 684 ++++++ packages/better-near-auth/src/agent-schema.ts | 136 ++ .../better-near-auth/src/auth-callbacks.ts | 4 + packages/better-near-auth/src/client.ts | 21 +- packages/better-near-auth/src/index.ts | 31 + packages/better-near-auth/src/utils.ts | 10 +- .../better-near-auth/src/wallet-signer.ts | 35 + packages/everything-dev/src/ui/auth.ts | 7 +- plugins/auth/src/auth-config.ts | 5 + plugins/auth/src/auth-instance.ts | 24 +- plugins/auth/src/config-schemas.ts | 7 + plugins/auth/src/config.ts | 7 + .../src/db/migrations/0014_agent-connect.sql | 49 + .../src/db/migrations/meta/0011_snapshot.json | 1993 ----------------- .../auth/src/db/migrations/meta/_journal.json | 18 +- plugins/auth/src/db/schema.ts | 67 + pnpm-lock.yaml | 16 + 23 files changed, 1635 insertions(+), 2019 deletions(-) create mode 100644 .changeset/agent-connect-plugin.md create mode 100644 packages/better-near-auth/src/agent-api.ts create mode 100644 packages/better-near-auth/src/agent-connect-client.ts create mode 100644 packages/better-near-auth/src/agent-connect.test.ts create mode 100644 packages/better-near-auth/src/agent-connect.ts create mode 100644 packages/better-near-auth/src/agent-schema.ts create mode 100644 packages/better-near-auth/src/auth-callbacks.ts create mode 100644 packages/better-near-auth/src/wallet-signer.ts create mode 100644 plugins/auth/src/db/migrations/0014_agent-connect.sql delete mode 100644 plugins/auth/src/db/migrations/meta/0011_snapshot.json diff --git a/.changeset/agent-connect-plugin.md b/.changeset/agent-connect-plugin.md new file mode 100644 index 000000000..c28129602 --- /dev/null +++ b/.changeset/agent-connect-plugin.md @@ -0,0 +1,5 @@ +--- +"better-near-auth": minor +--- + +Add an `agentConnect` plugin for the NEAR Intents Agent API (https://api.agentsonintents.com): give every signed-in user an owner-controlled agent account on NEAR Intents — the owner signs the rules once with their wallet or passkey, and agents hold revocable `ngt_` grant tokens that only act inside the owner-signed policy. The server plugin (`agentConnect`) wraps `@near-intents-agent-api/sdk` behind a small endpoint surface — `agent/create`, `agent/intents/submit`, `agent/status`, `agent/list`, `agent/grants/issue|revoke|list` — enforcing session↔agent↔intent ownership on every route, persisting idempotency keys before sends, and storing grant tokens encrypted at rest (HKDF→AES-256-GCM, never sent to a browser). The client plugin (`agentConnectClient`, surface `client.agent.*`) signs generated intents byte-for-byte with the connected wallet — `nep413` via signMessage, `nep366` via signDelegateActions, `webauthn` via the passkey authenticator — and submits them in one `signAndSubmitIntent` call. Server types (`Policy`, `OwnerWallet`, `SignedData`, `StatusResponse`, `GrantView`, …) are re-exported from the SDK rather than re-declared. Register the plugin unconditionally; it stays dormant until a `naa_` partner API key is configured (secret `NEAR_INTENTS_AGENT_API_KEY`, optional `agentApi.baseUrl`/`timeoutMs` auth variables). diff --git a/bos.app.ts b/bos.app.ts index f950a0cdc..6e0b5c20a 100644 --- a/bos.app.ts +++ b/bos.app.ts @@ -34,6 +34,7 @@ export default App({ "GITHUB_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET", "FASTNEAR_API_KEY", + "NEAR_INTENTS_AGENT_API_KEY", "TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER", diff --git a/packages/better-near-auth/package.json b/packages/better-near-auth/package.json index 87f0a5433..6c0cab4aa 100644 --- a/packages/better-near-auth/package.json +++ b/packages/better-near-auth/package.json @@ -50,6 +50,8 @@ "authentication", "blockchain", "web3", + "agent-connect", + "near-intents", "plugin", "tanstack-intent" ], @@ -79,10 +81,12 @@ } }, "dependencies": { + "@near-intents-agent-api/sdk": "^0.4.1", "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0", "@noble/post-quantum": "^0.6.1", "@scure/base": "^2.2.0", + "@simplewebauthn/browser": "^13.3.0", "nanostores": "^1.1.0", "near-kit": "^0.20.2", "zod": "^4.4.3" diff --git a/packages/better-near-auth/src/agent-api.ts b/packages/better-near-auth/src/agent-api.ts new file mode 100644 index 000000000..cb82877fc --- /dev/null +++ b/packages/better-near-auth/src/agent-api.ts @@ -0,0 +1,38 @@ +import type { AgentApiOptions } from "@near-intents-agent-api/sdk"; +import { AgentApi } from "@near-intents-agent-api/sdk"; + +export type { + AgentApiOptions, + AgentView, + BudgetView, + DeletionPreview, + ErrorDocument, + GenerateIntentResponse, + GenerateIntentResponseOf, + GrantCredential, + GrantView, + IdempotentOptions, + IdempotentResult, + Intent, + IntentPreview, + IntentType, + OwnerWallet, + Policy, + PolicyView, + SignedData, + StatusResponse, + StatusResponseOf, + StatusType, +} from "@near-intents-agent-api/sdk"; +export { + AgentApi, + AgentApiError, + AgentApiRequestError, + createGrantCredential, + createIdempotencyKey, + grantCommitment, +} from "@near-intents-agent-api/sdk"; + +export function createAgentApiClient(options: AgentApiOptions): AgentApi { + return new AgentApi(options); +} diff --git a/packages/better-near-auth/src/agent-connect-client.ts b/packages/better-near-auth/src/agent-connect-client.ts new file mode 100644 index 000000000..95aa0a9fa --- /dev/null +++ b/packages/better-near-auth/src/agent-connect-client.ts @@ -0,0 +1,200 @@ +import type { + BetterFetch, + BetterFetchOption, + BetterFetchResponse, + ClientStore, +} from "better-auth/client"; +import type { + AgentView, + GenerateIntentResponse, + GenerateIntentResponseOf, + GrantView, + SignedData, + StatusResponse, +} from "./agent-api.js"; +import type { + AgentStatusRequestT, + CreateAgentRequestT, + IssueAgentGrantRequestT, + ListAgentGrantsRequestT, + RevokeAgentGrantRequestT, + SubmitAgentIntentRequestT, +} from "./agent-connect.js"; +import type { AuthCallbacks } from "./auth-callbacks.js"; +import { getWalletSigner } from "./wallet-signer.js"; + +export interface AgentClientActions { + agent: { + create: ( + params: CreateAgentRequestT, + fetchOptions?: BetterFetchOption, + ) => Promise>>; + submitIntent: ( + params: SubmitAgentIntentRequestT, + fetchOptions?: BetterFetchOption, + ) => Promise>; + signIntent: (generated: GenerateIntentResponse) => Promise; + signAndSubmitIntent: ( + generated: GenerateIntentResponse, + callbacks?: AuthCallbacks, + ) => Promise>; + status: ( + params: AgentStatusRequestT, + fetchOptions?: BetterFetchOption, + ) => Promise>; + listAgents: ( + fetchOptions?: BetterFetchOption, + ) => Promise>; + issueGrant: ( + params: IssueAgentGrantRequestT, + fetchOptions?: BetterFetchOption, + ) => Promise>; + revokeGrant: ( + params: RevokeAgentGrantRequestT, + fetchOptions?: BetterFetchOption, + ) => Promise>; + listGrants: ( + params: ListAgentGrantsRequestT, + fetchOptions?: BetterFetchOption, + ) => Promise>; + }; +} + +async function signIntentWithWallet(generated: GenerateIntentResponse): Promise { + const { standard, payload } = generated.intent; + + if (standard === "nep413") { + const signer = getWalletSigner(); + if (!signer) { + throw new Error("Connect your NEAR wallet first to sign this intent"); + } + const nonce = Uint8Array.from(atob(payload.nonce), (c) => c.charCodeAt(0)); + const signedMessage = await signer.wallet.signMessage({ + message: payload.message, + recipient: payload.recipient, + nonce, + network: signer.network, + }); + return { + standard, + payload, + public_key: signedMessage.publicKey, + signature: signedMessage.signature, + }; + } + + if (standard === "nep366") { + const signer = getWalletSigner(); + if (!signer) { + throw new Error("Connect your NEAR wallet first to sign this intent"); + } + const result = await signer.wallet.signDelegateActions({ delegateActions: [payload] }); + const signedDelegate = result.signedDelegateActions[0]; + if (!signedDelegate) { + throw new Error("Wallet returned no signed delegate action"); + } + return { standard, payload, signed_delegate: signedDelegate }; + } + + if (standard === "webauthn") { + const { startAuthentication } = await import("@simplewebauthn/browser"); + const credential = await startAuthentication({ + optionsJSON: payload as Parameters[0]["optionsJSON"], + }); + return { standard, payload, credential } as SignedData; + } + + throw new Error(`Unsupported intent signing standard: ${standard}`); +} + +export const agentConnectClient = () => { + const plugin = { + id: "agentConnect" as const, + $InferServerPlugin: {}, + + getActions: ( + $fetch: BetterFetch, + $store: ClientStore, + _options: unknown, + ): AgentClientActions => { + void $store; + + return { + agent: { + create: async (params, fetchOptions?) => { + return await $fetch("/agent/create", { + method: "POST", + body: params, + ...fetchOptions, + }); + }, + submitIntent: async (params, fetchOptions?) => { + return await $fetch("/agent/intents/submit", { + method: "POST", + body: params, + ...fetchOptions, + }); + }, + signIntent: async (generated) => { + return await signIntentWithWallet(generated); + }, + signAndSubmitIntent: async (generated, callbacks?) => { + try { + const signedData = await signIntentWithWallet(generated); + const response = await $fetch("/agent/intents/submit", { + method: "POST", + body: { + type: generated.type, + correlationId: generated.correlation_id, + signedData, + }, + }); + if (response.error) { + throw new Error(response.error.message || "Failed to submit signed intent"); + } + callbacks?.onSuccess?.(); + return response; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + callbacks?.onError?.(err); + throw err; + } + }, + status: async (params, fetchOptions?) => { + return await $fetch("/agent/status", { + method: "POST", + body: params, + ...fetchOptions, + }); + }, + listAgents: async (fetchOptions?) => { + return await $fetch("/agent/list", { method: "GET", ...fetchOptions }); + }, + issueGrant: async (params, fetchOptions?) => { + return await $fetch("/agent/grants/issue", { + method: "POST", + body: params, + ...fetchOptions, + }); + }, + revokeGrant: async (params, fetchOptions?) => { + return await $fetch("/agent/grants/revoke", { + method: "POST", + body: params, + ...fetchOptions, + }); + }, + listGrants: async (params, fetchOptions?) => { + return await $fetch("/agent/grants", { + method: "POST", + body: params, + ...fetchOptions, + }); + }, + }, + }; + }, + }; + + return plugin; +}; diff --git a/packages/better-near-auth/src/agent-connect.test.ts b/packages/better-near-auth/src/agent-connect.test.ts new file mode 100644 index 000000000..946060584 --- /dev/null +++ b/packages/better-near-auth/src/agent-connect.test.ts @@ -0,0 +1,292 @@ +import { createHash } from "node:crypto"; +import { hex } from "@scure/base"; +import { getTestInstance } from "better-auth/test"; +import { describe, expect, it, vi } from "vitest"; +import { createGrantCredential, grantCommitment } from "./agent-api.js"; +import { agentConnect } from "./agent-connect.js"; +import { siwn } from "./index.js"; + +const MOCK_ACCOUNT_ID = "test.near"; +const MOCK_RECIPIENT = "example.near"; +const MOCK_PUBLIC_KEY = "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD"; +const AGENT_API_BASE = "http://localhost:9999"; + +vi.mock("near-kit", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Near: vi.fn(function NearMock() { + return {}; + }), + verifyNep413Signature: vi.fn(() => Promise.resolve(true)), + }; +}); + +function makeUniqueNonce(): Uint8Array { + const nonce = new Uint8Array(32); + crypto.getRandomValues(nonce); + return nonce; +} + +const mockSignedMessage = { + accountId: MOCK_ACCOUNT_ID, + publicKey: MOCK_PUBLIC_KEY, + signature: "mock-signature-base64", +}; + +function makeVerifyBody() { + const nonceBytes = makeUniqueNonce(); + const nonceHex = hex.encode(nonceBytes); + return { + signedMessage: mockSignedMessage, + message: `Sign in to ${MOCK_RECIPIENT}`, + recipient: MOCK_RECIPIENT, + nonce: nonceHex, + accountId: MOCK_ACCOUNT_ID, + }; +} + +const TEST_POLICY = { + frozen: false, + actions: ["swap", "transfer", "withdraw"], + confidential: false, + owner_approval: false, + assets: "any", + limits: {}, + max_actions_per_hour: null, + destinations: { mode: "only", list: [] }, + budget: { daily_usd: "10", weekly_usd: null, monthly_usd: null }, + timelock_ms: 0, +}; + +function makeAgentApiFetch() { + const calls: { url: string; init?: RequestInit }[] = []; + const handler = async ( + url: string | URL, + init?: RequestInit & { body?: string }, + ): Promise => { + calls.push({ url: String(url), init }); + const path = new URL(String(url)).pathname; + if (path.endsWith("/v1/generate-intent")) { + const body = JSON.parse(init?.body ?? "{}") as { + type: string; + agent_id?: string; + }; + const agentId = body.agent_id ?? "ag_test1"; + return Response.json({ + correlation_id: `intent_${calls.length}`, + type: body.type, + agent_id: agentId, + status: "PENDING_SIGNATURE", + expires_at: new Date(Date.now() + 600_000).toISOString(), + signer: { type: "near", account_id: MOCK_ACCOUNT_ID, public_key: MOCK_PUBLIC_KEY }, + intent: { + standard: "nep413", + payload: { + message: "create agent", + recipient: "api.agentsonintents.com", + nonce: "dGVzdA==", + }, + }, + preview: { summary: "Create your agent account" }, + }); + } + if (path.endsWith("/v1/submit-intent")) { + const body = JSON.parse(init?.body ?? "{}") as { type: string }; + const details = + body.type === "grant_issue" + ? { + grant: { + grant_id: "64hexgrantid", + agent_id: "ag_test1", + wallet_id: "w", + label: "l", + issued_at: "", + expires_at: "", + revoked_at: null, + revoked_reason: null, + owner_epoch: 0, + owner_message: {}, + }, + } + : {}; + return Response.json({ + correlation_id: "intent_1", + agent_id: "ag_test1", + status: "SUCCESS", + failure_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + dispatch_committed_at: null, + grant: null, + type: body.type, + details, + }); + } + if (path.endsWith("/v1/status")) { + return Response.json({ + correlation_id: "intent_1", + agent_id: "ag_test1", + status: "SUCCESS", + failure_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + dispatch_committed_at: null, + grant: null, + type: "agent_create", + details: { agent_id: "ag_test1", revision: 1, policy_hash: "hash", transaction_hash: null }, + }); + } + return Response.json( + { errors: [{ status: "404", code: "not_found", title: "Not found" }] }, + { status: 404 }, + ); + }; + const fetchFn = vi.fn(handler) as unknown as typeof fetch; + return { fetchFn, calls }; +} + +async function setup() { + const { fetchFn } = makeAgentApiFetch(); + return getTestInstance( + { + plugins: [ + siwn({ + recipient: MOCK_RECIPIENT, + validateLimitedAccessKey: async () => true, + }), + agentConnect({ + apiKey: `naa_${"a".repeat(43)}`, + baseUrl: AGENT_API_BASE, + fetch: fetchFn, + }), + ], + }, + { + clientOptions: { plugins: [] }, + disableTestUser: true, + }, + ); +} + +async function verifyWithCookie(customFetchImpl: (r: string, o?: unknown) => Promise) { + const res = await customFetchImpl("http://localhost/api/auth/near/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(makeVerifyBody()), + }); + expect(res.status).toBe(200); + const cookie = res.headers.get("set-cookie") || ""; + expect(cookie).not.toBe(""); + return cookie; +} + +describe("agent connect grant credentials", () => { + it("issues ngt_ tokens whose commitment is the sha256 of the token", () => { + const { token, commitment } = createGrantCredential(); + expect(token).toMatch(/^ngt_[A-Za-z0-9_-]{43}$/); + expect(commitment).toBe(createHash("sha256").update(token).digest("hex")); + expect(commitment).toBe(grantCommitment(token)); + }); +}); + +describe("agent connect routes", () => { + vi.setConfig({ testTimeout: 30_000 }); + + it("requires a session", async () => { + const { customFetchImpl } = await setup(); + const res = await customFetchImpl("http://localhost/api/auth/agent/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), + }); + expect(res.status).toBe(401); + }); + + it("returns the generated intent without leaking the api key", async () => { + const { customFetchImpl } = await setup(); + const cookie = await verifyWithCookie(customFetchImpl); + + const res = await customFetchImpl("http://localhost/api/auth/agent/create", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.correlation_id).toMatch(/^intent_/); + expect(body.intent.standard).toBe("nep413"); + expect(JSON.stringify(body)).not.toContain("naa_"); + }); + + it("refuses submits for correlation ids not issued to the user", async () => { + const { customFetchImpl } = await setup(); + const cookie = await verifyWithCookie(customFetchImpl); + + const res = await customFetchImpl("http://localhost/api/auth/agent/intents/submit", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ + type: "agent_create", + correlationId: "intent_not_issued", + signedData: { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", + }, + }), + }); + expect(res.status).toBe(404); + }); + + it("rejects a submit whose type does not match the issued intent", async () => { + const { customFetchImpl } = await setup(); + const cookie = await verifyWithCookie(customFetchImpl); + + const createRes = await customFetchImpl("http://localhost/api/auth/agent/create", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), + }); + const created = (await createRes.json()) as { correlation_id: string }; + + const res = await customFetchImpl("http://localhost/api/auth/agent/intents/submit", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ + type: "grant_revoke", + correlationId: created.correlation_id, + signedData: { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", + }, + }), + }); + expect(res.status).toBe(400); + }); + + it("keeps grant tokens server-side when issuing a grant", async () => { + const { customFetchImpl } = await setup(); + const cookie = await verifyWithCookie(customFetchImpl); + + const createRes = await customFetchImpl("http://localhost/api/auth/agent/create", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), + }); + expect(createRes.status).toBe(200); + + const res = await customFetchImpl("http://localhost/api/auth/agent/grants/issue", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ agentId: "ag_test1", label: "assistant-session-42" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("grant_issue"); + expect(JSON.stringify(body)).not.toContain("ngt_"); + }); +}); diff --git a/packages/better-near-auth/src/agent-connect.ts b/packages/better-near-auth/src/agent-connect.ts new file mode 100644 index 000000000..591b8d756 --- /dev/null +++ b/packages/better-near-auth/src/agent-connect.ts @@ -0,0 +1,684 @@ +import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api"; +import type { BetterAuthPlugin, DBAdapter } from "better-auth/types"; +import z from "zod"; +import type { + AgentApiOptions, + GenerateIntentResponse, + IdempotentResult, + OwnerWallet, + Policy, + SignedData, + StatusResponse, +} from "./agent-api.js"; +import { + AgentApiError, + AgentApiRequestError, + createAgentApiClient, + createGrantCredential, + createIdempotencyKey, +} from "./agent-api.js"; +import { agentSchema } from "./agent-schema.js"; +import type { NearAccount } from "./types.js"; +import { decryptPrivateKey, encryptPrivateKey } from "./utils.js"; + +const GRANT_TOKEN_SALT = "better-near-auth-agents"; +const MAX_GRANT_TTL_MS = 365 * 24 * 60 * 60 * 1000; +const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +const INTENT_TYPES = [ + "agent_create", + "policy_update", + "agent_freeze", + "agent_unfreeze", + "grant_issue", + "grant_revoke", + "execution_cancel", + "agent_archive", + "agent_restore", + "agent_delete", + "approval_vote", +] as const; + +const intentTypeSchema = z.enum(INTENT_TYPES); + +export const CreateAgentRequest = z.object({ + name: z.string().min(1).max(64), + policy: z.custom(() => true), +}); +export type CreateAgentRequestT = z.infer; + +export const SubmitAgentIntentRequest = z.object({ + type: intentTypeSchema, + correlationId: z.string().min(1), + signedData: z.custom(() => true), +}); +export type SubmitAgentIntentRequestT = z.infer; + +export const AgentStatusRequest = z.object({ + correlationId: z.string().min(1), + waitMs: z.number().int().min(0).max(30_000).optional(), +}); +export type AgentStatusRequestT = z.infer; + +export const IssueAgentGrantRequest = z.object({ + agentId: z.string().min(1), + label: z.string().min(1).max(100), + expiresAt: z.string().datetime().optional(), +}); +export type IssueAgentGrantRequestT = z.infer; + +export const RevokeAgentGrantRequest = z.object({ + agentId: z.string().min(1), + grantId: z.string().min(1), +}); +export type RevokeAgentGrantRequestT = z.infer; + +export const ListAgentGrantsRequest = z.object({ + agentId: z.string().min(1), +}); +export type ListAgentGrantsRequestT = z.infer; + +export interface AgentAccountRow { + id: string; + userId: string; + agentId: string; + name: string; + ownerType: string; + ownerAccountId?: string | null; + status: string; + createdAt: Date; +} + +export interface OwnerIntentRow { + id: string; + correlationId: string; + idempotencyKey: string; + intentType: string; + agentId?: string | null; + grantId?: string | null; + userId: string; + status: string; + expiresAt: Date; + createdAt: Date; +} + +export interface AgentGrantRow { + id: string; + userId: string; + agentId: string; + grantId?: string | null; + correlationId?: string | null; + label: string; + tokenCiphertext: string; + tokenIv: string; + commitment: string; + expiresAt: Date; + revokedAt?: Date | null; + createdAt: Date; +} + +export interface AgentConnectPasskeyOwner { + rpId: string; + origin: string; +} + +export type AgentConnectOptions = Omit & { + apiKey?: string; + passkeyOwner?: AgentConnectPasskeyOwner; +}; + +const AGENT_API_ERROR_STATUS: Record = { + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "CONFLICT", + 410: "NOT_FOUND", + 429: "TOO_MANY_REQUESTS", + 502: "BAD_GATEWAY", + 503: "SERVICE_UNAVAILABLE", +}; + +function mapAgentApiError(error: unknown): never { + if (error instanceof AgentApiRequestError) { + throw new APIError("SERVICE_UNAVAILABLE", { + message: "Agent API is unreachable — retry with the same request", + status: 503, + }); + } + if (error instanceof AgentApiError) { + const betterAuthStatus = (AGENT_API_ERROR_STATUS[error.status] ?? + "INTERNAL_SERVER_ERROR") as "BAD_REQUEST"; + throw new APIError(betterAuthStatus, { + message: error.detail || error.title, + code: error.code, + retryable: error.retryable, + availableAt: error.availableAt, + status: error.status, + }); + } + throw error; +} + +async function encryptGrantToken( + token: string, + secret: string, +): Promise<{ encrypted: string; iv: string }> { + return encryptPrivateKey(new TextEncoder().encode(token), secret, GRANT_TOKEN_SALT); +} + +async function decryptGrantToken(encrypted: string, iv: string, secret: string): Promise { + const bytes = await decryptPrivateKey(encrypted, iv, secret, GRANT_TOKEN_SALT); + return new TextDecoder().decode(bytes); +} + +export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => { + if (!options.apiKey) { + return { + id: "agentConnect", + schema: agentSchema, + endpoints: {}, + }; + } + + const api = createAgentApiClient({ + baseUrl: options.baseUrl, + timeoutMs: options.timeoutMs, + maxResponseBytes: options.maxResponseBytes, + fetch: options.fetch, + apiKey: options.apiKey, + }); + + const requireUser = (session: { user: { id: string } } | null): string => { + if (!session) { + throw new APIError("UNAUTHORIZED", { message: "Authentication required", status: 401 }); + } + return session.user.id; + }; + + const requireOwnedAgent = async ( + adapter: DBAdapter, + userId: string, + agentId: string, + ): Promise => { + const row = (await adapter.findOne({ + model: "agentAccount", + where: [ + { field: "agentId", operator: "eq", value: agentId }, + { field: "userId", operator: "eq", value: userId }, + ], + })) as AgentAccountRow | null; + if (!row) { + throw new APIError("NOT_FOUND", { + message: "Agent not found for this user", + status: 404, + }); + } + return row; + }; + + const requireOwnedIntent = async ( + adapter: DBAdapter, + userId: string, + correlationId: string, + ): Promise => { + const row = (await adapter.findOne({ + model: "ownerIntent", + where: [{ field: "correlationId", operator: "eq", value: correlationId }], + })) as OwnerIntentRow | null; + if (!row) { + throw new APIError("NOT_FOUND", { message: "Unknown correlation id", status: 404 }); + } + if (row.userId !== userId) { + throw new APIError("FORBIDDEN", { + message: "This intent does not belong to the current session", + status: 403, + }); + } + return row; + }; + + const deriveOwner = async (adapter: DBAdapter, userId: string): Promise => { + const nearAccount = (await adapter.findOne({ + model: "nearAccount", + where: [ + { field: "userId", operator: "eq", value: userId }, + { field: "isPrimary", operator: "eq", value: true }, + ], + })) as NearAccount | null; + if (nearAccount) { + return { + type: "near", + account_id: nearAccount.accountId, + public_key: nearAccount.publicKey, + }; + } + if (options.passkeyOwner) { + const authenticators = (await adapter.findMany({ + model: "passkey", + where: [{ field: "userId", operator: "eq", value: userId }], + })) as Array<{ credentialID: string; publicKey: string; createdAt?: Date | string }> | null; + const latest = authenticators + ?.slice() + .sort( + (a, b) => + (a.createdAt ? new Date(a.createdAt).getTime() : 0) - + (b.createdAt ? new Date(b.createdAt).getTime() : 0), + ) + .at(-1); + if (latest) { + return { + type: "passkey", + credential_id: latest.credentialID, + public_key: latest.publicKey, + rp_id: options.passkeyOwner.rpId, + origin: options.passkeyOwner.origin, + }; + } + } + throw new APIError("BAD_REQUEST", { + message: "No owner wallet available — link a NEAR account or register a passkey first", + status: 400, + }); + }; + + const recordIntent = async ( + adapter: DBAdapter, + args: { + correlationId: string; + idempotencyKey: string; + intentType: string; + agentId?: string | null; + grantId?: string | null; + userId: string; + status: string; + expiresAt: string; + }, + ): Promise => { + await adapter.create({ + model: "ownerIntent", + data: { + correlationId: args.correlationId, + idempotencyKey: args.idempotencyKey, + intentType: args.intentType, + agentId: args.agentId ?? null, + grantId: args.grantId ?? null, + userId: args.userId, + status: args.status, + expiresAt: new Date(args.expiresAt), + createdAt: new Date(), + }, + }); + }; + + const updateIntentStatus = async ( + adapter: DBAdapter, + correlationId: string, + status: string, + ): Promise => { + try { + await adapter.update({ + model: "ownerIntent", + where: [{ field: "correlationId", operator: "eq", value: correlationId }], + update: { status }, + }); + } catch (error) { + console.error("[agentConnect] failed to update owner intent status:", error); + } + }; + + return { + id: "agentConnect", + schema: agentSchema, + endpoints: { + createAgent: createAuthEndpoint( + "/agent/create", + { + method: "POST", + body: CreateAgentRequest, + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + const { name, policy } = ctx.body; + + const owner = await deriveOwner(ctx.context.adapter, userId); + const idempotencyKey = createIdempotencyKey(); + + let generated: IdempotentResult; + try { + generated = await api.generateIntent( + { + type: "agent_create", + name, + external_user_id: userId, + owner, + policy, + }, + { idempotencyKey }, + ); + } catch (error) { + mapAgentApiError(error); + } + + try { + await ctx.context.adapter.create({ + model: "agentAccount", + data: { + userId, + agentId: generated.agent_id, + name, + ownerType: owner.type, + ownerAccountId: owner.type === "near" ? owner.account_id : null, + status: "PENDING", + createdAt: new Date(), + }, + }); + await recordIntent(ctx.context.adapter, { + correlationId: generated.correlation_id, + idempotencyKey: generated.idempotencyKey, + intentType: "agent_create", + agentId: generated.agent_id, + userId, + status: generated.status, + expiresAt: generated.expires_at, + }); + } catch (error) { + console.error("[agentConnect] failed to persist agent_create state:", error); + } + + return ctx.json(generated); + }, + ), + + submitAgentIntent: createAuthEndpoint( + "/agent/intents/submit", + { + method: "POST", + body: SubmitAgentIntentRequest, + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + const { type, correlationId, signedData } = ctx.body; + + const intentRow = await requireOwnedIntent(ctx.context.adapter, userId, correlationId); + if (intentRow.intentType !== type) { + throw new APIError("BAD_REQUEST", { + message: "Intent type does not match the issued correlation id", + status: 400, + }); + } + + let status: StatusResponse; + try { + status = await api.submitIntent({ + type, + correlation_id: correlationId, + signed_data: signedData, + }); + } catch (error) { + mapAgentApiError(error); + } + + await updateIntentStatus(ctx.context.adapter, correlationId, status.status); + + if (type === "grant_issue" && status.status === "SUCCESS" && intentRow.grantId) { + const details = status.details as { grant?: { grant_id: string } | null } | undefined; + const issuedGrantId = details?.grant?.grant_id; + if (issuedGrantId) { + try { + await ctx.context.adapter.update({ + model: "agentGrant", + where: [{ field: "id", operator: "eq", value: intentRow.grantId }], + update: { grantId: issuedGrantId }, + }); + } catch (error) { + console.error("[agentConnect] failed to link grant row:", error); + } + } + } + + if (type === "grant_revoke" && status.status === "SUCCESS" && intentRow.grantId) { + try { + await ctx.context.adapter.update({ + model: "agentGrant", + where: [ + { field: "agentId", operator: "eq", value: intentRow.agentId ?? "" }, + { field: "grantId", operator: "eq", value: intentRow.grantId }, + { field: "userId", operator: "eq", value: userId }, + ], + update: { revokedAt: new Date() }, + }); + } catch (error) { + console.error("[agentConnect] failed to mark grant revoked:", error); + } + } + + if (type === "agent_create" && status.status === "SUCCESS" && intentRow.agentId) { + try { + await ctx.context.adapter.update({ + model: "agentAccount", + where: [ + { field: "agentId", operator: "eq", value: intentRow.agentId }, + { field: "userId", operator: "eq", value: userId }, + ], + update: { status: "ACTIVE" }, + }); + } catch (error) { + console.error("[agentConnect] failed to activate agent row:", error); + } + } + + return ctx.json(status); + }, + ), + + getAgentStatus: createAuthEndpoint( + "/agent/status", + { + method: "POST", + body: AgentStatusRequest, + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + const { correlationId, waitMs } = ctx.body; + + await requireOwnedIntent(ctx.context.adapter, userId, correlationId); + + let status: StatusResponse; + try { + status = await api.getStatus(correlationId, { waitMs }); + } catch (error) { + mapAgentApiError(error); + } + + await updateIntentStatus(ctx.context.adapter, correlationId, status.status); + + return ctx.json(status); + }, + ), + + listAgents: createAuthEndpoint( + "/agent/list", + { + method: "GET", + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + + try { + const page = await api.listAgents({ external_user_id: userId }); + return ctx.json({ + data: page.data, + nextCursor: page.next_cursor, + }); + } catch (error) { + mapAgentApiError(error); + } + }, + ), + + issueAgentGrant: createAuthEndpoint( + "/agent/grants/issue", + { + method: "POST", + body: IssueAgentGrantRequest, + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + const { agentId, label, expiresAt } = ctx.body; + + await requireOwnedAgent(ctx.context.adapter, userId, agentId); + + const requestedExpiry = expiresAt ? new Date(expiresAt).getTime() : Number.NaN; + if (expiresAt && Number.isNaN(requestedExpiry)) { + throw new APIError("BAD_REQUEST", { + message: "expiresAt must be an ISO datetime", + status: 400, + }); + } + const maxExpiry = Date.now() + MAX_GRANT_TTL_MS; + const resolvedExpiryMs = Number.isNaN(requestedExpiry) + ? Date.now() + DEFAULT_GRANT_TTL_MS + : Math.min(requestedExpiry, maxExpiry); + + const { token, commitment } = createGrantCredential(); + const encrypted = await encryptGrantToken(token, ctx.context.secret); + + const grantRow = (await ctx.context.adapter.create({ + model: "agentGrant", + data: { + userId, + agentId, + grantId: null, + correlationId: null, + label, + tokenCiphertext: encrypted.encrypted, + tokenIv: encrypted.iv, + commitment, + expiresAt: new Date(resolvedExpiryMs), + revokedAt: null, + createdAt: new Date(), + }, + })) as AgentGrantRow | null; + + const idempotencyKey = createIdempotencyKey(); + let generated: IdempotentResult; + try { + generated = await api.generateIntent( + { + type: "grant_issue", + agent_id: agentId, + label, + credential: commitment, + expires_at: new Date(resolvedExpiryMs).toISOString(), + }, + { idempotencyKey }, + ); + } catch (error) { + mapAgentApiError(error); + } + + try { + await recordIntent(ctx.context.adapter, { + correlationId: generated.correlation_id, + idempotencyKey: generated.idempotencyKey, + intentType: "grant_issue", + agentId, + grantId: grantRow?.id ?? null, + userId, + status: generated.status, + expiresAt: generated.expires_at, + }); + } catch (error) { + console.error("[agentConnect] failed to persist grant_issue state:", error); + } + + return ctx.json(generated); + }, + ), + + revokeAgentGrant: createAuthEndpoint( + "/agent/grants/revoke", + { + method: "POST", + body: RevokeAgentGrantRequest, + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + const { agentId, grantId } = ctx.body; + + await requireOwnedAgent(ctx.context.adapter, userId, agentId); + + const idempotencyKey = createIdempotencyKey(); + let generated: IdempotentResult; + try { + generated = await api.generateIntent( + { + type: "grant_revoke", + agent_id: agentId, + grant_id: grantId, + }, + { idempotencyKey }, + ); + } catch (error) { + mapAgentApiError(error); + } + + try { + await recordIntent(ctx.context.adapter, { + correlationId: generated.correlation_id, + idempotencyKey: generated.idempotencyKey, + intentType: "grant_revoke", + agentId, + grantId, + userId, + status: generated.status, + expiresAt: generated.expires_at, + }); + } catch (error) { + console.error("[agentConnect] failed to persist grant_revoke state:", error); + } + + return ctx.json(generated); + }, + ), + + listAgentGrants: createAuthEndpoint( + "/agent/grants", + { + method: "POST", + body: ListAgentGrantsRequest, + use: [sessionMiddleware], + }, + async (ctx) => { + const session = ctx.context.session; + const userId = requireUser(session); + const { agentId } = ctx.body; + + await requireOwnedAgent(ctx.context.adapter, userId, agentId); + + try { + const grants = await api.listGrants(agentId); + return ctx.json({ data: grants }); + } catch (error) { + mapAgentApiError(error); + } + }, + ), + }, + } satisfies BetterAuthPlugin; +}; + +export { agentSchema, decryptGrantToken, encryptGrantToken }; diff --git a/packages/better-near-auth/src/agent-schema.ts b/packages/better-near-auth/src/agent-schema.ts new file mode 100644 index 000000000..bfdac58c5 --- /dev/null +++ b/packages/better-near-auth/src/agent-schema.ts @@ -0,0 +1,136 @@ +import type { BetterAuthPluginDBSchema } from "better-auth/db"; + +export const agentSchema = { + agentAccount: { + fields: { + userId: { + type: "string", + references: { + model: "user", + field: "id", + }, + required: true, + }, + agentId: { + type: "string", + required: true, + }, + name: { + type: "string", + required: true, + }, + ownerType: { + type: "string", + required: true, + }, + ownerAccountId: { + type: "string", + required: false, + }, + status: { + type: "string", + required: true, + }, + createdAt: { + type: "date", + required: true, + }, + }, + }, + ownerIntent: { + fields: { + correlationId: { + type: "string", + required: true, + }, + idempotencyKey: { + type: "string", + required: true, + }, + intentType: { + type: "string", + required: true, + }, + agentId: { + type: "string", + required: false, + }, + grantId: { + type: "string", + required: false, + }, + userId: { + type: "string", + references: { + model: "user", + field: "id", + }, + required: true, + }, + status: { + type: "string", + required: true, + }, + expiresAt: { + type: "date", + required: true, + }, + createdAt: { + type: "date", + required: true, + }, + }, + }, + agentGrant: { + fields: { + userId: { + type: "string", + references: { + model: "user", + field: "id", + }, + required: true, + }, + agentId: { + type: "string", + required: true, + }, + grantId: { + type: "string", + required: false, + }, + correlationId: { + type: "string", + required: false, + }, + label: { + type: "string", + required: true, + }, + tokenCiphertext: { + type: "string", + required: true, + }, + tokenIv: { + type: "string", + required: true, + }, + commitment: { + type: "string", + required: true, + }, + expiresAt: { + type: "date", + required: true, + }, + revokedAt: { + type: "date", + required: false, + }, + createdAt: { + type: "date", + required: true, + }, + }, + }, +} satisfies BetterAuthPluginDBSchema; diff --git a/packages/better-near-auth/src/auth-callbacks.ts b/packages/better-near-auth/src/auth-callbacks.ts new file mode 100644 index 000000000..425fdb002 --- /dev/null +++ b/packages/better-near-auth/src/auth-callbacks.ts @@ -0,0 +1,4 @@ +export interface AuthCallbacks { + onSuccess?: () => void; + onError?: (error: Error & { status?: number; code?: string }) => void; +} diff --git a/packages/better-near-auth/src/client.ts b/packages/better-near-auth/src/client.ts index 24efa2361..735c4a662 100644 --- a/packages/better-near-auth/src/client.ts +++ b/packages/better-near-auth/src/client.ts @@ -58,7 +58,13 @@ import { type ViewContractRequestT, type ViewContractResponseT, } from "./types.js"; +import { + clearWalletSigner, + type NearConnectSignerWallet, + setWalletSigner, +} from "./wallet-signer.js"; +export type { AuthCallbacks } from "./auth-callbacks.js"; export { getNearAtoms, type NearAtomsSource, @@ -67,10 +73,7 @@ export { type NearState, } from "./store.js"; -export interface AuthCallbacks { - onSuccess?: () => void; - onError?: (error: Error & { status?: number; code?: string }) => void; -} +import type { AuthCallbacks } from "./auth-callbacks.js"; export interface SIWNClientConfig { recipient?: string; @@ -161,6 +164,7 @@ export interface SIWNClientActions { }; } +export { type AgentClientActions, agentConnectClient } from "./agent-connect-client.js"; export { DEFAULT_DEVICE_LINK_CLIENT_ID } from "./constants.js"; export { isPasskeyWalletAvailable, type PasskeyWalletNetwork } from "./passkey.js"; @@ -245,6 +249,14 @@ export const siwnClient = (config: SIWNClientConfig) => { .getConnectedWallet() .then((connected) => { connectedWallets.set(network, connected); + if (connected.accounts?.[0]?.accountId) { + setWalletSigner({ + accountId: connected.accounts[0].accountId, + publicKey: connected.accounts[0].publicKey ?? null, + network, + wallet: connected.wallet as NearConnectSignerWallet, + }); + } return connected; }) .finally(() => { @@ -306,6 +318,7 @@ export const siwnClient = (config: SIWNClientConfig) => { connector.on("wallet:signOut", () => { connectedWallets.delete(network); + clearWalletSigner(network); if (activeNetwork.get() !== network) return; walletConnected.set(false); const state = nearState.get(); diff --git a/packages/better-near-auth/src/index.ts b/packages/better-near-auth/src/index.ts index a88dfe6c2..465a43cfb 100644 --- a/packages/better-near-auth/src/index.ts +++ b/packages/better-near-auth/src/index.ts @@ -85,6 +85,37 @@ import { ViewContractResponse, } from "./types.js"; +export type { + AgentApiOptions, + AgentView, + GenerateIntentResponse, + GenerateIntentResponseOf, + GrantView, + Intent, + IntentPreview, + IntentType, + OwnerWallet, + Policy, + PolicyView, + SignedData, + StatusResponse, + StatusResponseOf, +} from "./agent-api.js"; +export { + type AgentAccountRow, + type AgentConnectOptions, + type AgentConnectPasskeyOwner, + type AgentGrantRow, + type AgentStatusRequestT, + agentConnect, + agentSchema, + type CreateAgentRequestT, + type IssueAgentGrantRequestT, + type ListAgentGrantsRequestT, + type OwnerIntentRow, + type RevokeAgentGrantRequestT, + type SubmitAgentIntentRequestT, +} from "./agent-connect.js"; export { getPasskeyWalletFactory, isDeterministicAccountId, diff --git a/packages/better-near-auth/src/utils.ts b/packages/better-near-auth/src/utils.ts index a93ffbada..ef4e20263 100644 --- a/packages/better-near-auth/src/utils.ts +++ b/packages/better-near-auth/src/utils.ts @@ -20,7 +20,7 @@ export function hexToBytes(hex: string): Uint8Array { return bytes; } -async function deriveAesKey(secret: string): Promise { +async function deriveAesKey(secret: string, salt = "better-near-auth-relayer"): Promise { const keyMaterial = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), @@ -32,7 +32,7 @@ async function deriveAesKey(secret: string): Promise { { name: "HKDF", hash: "SHA-256", - salt: new TextEncoder().encode("better-near-auth-relayer"), + salt: new TextEncoder().encode(salt), info: new Uint8Array(0), }, keyMaterial, @@ -45,8 +45,9 @@ async function deriveAesKey(secret: string): Promise { export async function encryptPrivateKey( privateKey: Uint8Array, secret: string, + salt?: string, ): Promise<{ encrypted: string; iv: string }> { - const aesKey = await deriveAesKey(secret); + const aesKey = await deriveAesKey(secret, salt); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, @@ -63,8 +64,9 @@ export async function decryptPrivateKey( encrypted: string, iv: string, secret: string, + salt?: string, ): Promise { - const aesKey = await deriveAesKey(secret); + const aesKey = await deriveAesKey(secret, salt); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: base64ToBytes(iv) as Uint8Array }, aesKey, diff --git a/packages/better-near-auth/src/wallet-signer.ts b/packages/better-near-auth/src/wallet-signer.ts new file mode 100644 index 000000000..51e2daf5b --- /dev/null +++ b/packages/better-near-auth/src/wallet-signer.ts @@ -0,0 +1,35 @@ +export interface NearConnectSignerWallet { + signMessage: (params: { + message: string; + recipient: string; + nonce: Uint8Array; + network?: string; + callbackUrl?: string; + }) => Promise<{ accountId: string; publicKey: string; signature: string }>; + signDelegateActions: (params: { + delegateActions: unknown[]; + }) => Promise<{ signedDelegateActions: string[] }>; +} + +export interface NearConnectSigner { + accountId: string; + publicKey: string | null; + network: "mainnet" | "testnet"; + wallet: NearConnectSignerWallet; +} + +const signers = new Map<"mainnet" | "testnet", NearConnectSigner>(); + +export function setWalletSigner(signer: NearConnectSigner | null): void { + if (!signer) return; + signers.set(signer.network, signer); +} + +export function clearWalletSigner(network: "mainnet" | "testnet"): void { + signers.delete(network); +} + +export function getWalletSigner(network?: "mainnet" | "testnet"): NearConnectSigner | null { + if (network) return signers.get(network) ?? null; + return signers.get("mainnet") ?? signers.get("testnet") ?? null; +} diff --git a/packages/everything-dev/src/ui/auth.ts b/packages/everything-dev/src/ui/auth.ts index 2a3e0910f..808d07bb8 100644 --- a/packages/everything-dev/src/ui/auth.ts +++ b/packages/everything-dev/src/ui/auth.ts @@ -12,7 +12,11 @@ import { } from "better-auth/client/plugins"; import { createAuthClient as createBetterAuthClient } from "better-auth/react"; import type { PasskeyWalletLink, RelayedTransactionT } from "better-near-auth"; -import { DEFAULT_DEVICE_LINK_CLIENT_ID, siwnClient } from "better-near-auth/client"; +import { + agentConnectClient, + DEFAULT_DEVICE_LINK_CLIENT_ID, + siwnClient, +} from "better-near-auth/client"; import type { ClientRuntimeConfig } from "../types"; import { getRuntimeConfig } from "./runtime"; @@ -119,6 +123,7 @@ export function createAuthClient(options: CreateAuthClientOptions = {}) { }, }), siwnClient(nearAuthConfig), + agentConnectClient(), adminClient(), anonymousClient(), phoneNumberClient(), diff --git a/plugins/auth/src/auth-config.ts b/plugins/auth/src/auth-config.ts index 1176bee59..d00723e3e 100644 --- a/plugins/auth/src/auth-config.ts +++ b/plugins/auth/src/auth-config.ts @@ -72,6 +72,11 @@ export interface AuthConfig { }; }; siwn: AuthSiwnConfig; + agentApi?: { + apiKey?: string; + baseUrl?: string; + timeoutMs?: number; + }; email?: { from: string; }; diff --git a/plugins/auth/src/auth-instance.ts b/plugins/auth/src/auth-instance.ts index 291a92288..0be73ad91 100644 --- a/plugins/auth/src/auth-instance.ts +++ b/plugins/auth/src/auth-instance.ts @@ -13,7 +13,19 @@ import { phoneNumber, } from "better-auth/plugins"; import { createAccessControl } from "better-auth/plugins/access"; -import { DEFAULT_DEVICE_LINK_CLIENT_ID, type SIWNPluginOptions, siwn } from "better-near-auth"; +import { + adminAc, + defaultStatements, + memberAc, + ownerAc, +} from "better-auth/plugins/organization/access"; +import { + type AgentConnectOptions, + agentConnect, + DEFAULT_DEVICE_LINK_CLIENT_ID, + type SIWNPluginOptions, + siwn, +} from "better-near-auth"; import { eq, gt } from "drizzle-orm"; import type { AuthConfig, AuthPermissionStatements } from "./auth-config"; import type { Database as AuthDatabase } from "./db"; @@ -279,6 +291,15 @@ export function createAuthInstance( const mainnetRecipient = isRecipientsConfig(siwnOptions) ? siwnOptions.recipients.mainnet : siwnOptions.recipient; + const agentApiOptions: AgentConnectOptions = { + apiKey: config.agentApi?.apiKey, + baseUrl: config.agentApi?.baseUrl, + timeoutMs: config.agentApi?.timeoutMs, + passkeyOwner: { + rpId: passkeyOptions.rpID, + origin: passkeyOptions.origin[0] ?? config.baseUrl, + }, + }; return betterAuth({ database: drizzleAdapter(db, { @@ -303,6 +324,7 @@ export function createAuthInstance( socialProviders, plugins: [ siwn(siwnOptions), + agentConnect(agentApiOptions), admin({ defaultRole: "user", adminRoles: ["admin"] }), anonymous({ emailDomainName: mainnetRecipient }), haveIBeenPwned({ enabled: process.env.NODE_ENV !== "test" }), diff --git a/plugins/auth/src/config-schemas.ts b/plugins/auth/src/config-schemas.ts index 24fb0b9dc..34b41dc11 100644 --- a/plugins/auth/src/config-schemas.ts +++ b/plugins/auth/src/config-schemas.ts @@ -121,6 +121,12 @@ export const authVariablesSchema = z.object({ }) .optional(), siwn: z.union([authSiwnRecipientSchema, authSiwnRecipientsSchema]), + agentApi: z + .object({ + baseUrl: z.string().optional(), + timeoutMs: z.number().optional(), + }) + .optional(), deviceLink: z .object({ clientId: z.string().min(1).default(DEFAULT_DEVICE_LINK_CLIENT_ID), @@ -144,6 +150,7 @@ export const authSecretsSchema = z.object({ TWILIO_PHONE_NUMBER: z.string().optional(), NEAR_RELAYER_PRIVATE_KEY_MAINNET: z.string().optional(), NEAR_RELAYER_PRIVATE_KEY_TESTNET: z.string().optional(), + NEAR_INTENTS_AGENT_API_KEY: z.string().optional(), RESEND_API_KEY: z.string().optional(), }); diff --git a/plugins/auth/src/config.ts b/plugins/auth/src/config.ts index 20dca9f2d..e3703ffe5 100644 --- a/plugins/auth/src/config.ts +++ b/plugins/auth/src/config.ts @@ -226,6 +226,13 @@ export function normalizeAuthConfig( ...siwn, apiKey: secrets.FASTNEAR_API_KEY, }, + agentApi: secrets.NEAR_INTENTS_AGENT_API_KEY + ? { + apiKey: secrets.NEAR_INTENTS_AGENT_API_KEY, + baseUrl: variables.agentApi?.baseUrl, + timeoutMs: variables.agentApi?.timeoutMs, + } + : undefined, email: variables.email?.from ? { from: variables.email.from, diff --git a/plugins/auth/src/db/migrations/0014_agent-connect.sql b/plugins/auth/src/db/migrations/0014_agent-connect.sql new file mode 100644 index 000000000..921b8429c --- /dev/null +++ b/plugins/auth/src/db/migrations/0014_agent-connect.sql @@ -0,0 +1,49 @@ +CREATE TABLE "agent_account" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "agent_id" text NOT NULL, + "name" text NOT NULL, + "owner_type" text NOT NULL, + "owner_account_id" text, + "status" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "agent_grant" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "agent_id" text NOT NULL, + "grant_id" text, + "correlation_id" text, + "label" text NOT NULL, + "token_ciphertext" text NOT NULL, + "token_iv" text NOT NULL, + "commitment" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "owner_intent" ( + "id" text PRIMARY KEY NOT NULL, + "correlation_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "intent_type" text NOT NULL, + "agent_id" text, + "grant_id" text, + "user_id" text NOT NULL, + "status" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "agent_account" ADD CONSTRAINT "agent_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_grant" ADD CONSTRAINT "agent_grant_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "owner_intent" ADD CONSTRAINT "owner_intent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "agentAccount_agentId_uidx" ON "agent_account" USING btree ("agent_id");--> statement-breakpoint +CREATE INDEX "agentAccount_userId_idx" ON "agent_account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "agentGrant_agentId_idx" ON "agent_grant" USING btree ("agent_id");--> statement-breakpoint +CREATE INDEX "agentGrant_userId_idx" ON "agent_grant" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "agentGrant_commitment_idx" ON "agent_grant" USING btree ("commitment");--> statement-breakpoint +CREATE UNIQUE INDEX "ownerIntent_correlationId_uidx" ON "owner_intent" USING btree ("correlation_id");--> statement-breakpoint +CREATE INDEX "ownerIntent_userId_idx" ON "owner_intent" USING btree ("user_id"); \ No newline at end of file diff --git a/plugins/auth/src/db/migrations/meta/0011_snapshot.json b/plugins/auth/src/db/migrations/meta/0011_snapshot.json deleted file mode 100644 index 5318f0731..000000000 --- a/plugins/auth/src/db/migrations/meta/0011_snapshot.json +++ /dev/null @@ -1,1993 +0,0 @@ -{ - "id": "a5ed4552-3787-4a19-aa6a-49b7796cae75", - "prevId": "77a74fab-edd3-46a2-b3b4-958a4e21d02d", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.apikey": { - "name": "apikey", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "config_id": { - "name": "config_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'default'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "start": { - "name": "start", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "reference_id": { - "name": "reference_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refill_interval": { - "name": "refill_interval", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "refill_amount": { - "name": "refill_amount", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_refill_at": { - "name": "last_refill_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "rate_limit_enabled": { - "name": "rate_limit_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "rate_limit_time_window": { - "name": "rate_limit_time_window", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 86400000 - }, - "rate_limit_max": { - "name": "rate_limit_max", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 10 - }, - "request_count": { - "name": "request_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "remaining": { - "name": "remaining", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_request": { - "name": "last_request", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "permissions": { - "name": "permissions", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "apikey_configId_idx": { - "name": "apikey_configId_idx", - "columns": [ - { - "expression": "config_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "apikey_referenceId_idx": { - "name": "apikey_referenceId_idx", - "columns": [ - { - "expression": "reference_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "apikey_key_idx": { - "name": "apikey_key_idx", - "columns": [ - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_code": { - "name": "device_code", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "device_code": { - "name": "device_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_code": { - "name": "user_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_polled_at": { - "name": "last_polled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "polling_interval": { - "name": "polling_interval", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "client_id": { - "name": "client_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deviceCode_deviceCode_uidx": { - "name": "deviceCode_deviceCode_uidx", - "columns": [ - { - "expression": "device_code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deviceCode_userCode_uidx": { - "name": "deviceCode_userCode_uidx", - "columns": [ - { - "expression": "user_code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "device_code_user_id_user_id_fk": { - "name": "device_code_user_id_user_id_fk", - "tableFrom": "device_code", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_link_claim": { - "name": "device_link_claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "token_hash": { - "name": "token_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "client_id": { - "name": "client_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "consumed_at": { - "name": "consumed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deviceLinkClaim_tokenHash_uidx": { - "name": "deviceLinkClaim_tokenHash_uidx", - "columns": [ - { - "expression": "token_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.invitation": { - "name": "invitation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "inviter_id": { - "name": "inviter_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "near_account_id": { - "name": "near_account_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "near_network": { - "name": "near_network", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "invitation_organizationId_idx": { - "name": "invitation_organizationId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_email_idx": { - "name": "invitation_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_teamId_idx": { - "name": "invitation_teamId_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_nearAccountId_idx": { - "name": "invitation_nearAccountId_idx", - "columns": [ - { - "expression": "near_account_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_nearNetwork_idx": { - "name": "invitation_nearNetwork_idx", - "columns": [ - { - "expression": "near_network", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "invitation_organization_id_organization_id_fk": { - "name": "invitation_organization_id_organization_id_fk", - "tableFrom": "invitation", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "invitation_inviter_id_user_id_fk": { - "name": "invitation_inviter_id_user_id_fk", - "tableFrom": "invitation", - "tableTo": "user", - "columnsFrom": ["inviter_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.member": { - "name": "member", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "member_organizationId_idx": { - "name": "member_organizationId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "member_userId_idx": { - "name": "member_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "member_organization_id_organization_id_fk": { - "name": "member_organization_id_organization_id_fk", - "tableFrom": "member", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "member_user_id_user_id_fk": { - "name": "member_user_id_user_id_fk", - "tableFrom": "member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.near_account": { - "name": "near_account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "near_account_user_id_user_id_fk": { - "name": "near_account_user_id_user_id_fk", - "tableFrom": "near_account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.onboarding_code": { - "name": "onboarding_code", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "code_hash": { - "name": "code_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "event_id": { - "name": "event_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "event_name": { - "name": "event_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encrypted_code": { - "name": "encrypted_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "max_uses": { - "name": "max_uses", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 50 - }, - "used_count": { - "name": "used_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "onboardingCode_codeHash_uidx": { - "name": "onboardingCode_codeHash_uidx", - "columns": [ - { - "expression": "code_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "onboardingCode_organizationId_eventId_idx": { - "name": "onboardingCode_organizationId_eventId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "event_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "onboarding_code_organization_id_organization_id_fk": { - "name": "onboarding_code_organization_id_organization_id_fk", - "tableFrom": "onboarding_code", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "onboarding_code_team_id_team_id_fk": { - "name": "onboarding_code_team_id_team_id_fk", - "tableFrom": "onboarding_code", - "tableTo": "team", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "onboarding_code_created_by_user_id_fk": { - "name": "onboarding_code_created_by_user_id_fk", - "tableFrom": "onboarding_code", - "tableTo": "user", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.onboarding_redemption": { - "name": "onboarding_redemption", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "code_id": { - "name": "code_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "onboardingRedemption_codeId_userId_uidx": { - "name": "onboardingRedemption_codeId_userId_uidx", - "columns": [ - { - "expression": "code_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "onboardingRedemption_codeId_idx": { - "name": "onboardingRedemption_codeId_idx", - "columns": [ - { - "expression": "code_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "onboarding_redemption_code_id_onboarding_code_id_fk": { - "name": "onboarding_redemption_code_id_onboarding_code_id_fk", - "tableFrom": "onboarding_redemption", - "tableTo": "onboarding_code", - "columnsFrom": ["code_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "onboarding_redemption_user_id_user_id_fk": { - "name": "onboarding_redemption_user_id_user_id_fk", - "tableFrom": "onboarding_redemption", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organization": { - "name": "organization", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "logo": { - "name": "logo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "requested_by": { - "name": "requested_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "rejection_reason": { - "name": "rejection_reason", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "organization_slug_uidx": { - "name": "organization_slug_uidx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "organization_requested_by_user_id_fk": { - "name": "organization_requested_by_user_id_fk", - "tableFrom": "organization", - "tableTo": "user", - "columnsFrom": ["requested_by"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "organization_slug_unique": { - "name": "organization_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.passkey": { - "name": "passkey", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "credential_id": { - "name": "credential_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "counter": { - "name": "counter", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "device_type": { - "name": "device_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "backed_up": { - "name": "backed_up", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "transports": { - "name": "transports", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "aaguid": { - "name": "aaguid", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "passkey_userId_idx": { - "name": "passkey_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "passkey_credentialID_idx": { - "name": "passkey_credentialID_idx", - "columns": [ - { - "expression": "credential_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "passkey_user_id_user_id_fk": { - "name": "passkey_user_id_user_id_fk", - "tableFrom": "passkey", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.relayed_transaction": { - "name": "relayed_transaction", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "receiver_id": { - "name": "receiver_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "gas_used": { - "name": "gas_used", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "relayedTransaction_txHash_idx": { - "name": "relayedTransaction_txHash_idx", - "columns": [ - { - "expression": "tx_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "relayedTransaction_userId_idx": { - "name": "relayedTransaction_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "relayed_transaction_user_id_user_id_fk": { - "name": "relayed_transaction_user_id_user_id_fk", - "tableFrom": "relayed_transaction", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.relayer_key": { - "name": "relayer_key", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encrypted_private_key": { - "name": "encrypted_private_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "iv": { - "name": "iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "impersonated_by": { - "name": "impersonated_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "active_organization_id": { - "name": "active_organization_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "active_team_id": { - "name": "active_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team": { - "name": "team", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "team_organizationId_idx": { - "name": "team_organizationId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "team_organization_id_organization_id_fk": { - "name": "team_organization_id_organization_id_fk", - "tableFrom": "team", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team_member": { - "name": "team_member", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "teamMember_teamId_idx": { - "name": "teamMember_teamId_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "teamMember_userId_idx": { - "name": "teamMember_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "team_member_team_id_team_id_fk": { - "name": "team_member_team_id_team_id_fk", - "tableFrom": "team_member", - "tableTo": "team", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "team_member_user_id_user_id_fk": { - "name": "team_member_user_id_user_id_fk", - "tableFrom": "team_member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "banned": { - "name": "banned", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "ban_reason": { - "name": "ban_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ban_expires": { - "name": "ban_expires", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "phone_number": { - "name": "phone_number", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "phone_number_verified": { - "name": "phone_number_verified", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "locale": { - "name": "locale", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - }, - "user_phone_number_unique": { - "name": "user_phone_number_unique", - "nullsNotDistinct": false, - "columns": ["phone_number"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/plugins/auth/src/db/migrations/meta/_journal.json b/plugins/auth/src/db/migrations/meta/_journal.json index c3ee20041..5bd31d896 100644 --- a/plugins/auth/src/db/migrations/meta/_journal.json +++ b/plugins/auth/src/db/migrations/meta/_journal.json @@ -75,22 +75,8 @@ { "idx": 11, "version": "7", - "when": 1791992588711, - "tag": "0011_team-member-role", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1792004692698, - "tag": "0012_gifted_nicolaos", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1792004827601, - "tag": "0013_sleepy_komodo", + "when": 1792045690836, + "tag": "0011_agent-connect", "breakpoints": true } ] diff --git a/plugins/auth/src/db/schema.ts b/plugins/auth/src/db/schema.ts index d9139ccd6..29de00581 100644 --- a/plugins/auth/src/db/schema.ts +++ b/plugins/auth/src/db/schema.ts @@ -402,6 +402,73 @@ export const relayerKey = pgTable("relayer_key", { lastUsedAt: timestamp("last_used_at", { mode: "date", withTimezone: true }), }); +export const agentAccount = pgTable( + "agent_account", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + agentId: text("agent_id").notNull(), + name: text("name").notNull(), + ownerType: text("owner_type").notNull(), + ownerAccountId: text("owner_account_id"), + status: text("status").notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("agentAccount_agentId_uidx").on(table.agentId), + index("agentAccount_userId_idx").on(table.userId), + ], +); + +export const ownerIntent = pgTable( + "owner_intent", + { + id: text("id").primaryKey(), + correlationId: text("correlation_id").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + intentType: text("intent_type").notNull(), + agentId: text("agent_id"), + grantId: text("grant_id"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + status: text("status").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("ownerIntent_correlationId_uidx").on(table.correlationId), + index("ownerIntent_userId_idx").on(table.userId), + ], +); + +export const agentGrant = pgTable( + "agent_grant", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + agentId: text("agent_id").notNull(), + grantId: text("grant_id"), + correlationId: text("correlation_id"), + label: text("label").notNull(), + tokenCiphertext: text("token_ciphertext").notNull(), + tokenIv: text("token_iv").notNull(), + commitment: text("commitment").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { mode: "date", withTimezone: true }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agentGrant_agentId_idx").on(table.agentId), + index("agentGrant_userId_idx").on(table.userId), + index("agentGrant_commitment_idx").on(table.commitment), + ], +); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe9a13465..c9d400375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -666,6 +666,9 @@ importers: packages/better-near-auth: dependencies: + '@near-intents-agent-api/sdk': + specifier: ^0.4.1 + version: 0.4.1 '@noble/curves': specifier: ^2.4.0 version: 2.4.0 @@ -678,6 +681,9 @@ importers: '@scure/base': specifier: ^2.2.0 version: 2.4.0 + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 nanostores: specifier: ^1.1.0 version: 1.5.4 @@ -3289,6 +3295,10 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@near-intents-agent-api/sdk@0.4.1': + resolution: {integrity: sha512-1b9QlOK/j2jiFPfFuSfUS9ZRox+EHOuF8gd0bfwzasAYdGZ1S5QOOIAAYS6Vv6DAhdJHWwH7xk/rgVaQmoQCQQ==} + engines: {node: '>=24'} + '@neodrag/core@3.0.0-next.11': resolution: {integrity: sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg==} @@ -9471,6 +9481,12 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@near-intents-agent-api/sdk@0.4.1': + dependencies: + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@scure/base': 2.4.0 + '@neodrag/core@3.0.0-next.11': {} '@neodrag/solid@3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.15)': From a2bab2c7ff2bd9dec5022fceed5870c4dd257ab1 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 02:00:46 -0500 Subject: [PATCH 09/20] refactor(auth): address agent-connect review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persist the owner-intent ledger row (keyed by idempotency key) before the generate call, then link the real correlation id after — the send/persist ordering rule holds across crashes; ledger writes fail loud instead of being swallowed - ownerIntent.grantId held two meanings (local row id for issues, API grant id for revokes); rename to grantRowId and resolve the local row at revoke-creation time so submit updates one uniform key - cross-user correlation ids answer 404 (not 403) — no enumeration leak - type the error-status map as a literal union; drop the lying cast and the dead null parameter in the wallet signer bridge - test the grant-token round trip (encrypt → refuse wrong secret → decrypt) and add GLOSSARY entries for the agent-connect vocabulary --- GLOSSARY.md | 18 + .../src/agent-connect.test.ts | 12 + .../better-near-auth/src/agent-connect.ts | 203 +++---- packages/better-near-auth/src/agent-schema.ts | 2 +- .../better-near-auth/src/wallet-signer.ts | 3 +- .../src/db/migrations/0014_agent-connect.sql | 2 +- .../src/db/migrations/meta/0013_snapshot.json | 495 +++++++++++++----- .../auth/src/db/migrations/meta/_journal.json | 2 +- plugins/auth/src/db/schema.ts | 2 +- 9 files changed, 513 insertions(+), 226 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index 05ae08bd4..8ff73ca39 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -105,3 +105,21 @@ _Avoid_: provision, register **Lane**: One of a Gas Key's independent nonce sequences; parallel sends pick different Lanes and never serialize on each other. _Avoid_: nonce (the per-lane counter), slot + +## Agent connect + +**Agent Account**: +A custody account on NEAR Intents owned by a signed-in user and driven by AI agents under the owner-signed policy; the auth plugin maps one Better Auth user to their Agent Accounts (`agentAccount`). +_Avoid_: agent wallet (that's the custody Wallet inside it), bot account + +**Owner Intent**: +One owner-authorized change to an Agent Account (create, policy, grant, freeze, delete) — generated by the backend, signed by the owner's wallet byte-for-byte, then submitted; tracked per user in `ownerIntent` by Correlation Id. +_Avoid_: transaction (on-chain only for some intents), action (that's a policy permission) + +**Grant Token**: +The revocable `ngt_…` credential letting one agent session act on an Agent Account inside the policy; the server stores it encrypted at rest and it never reaches a browser or model. +_Avoid_: API key (the partner's `naa_` credential — different party), session token + +**Correlation Id**: +The tracking id of one Owner Intent or execution (`intent_…`/`op_…`); the ledger authorizes submits and status reads against it. +_Avoid_: operation id generically, request id (that's the API's `meta.request_id`) diff --git a/packages/better-near-auth/src/agent-connect.test.ts b/packages/better-near-auth/src/agent-connect.test.ts index 946060584..42dfad70e 100644 --- a/packages/better-near-auth/src/agent-connect.test.ts +++ b/packages/better-near-auth/src/agent-connect.test.ts @@ -188,6 +188,18 @@ describe("agent connect grant credentials", () => { expect(commitment).toBe(createHash("sha256").update(token).digest("hex")); expect(commitment).toBe(grantCommitment(token)); }); + + it("stores grant tokens encrypted and recovers them server-side only", async () => { + const { encryptGrantToken, decryptGrantToken } = await import("./agent-connect.js"); + const secret = "test-auth-secret"; + const { token } = createGrantCredential(); + + const stored = await encryptGrantToken(token, secret); + expect(stored.encrypted).not.toContain(token); + expect(stored.iv).not.toContain(token); + await expect(decryptGrantToken(stored.encrypted, stored.iv, "wrong-secret")).rejects.toThrow(); + await expect(decryptGrantToken(stored.encrypted, stored.iv, secret)).resolves.toBe(token); + }); }); describe("agent connect routes", () => { diff --git a/packages/better-near-auth/src/agent-connect.ts b/packages/better-near-auth/src/agent-connect.ts index 591b8d756..eb4e77c50 100644 --- a/packages/better-near-auth/src/agent-connect.ts +++ b/packages/better-near-auth/src/agent-connect.ts @@ -24,6 +24,7 @@ import { decryptPrivateKey, encryptPrivateKey } from "./utils.js"; const GRANT_TOKEN_SALT = "better-near-auth-agents"; const MAX_GRANT_TTL_MS = 365 * 24 * 60 * 60 * 1000; const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const PRE_SEND_TTL_MS = 15 * 60 * 1000; const INTENT_TYPES = [ "agent_create", @@ -95,7 +96,7 @@ export interface OwnerIntentRow { idempotencyKey: string; intentType: string; agentId?: string | null; - grantId?: string | null; + grantRowId?: string | null; userId: string; status: string; expiresAt: Date; @@ -127,7 +128,18 @@ export type AgentConnectOptions = Omit passkeyOwner?: AgentConnectPasskeyOwner; }; -const AGENT_API_ERROR_STATUS: Record = { +type APIErrorStatus = + | "BAD_REQUEST" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT" + | "TOO_MANY_REQUESTS" + | "BAD_GATEWAY" + | "SERVICE_UNAVAILABLE" + | "INTERNAL_SERVER_ERROR"; + +const AGENT_API_ERROR_STATUS: Record = { 400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN", @@ -147,9 +159,7 @@ function mapAgentApiError(error: unknown): never { }); } if (error instanceof AgentApiError) { - const betterAuthStatus = (AGENT_API_ERROR_STATUS[error.status] ?? - "INTERNAL_SERVER_ERROR") as "BAD_REQUEST"; - throw new APIError(betterAuthStatus, { + throw new APIError(AGENT_API_ERROR_STATUS[error.status] ?? "INTERNAL_SERVER_ERROR", { message: error.detail || error.title, code: error.code, retryable: error.retryable, @@ -226,15 +236,9 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => model: "ownerIntent", where: [{ field: "correlationId", operator: "eq", value: correlationId }], })) as OwnerIntentRow | null; - if (!row) { + if (!row || row.userId !== userId) { throw new APIError("NOT_FOUND", { message: "Unknown correlation id", status: 404 }); } - if (row.userId !== userId) { - throw new APIError("FORBIDDEN", { - message: "This intent does not belong to the current session", - status: 403, - }); - } return row; }; @@ -282,33 +286,53 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => }); }; - const recordIntent = async ( + const runIntent = async ( adapter: DBAdapter, args: { - correlationId: string; idempotencyKey: string; intentType: string; agentId?: string | null; - grantId?: string | null; + grantRowId?: string | null; userId: string; - status: string; - expiresAt: string; }, - ): Promise => { + send: () => Promise>, + ): Promise> => { await adapter.create({ model: "ownerIntent", data: { - correlationId: args.correlationId, + correlationId: `pending-${args.idempotencyKey}`, idempotencyKey: args.idempotencyKey, intentType: args.intentType, agentId: args.agentId ?? null, - grantId: args.grantId ?? null, + grantRowId: args.grantRowId ?? null, userId: args.userId, - status: args.status, - expiresAt: new Date(args.expiresAt), + status: "PENDING_SIGNATURE", + expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), createdAt: new Date(), }, }); + + const generated = await send(); + + try { + await adapter.update({ + model: "ownerIntent", + where: [{ field: "idempotencyKey", operator: "eq", value: args.idempotencyKey }], + update: { + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + }, + }); + } catch (error) { + console.error("[agentConnect] failed to link the generated intent to the ledger:", error); + throw new APIError("INTERNAL_SERVER_ERROR", { + message: "Agent intent was created but could not be recorded — retry to reuse it", + status: 500, + }); + } + + return generated; }; const updateIntentStatus = async ( @@ -348,15 +372,24 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => let generated: IdempotentResult; try { - generated = await api.generateIntent( + generated = await runIntent( + ctx.context.adapter, { - type: "agent_create", - name, - external_user_id: userId, - owner, - policy, + idempotencyKey, + intentType: "agent_create", + userId, }, - { idempotencyKey }, + () => + api.generateIntent( + { + type: "agent_create", + name, + external_user_id: userId, + owner, + policy, + }, + { idempotencyKey }, + ), ); } catch (error) { mapAgentApiError(error); @@ -375,15 +408,6 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => createdAt: new Date(), }, }); - await recordIntent(ctx.context.adapter, { - correlationId: generated.correlation_id, - idempotencyKey: generated.idempotencyKey, - intentType: "agent_create", - agentId: generated.agent_id, - userId, - status: generated.status, - expiresAt: generated.expires_at, - }); } catch (error) { console.error("[agentConnect] failed to persist agent_create state:", error); } @@ -425,14 +449,14 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => await updateIntentStatus(ctx.context.adapter, correlationId, status.status); - if (type === "grant_issue" && status.status === "SUCCESS" && intentRow.grantId) { + if (type === "grant_issue" && status.status === "SUCCESS" && intentRow.grantRowId) { const details = status.details as { grant?: { grant_id: string } | null } | undefined; const issuedGrantId = details?.grant?.grant_id; if (issuedGrantId) { try { await ctx.context.adapter.update({ model: "agentGrant", - where: [{ field: "id", operator: "eq", value: intentRow.grantId }], + where: [{ field: "id", operator: "eq", value: intentRow.grantRowId }], update: { grantId: issuedGrantId }, }); } catch (error) { @@ -441,15 +465,11 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => } } - if (type === "grant_revoke" && status.status === "SUCCESS" && intentRow.grantId) { + if (type === "grant_revoke" && status.status === "SUCCESS" && intentRow.grantRowId) { try { await ctx.context.adapter.update({ model: "agentGrant", - where: [ - { field: "agentId", operator: "eq", value: intentRow.agentId ?? "" }, - { field: "grantId", operator: "eq", value: intentRow.grantId }, - { field: "userId", operator: "eq", value: userId }, - ], + where: [{ field: "id", operator: "eq", value: intentRow.grantRowId }], update: { revokedAt: new Date() }, }); } catch (error) { @@ -574,35 +594,31 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => const idempotencyKey = createIdempotencyKey(); let generated: IdempotentResult; try { - generated = await api.generateIntent( + generated = await runIntent( + ctx.context.adapter, { - type: "grant_issue", - agent_id: agentId, - label, - credential: commitment, - expires_at: new Date(resolvedExpiryMs).toISOString(), + idempotencyKey, + intentType: "grant_issue", + agentId, + grantRowId: grantRow?.id ?? null, + userId, }, - { idempotencyKey }, + () => + api.generateIntent( + { + type: "grant_issue", + agent_id: agentId, + label, + credential: commitment, + expires_at: new Date(resolvedExpiryMs).toISOString(), + }, + { idempotencyKey }, + ), ); } catch (error) { mapAgentApiError(error); } - try { - await recordIntent(ctx.context.adapter, { - correlationId: generated.correlation_id, - idempotencyKey: generated.idempotencyKey, - intentType: "grant_issue", - agentId, - grantId: grantRow?.id ?? null, - userId, - status: generated.status, - expiresAt: generated.expires_at, - }); - } catch (error) { - console.error("[agentConnect] failed to persist grant_issue state:", error); - } - return ctx.json(generated); }, ), @@ -621,36 +637,47 @@ export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => await requireOwnedAgent(ctx.context.adapter, userId, agentId); + const grantRow = (await ctx.context.adapter.findOne({ + model: "agentGrant", + where: [ + { field: "agentId", operator: "eq", value: agentId }, + { field: "grantId", operator: "eq", value: grantId }, + { field: "userId", operator: "eq", value: userId }, + ], + })) as AgentGrantRow | null; + if (!grantRow) { + throw new APIError("NOT_FOUND", { + message: "Grant not found for this user", + status: 404, + }); + } + const idempotencyKey = createIdempotencyKey(); let generated: IdempotentResult; try { - generated = await api.generateIntent( + generated = await runIntent( + ctx.context.adapter, { - type: "grant_revoke", - agent_id: agentId, - grant_id: grantId, + idempotencyKey, + intentType: "grant_revoke", + agentId, + grantRowId: grantRow.id, + userId, }, - { idempotencyKey }, + () => + api.generateIntent( + { + type: "grant_revoke", + agent_id: agentId, + grant_id: grantId, + }, + { idempotencyKey }, + ), ); } catch (error) { mapAgentApiError(error); } - try { - await recordIntent(ctx.context.adapter, { - correlationId: generated.correlation_id, - idempotencyKey: generated.idempotencyKey, - intentType: "grant_revoke", - agentId, - grantId, - userId, - status: generated.status, - expiresAt: generated.expires_at, - }); - } catch (error) { - console.error("[agentConnect] failed to persist grant_revoke state:", error); - } - return ctx.json(generated); }, ), diff --git a/packages/better-near-auth/src/agent-schema.ts b/packages/better-near-auth/src/agent-schema.ts index bfdac58c5..a66ae285c 100644 --- a/packages/better-near-auth/src/agent-schema.ts +++ b/packages/better-near-auth/src/agent-schema.ts @@ -55,7 +55,7 @@ export const agentSchema = { type: "string", required: false, }, - grantId: { + grantRowId: { type: "string", required: false, }, diff --git a/packages/better-near-auth/src/wallet-signer.ts b/packages/better-near-auth/src/wallet-signer.ts index 51e2daf5b..a78fc69d9 100644 --- a/packages/better-near-auth/src/wallet-signer.ts +++ b/packages/better-near-auth/src/wallet-signer.ts @@ -20,8 +20,7 @@ export interface NearConnectSigner { const signers = new Map<"mainnet" | "testnet", NearConnectSigner>(); -export function setWalletSigner(signer: NearConnectSigner | null): void { - if (!signer) return; +export function setWalletSigner(signer: NearConnectSigner): void { signers.set(signer.network, signer); } diff --git a/plugins/auth/src/db/migrations/0014_agent-connect.sql b/plugins/auth/src/db/migrations/0014_agent-connect.sql index 921b8429c..27878ed6e 100644 --- a/plugins/auth/src/db/migrations/0014_agent-connect.sql +++ b/plugins/auth/src/db/migrations/0014_agent-connect.sql @@ -30,7 +30,7 @@ CREATE TABLE "owner_intent" ( "idempotency_key" text NOT NULL, "intent_type" text NOT NULL, "agent_id" text, - "grant_id" text, + "grant_row_id" text, "user_id" text NOT NULL, "status" text NOT NULL, "expires_at" timestamp with time zone NOT NULL, diff --git a/plugins/auth/src/db/migrations/meta/0013_snapshot.json b/plugins/auth/src/db/migrations/meta/0013_snapshot.json index ffbde543e..127f5b790 100644 --- a/plugins/auth/src/db/migrations/meta/0013_snapshot.json +++ b/plugins/auth/src/db/migrations/meta/0013_snapshot.json @@ -1,6 +1,6 @@ { - "id": "bbb1b325-4df5-4dc0-b58c-123a281e246a", - "prevId": "cf3818a4-7c9f-44d1-a052-fb8e5dd156f1", + "id": "2c3d6a19-5e5c-4439-aa1d-8aa34340337a", + "prevId": "77a74fab-edd3-46a2-b3b4-958a4e21d02d", "version": "7", "dialect": "postgresql", "tables": { @@ -123,6 +123,251 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_account": { + "name": "agent_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentAccount_agentId_uidx": { + "name": "agentAccount_agentId_uidx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAccount_userId_idx": { + "name": "agentAccount_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_account_user_id_user_id_fk": { + "name": "agent_account_user_id_user_id_fk", + "tableFrom": "agent_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grant": { + "name": "agent_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commitment": { + "name": "commitment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentGrant_agentId_idx": { + "name": "agentGrant_agentId_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_userId_idx": { + "name": "agentGrant_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_commitment_idx": { + "name": "agentGrant_commitment_idx", + "columns": [ + { + "expression": "commitment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_grant_user_id_user_id_fk": { + "name": "agent_grant_user_id_user_id_fk", + "tableFrom": "agent_grant", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.apikey": { "name": "apikey", "schema": "", @@ -507,109 +752,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.funded_gas_key": { - "name": "funded_gas_key", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "fundedGasKey_userId_idx": { - "name": "fundedGasKey_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "fundedGasKey_accountId_idx": { - "name": "fundedGasKey_accountId_idx", - "columns": [ - { - "expression": "account_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "funded_gas_key_user_id_user_id_fk": { - "name": "funded_gas_key_user_id_user_id_fk", - "tableFrom": "funded_gas_key", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.invitation": { "name": "invitation", "schema": "", @@ -1313,6 +1455,121 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.owner_intent": { + "name": "owner_intent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent_type": { + "name": "intent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_row_id": { + "name": "grant_row_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ownerIntent_correlationId_uidx": { + "name": "ownerIntent_correlationId_uidx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownerIntent_userId_idx": { + "name": "ownerIntent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_intent_user_id_user_id_fk": { + "name": "owner_intent_user_id_user_id_fk", + "tableFrom": "owner_intent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.passkey": { "name": "passkey", "schema": "", @@ -1745,13 +2002,6 @@ "primaryKey": false, "notNull": true }, - "member_count": { - "name": "member_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, "metadata": { "name": "metadata", "type": "text", @@ -1829,19 +2079,6 @@ "primaryKey": false, "notNull": true }, - "membership_key": { - "name": "membership_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -1903,13 +2140,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "team_member_membership_key_unique": { - "name": "team_member_membership_key_unique", - "nullsNotDistinct": false, - "columns": ["membership_key"] - } - }, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false diff --git a/plugins/auth/src/db/migrations/meta/_journal.json b/plugins/auth/src/db/migrations/meta/_journal.json index 5bd31d896..5f9b3a4e1 100644 --- a/plugins/auth/src/db/migrations/meta/_journal.json +++ b/plugins/auth/src/db/migrations/meta/_journal.json @@ -75,7 +75,7 @@ { "idx": 11, "version": "7", - "when": 1792045690836, + "when": 1792047469402, "tag": "0011_agent-connect", "breakpoints": true } diff --git a/plugins/auth/src/db/schema.ts b/plugins/auth/src/db/schema.ts index 29de00581..78cda6e1d 100644 --- a/plugins/auth/src/db/schema.ts +++ b/plugins/auth/src/db/schema.ts @@ -430,7 +430,7 @@ export const ownerIntent = pgTable( idempotencyKey: text("idempotency_key").notNull(), intentType: text("intent_type").notNull(), agentId: text("agent_id"), - grantId: text("grant_id"), + grantRowId: text("grant_row_id"), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), From c73b4a8e7dccd9bf60841edf4a37baca118747fe Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 02:05:02 -0500 Subject: [PATCH 10/20] docs(skills): fix init-upgrade journal path reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meta/_journal.json is per-workspace and resolves nowhere from the skill file — reword so the docs link check passes on the stacked branch. --- packages/everything-dev/skills/init-upgrade/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/everything-dev/skills/init-upgrade/SKILL.md b/packages/everything-dev/skills/init-upgrade/SKILL.md index 34d1ee041..51bafd0f4 100644 --- a/packages/everything-dev/skills/init-upgrade/SKILL.md +++ b/packages/everything-dev/skills/init-upgrade/SKILL.md @@ -227,7 +227,7 @@ A diverged `package.json` is never a conflict when both sides parse — it merge When both sides have `src/db/migrations/` journals, the chain is **reconciled** instead of overwritten: - **Applied entries win.** Every local journal entry is presumed applied to the database and is kept verbatim — never renumbered or rehashed. -- **Colliding upstream migrations are renumbered and appended.** An upstream entry that shares an idx or lands below the local tail but is not present locally is renumbered after the local tail in upstream order: upstream `0011_team-member-role` with a local tail at idx 12 becomes `0013_team-member-role`. The `.sql` file is copied to the new name, `meta/_snapshot.json` is renamed to match, and `meta/_journal.json` is rebuilt (local entries verbatim + appended entries carrying upstream's `when`/`version`). +- **Colliding upstream migrations are renumbered and appended.** An upstream entry that shares an idx or lands below the local tail but is not present locally is renumbered after the local tail in upstream order: upstream `0011_team-member-role` with a local tail at idx 12 becomes `0013_team-member-role`. The `.sql` file is copied to the new name, `meta/_snapshot.json` is renamed to match, and the migration journal under `meta` (`_journal.json`) is rebuilt (local entries verbatim + appended entries carrying upstream's `when`/`version`). - **Content matching keeps repeat imports convergent.** An upstream entry whose SQL content hash matches a local entry (e.g. one imported under a renumbered tag earlier) is treated as already imported — nothing is appended twice. - **Report.** The import report and console output show the kept set (applied), the appended set (`fromTag → toTag`, new idx), and what the next boot will apply versus what is already applied. Renamed snapshots record upstream intermediate schema states — review `drizzle-kit generate` output against the merged chain before generating new migrations. - **Held back.** Only unresolvable drift holds the chain now: a shared tag whose SQL content differs locally (already-applied hashes would drift), or an unreadable journal. Held chain files are not written; the rest of the workspace imports normally. A held chain gates the run even when no other file conflicts. From 905f517cfe6fc2677d451a911e83dadaa3b5583a Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 02:15:41 -0500 Subject: [PATCH 11/20] chore(auth): regenerate agent-connect migration snapshot after rebase --- .../src/db/migrations/meta/0014_snapshot.json | 2475 +++++++++++++++++ 1 file changed, 2475 insertions(+) create mode 100644 plugins/auth/src/db/migrations/meta/0014_snapshot.json diff --git a/plugins/auth/src/db/migrations/meta/0014_snapshot.json b/plugins/auth/src/db/migrations/meta/0014_snapshot.json new file mode 100644 index 000000000..261b5777d --- /dev/null +++ b/plugins/auth/src/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,2475 @@ +{ + "id": "b47cbe32-cf04-4c89-80ed-a55c3c3d206c", + "prevId": "bbb1b325-4df5-4dc0-b58c-123a281e246a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_account": { + "name": "agent_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentAccount_agentId_uidx": { + "name": "agentAccount_agentId_uidx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAccount_userId_idx": { + "name": "agentAccount_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_account_user_id_user_id_fk": { + "name": "agent_account_user_id_user_id_fk", + "tableFrom": "agent_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grant": { + "name": "agent_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commitment": { + "name": "commitment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentGrant_agentId_idx": { + "name": "agentGrant_agentId_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_userId_idx": { + "name": "agentGrant_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_commitment_idx": { + "name": "agentGrant_commitment_idx", + "columns": [ + { + "expression": "commitment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_grant_user_id_user_id_fk": { + "name": "agent_grant_user_id_user_id_fk", + "tableFrom": "agent_grant", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deviceCode_deviceCode_uidx": { + "name": "deviceCode_deviceCode_uidx", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deviceCode_userCode_uidx": { + "name": "deviceCode_userCode_uidx", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_link_claim": { + "name": "device_link_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deviceLinkClaim_tokenHash_uidx": { + "name": "deviceLinkClaim_tokenHash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funded_gas_key": { + "name": "funded_gas_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fundedGasKey_userId_idx": { + "name": "fundedGasKey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fundedGasKey_accountId_idx": { + "name": "fundedGasKey_accountId_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funded_gas_key_user_id_user_id_fk": { + "name": "funded_gas_key_user_id_user_id_fk", + "tableFrom": "funded_gas_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "near_network": { + "name": "near_network", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_teamId_idx": { + "name": "invitation_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_nearAccountId_idx": { + "name": "invitation_nearAccountId_idx", + "columns": [ + { + "expression": "near_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_nearNetwork_idx": { + "name": "invitation_nearNetwork_idx", + "columns": [ + { + "expression": "near_network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.near_account": { + "name": "near_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "near_account_user_id_user_id_fk": { + "name": "near_account_user_id_user_id_fk", + "tableFrom": "near_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_code": { + "name": "onboarding_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code": { + "name": "encrypted_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingCode_codeHash_uidx": { + "name": "onboardingCode_codeHash_uidx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboardingCode_organizationId_eventId_idx": { + "name": "onboardingCode_organizationId_eventId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_code_organization_id_organization_id_fk": { + "name": "onboarding_code_organization_id_organization_id_fk", + "tableFrom": "onboarding_code", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_code_team_id_team_id_fk": { + "name": "onboarding_code_team_id_team_id_fk", + "tableFrom": "onboarding_code", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_code_created_by_user_id_fk": { + "name": "onboarding_code_created_by_user_id_fk", + "tableFrom": "onboarding_code", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_redemption": { + "name": "onboarding_redemption", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_id": { + "name": "code_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingRedemption_codeId_userId_uidx": { + "name": "onboardingRedemption_codeId_userId_uidx", + "columns": [ + { + "expression": "code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboardingRedemption_codeId_idx": { + "name": "onboardingRedemption_codeId_idx", + "columns": [ + { + "expression": "code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_redemption_code_id_onboarding_code_id_fk": { + "name": "onboarding_redemption_code_id_onboarding_code_id_fk", + "tableFrom": "onboarding_redemption", + "tableTo": "onboarding_code", + "columnsFrom": ["code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_redemption_user_id_user_id_fk": { + "name": "onboarding_redemption_user_id_user_id_fk", + "tableFrom": "onboarding_redemption", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_requested_by_user_id_fk": { + "name": "organization_requested_by_user_id_fk", + "tableFrom": "organization", + "tableTo": "user", + "columnsFrom": ["requested_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_intent": { + "name": "owner_intent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent_type": { + "name": "intent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_row_id": { + "name": "grant_row_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ownerIntent_correlationId_uidx": { + "name": "ownerIntent_correlationId_uidx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownerIntent_userId_idx": { + "name": "ownerIntent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_intent_user_id_user_id_fk": { + "name": "owner_intent_user_id_user_id_fk", + "tableFrom": "owner_intent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relayed_transaction": { + "name": "relayed_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gas_used": { + "name": "gas_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "relayedTransaction_txHash_idx": { + "name": "relayedTransaction_txHash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "relayedTransaction_userId_idx": { + "name": "relayedTransaction_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "relayed_transaction_user_id_user_id_fk": { + "name": "relayed_transaction_user_id_user_id_fk", + "tableFrom": "relayed_transaction", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relayer_key": { + "name": "relayer_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_private_key": { + "name": "encrypted_private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_organizationId_idx": { + "name": "team_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_organization_id_organization_id_fk": { + "name": "team_organization_id_organization_id_fk", + "tableFrom": "team", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teamMember_teamId_idx": { + "name": "teamMember_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teamMember_userId_idx": { + "name": "teamMember_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_member_membership_key_unique": { + "name": "team_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": ["membership_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number_verified": { + "name": "phone_number_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_phone_number_unique": { + "name": "user_phone_number_unique", + "nullsNotDistinct": false, + "columns": ["phone_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} From 7a1f821bd8b197836a082facb30725856464a8e4 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 02:48:07 -0500 Subject: [PATCH 12/20] fix(auth): restore upstream migration chain and regenerate 0014 The rebase's snapshot/journal resolution clobbered the upstream 0011-0013 entries; restore them verbatim from the base and regenerate the agent-connect migration as 0014 from the merged schema. --- .../src/db/migrations/meta/0011_snapshot.json | 1993 +++++++++++++++++ .../src/db/migrations/meta/0013_snapshot.json | 495 ++-- .../src/db/migrations/meta/0014_snapshot.json | 208 +- .../auth/src/db/migrations/meta/_journal.json | 27 +- 4 files changed, 2304 insertions(+), 419 deletions(-) create mode 100644 plugins/auth/src/db/migrations/meta/0011_snapshot.json diff --git a/plugins/auth/src/db/migrations/meta/0011_snapshot.json b/plugins/auth/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 000000000..5318f0731 --- /dev/null +++ b/plugins/auth/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1993 @@ +{ + "id": "a5ed4552-3787-4a19-aa6a-49b7796cae75", + "prevId": "77a74fab-edd3-46a2-b3b4-958a4e21d02d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deviceCode_deviceCode_uidx": { + "name": "deviceCode_deviceCode_uidx", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deviceCode_userCode_uidx": { + "name": "deviceCode_userCode_uidx", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_link_claim": { + "name": "device_link_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deviceLinkClaim_tokenHash_uidx": { + "name": "deviceLinkClaim_tokenHash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "near_network": { + "name": "near_network", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_teamId_idx": { + "name": "invitation_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_nearAccountId_idx": { + "name": "invitation_nearAccountId_idx", + "columns": [ + { + "expression": "near_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_nearNetwork_idx": { + "name": "invitation_nearNetwork_idx", + "columns": [ + { + "expression": "near_network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.near_account": { + "name": "near_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "near_account_user_id_user_id_fk": { + "name": "near_account_user_id_user_id_fk", + "tableFrom": "near_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_code": { + "name": "onboarding_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code": { + "name": "encrypted_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingCode_codeHash_uidx": { + "name": "onboardingCode_codeHash_uidx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboardingCode_organizationId_eventId_idx": { + "name": "onboardingCode_organizationId_eventId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_code_organization_id_organization_id_fk": { + "name": "onboarding_code_organization_id_organization_id_fk", + "tableFrom": "onboarding_code", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_code_team_id_team_id_fk": { + "name": "onboarding_code_team_id_team_id_fk", + "tableFrom": "onboarding_code", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_code_created_by_user_id_fk": { + "name": "onboarding_code_created_by_user_id_fk", + "tableFrom": "onboarding_code", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_redemption": { + "name": "onboarding_redemption", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_id": { + "name": "code_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingRedemption_codeId_userId_uidx": { + "name": "onboardingRedemption_codeId_userId_uidx", + "columns": [ + { + "expression": "code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboardingRedemption_codeId_idx": { + "name": "onboardingRedemption_codeId_idx", + "columns": [ + { + "expression": "code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_redemption_code_id_onboarding_code_id_fk": { + "name": "onboarding_redemption_code_id_onboarding_code_id_fk", + "tableFrom": "onboarding_redemption", + "tableTo": "onboarding_code", + "columnsFrom": ["code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_redemption_user_id_user_id_fk": { + "name": "onboarding_redemption_user_id_user_id_fk", + "tableFrom": "onboarding_redemption", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_requested_by_user_id_fk": { + "name": "organization_requested_by_user_id_fk", + "tableFrom": "organization", + "tableTo": "user", + "columnsFrom": ["requested_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relayed_transaction": { + "name": "relayed_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gas_used": { + "name": "gas_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "relayedTransaction_txHash_idx": { + "name": "relayedTransaction_txHash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "relayedTransaction_userId_idx": { + "name": "relayedTransaction_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "relayed_transaction_user_id_user_id_fk": { + "name": "relayed_transaction_user_id_user_id_fk", + "tableFrom": "relayed_transaction", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relayer_key": { + "name": "relayer_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_private_key": { + "name": "encrypted_private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_organizationId_idx": { + "name": "team_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_organization_id_organization_id_fk": { + "name": "team_organization_id_organization_id_fk", + "tableFrom": "team", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teamMember_teamId_idx": { + "name": "teamMember_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teamMember_userId_idx": { + "name": "teamMember_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number_verified": { + "name": "phone_number_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_phone_number_unique": { + "name": "user_phone_number_unique", + "nullsNotDistinct": false, + "columns": ["phone_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/plugins/auth/src/db/migrations/meta/0013_snapshot.json b/plugins/auth/src/db/migrations/meta/0013_snapshot.json index 127f5b790..ffbde543e 100644 --- a/plugins/auth/src/db/migrations/meta/0013_snapshot.json +++ b/plugins/auth/src/db/migrations/meta/0013_snapshot.json @@ -1,6 +1,6 @@ { - "id": "2c3d6a19-5e5c-4439-aa1d-8aa34340337a", - "prevId": "77a74fab-edd3-46a2-b3b4-958a4e21d02d", + "id": "bbb1b325-4df5-4dc0-b58c-123a281e246a", + "prevId": "cf3818a4-7c9f-44d1-a052-fb8e5dd156f1", "version": "7", "dialect": "postgresql", "tables": { @@ -123,251 +123,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.agent_account": { - "name": "agent_account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_type": { - "name": "owner_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_account_id": { - "name": "owner_account_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "agentAccount_agentId_uidx": { - "name": "agentAccount_agentId_uidx", - "columns": [ - { - "expression": "agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "agentAccount_userId_idx": { - "name": "agentAccount_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "agent_account_user_id_user_id_fk": { - "name": "agent_account_user_id_user_id_fk", - "tableFrom": "agent_account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.agent_grant": { - "name": "agent_grant", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "grant_id": { - "name": "grant_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "correlation_id": { - "name": "correlation_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_ciphertext": { - "name": "token_ciphertext", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_iv": { - "name": "token_iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "commitment": { - "name": "commitment", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "agentGrant_agentId_idx": { - "name": "agentGrant_agentId_idx", - "columns": [ - { - "expression": "agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "agentGrant_userId_idx": { - "name": "agentGrant_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "agentGrant_commitment_idx": { - "name": "agentGrant_commitment_idx", - "columns": [ - { - "expression": "commitment", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "agent_grant_user_id_user_id_fk": { - "name": "agent_grant_user_id_user_id_fk", - "tableFrom": "agent_grant", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.apikey": { "name": "apikey", "schema": "", @@ -752,6 +507,109 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.funded_gas_key": { + "name": "funded_gas_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fundedGasKey_userId_idx": { + "name": "fundedGasKey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fundedGasKey_accountId_idx": { + "name": "fundedGasKey_accountId_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funded_gas_key_user_id_user_id_fk": { + "name": "funded_gas_key_user_id_user_id_fk", + "tableFrom": "funded_gas_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.invitation": { "name": "invitation", "schema": "", @@ -1455,121 +1313,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.owner_intent": { - "name": "owner_intent", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "correlation_id": { - "name": "correlation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "intent_type": { - "name": "intent_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "grant_row_id": { - "name": "grant_row_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "ownerIntent_correlationId_uidx": { - "name": "ownerIntent_correlationId_uidx", - "columns": [ - { - "expression": "correlation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "ownerIntent_userId_idx": { - "name": "ownerIntent_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "owner_intent_user_id_user_id_fk": { - "name": "owner_intent_user_id_user_id_fk", - "tableFrom": "owner_intent", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.passkey": { "name": "passkey", "schema": "", @@ -2002,6 +1745,13 @@ "primaryKey": false, "notNull": true }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "metadata": { "name": "metadata", "type": "text", @@ -2079,6 +1829,19 @@ "primaryKey": false, "notNull": true }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -2140,7 +1903,13 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "team_member_membership_key_unique": { + "name": "team_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": ["membership_key"] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false diff --git a/plugins/auth/src/db/migrations/meta/0014_snapshot.json b/plugins/auth/src/db/migrations/meta/0014_snapshot.json index 261b5777d..e0aefac71 100644 --- a/plugins/auth/src/db/migrations/meta/0014_snapshot.json +++ b/plugins/auth/src/db/migrations/meta/0014_snapshot.json @@ -1,5 +1,5 @@ { - "id": "b47cbe32-cf04-4c89-80ed-a55c3c3d206c", + "id": "416f2385-a50e-45cf-9392-356cdc33f344", "prevId": "bbb1b325-4df5-4dc0-b58c-123a281e246a", "version": "7", "dialect": "postgresql", @@ -111,8 +111,12 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -214,8 +218,12 @@ "name": "agent_account_user_id_user_id_fk", "tableFrom": "agent_account", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -356,8 +364,12 @@ "name": "agent_grant_user_id_user_id_fk", "tableFrom": "agent_grant", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -674,8 +686,12 @@ "name": "device_code_user_id_user_id_fk", "tableFrom": "device_code", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -843,8 +859,12 @@ "name": "funded_gas_key_user_id_user_id_fk", "tableFrom": "funded_gas_key", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1010,8 +1030,12 @@ "name": "invitation_organization_id_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1019,8 +1043,12 @@ "name": "invitation_inviter_id_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": ["inviter_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1104,8 +1132,12 @@ "name": "member_organization_id_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1113,8 +1145,12 @@ "name": "member_user_id_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1179,8 +1215,12 @@ "name": "near_account_user_id_user_id_fk", "tableFrom": "near_account", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1334,8 +1374,12 @@ "name": "onboarding_code_organization_id_organization_id_fk", "tableFrom": "onboarding_code", "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1343,8 +1387,12 @@ "name": "onboarding_code_team_id_team_id_fk", "tableFrom": "onboarding_code", "tableTo": "team", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1352,8 +1400,12 @@ "name": "onboarding_code_created_by_user_id_fk", "tableFrom": "onboarding_code", "tableTo": "user", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1437,8 +1489,12 @@ "name": "onboarding_redemption_code_id_onboarding_code_id_fk", "tableFrom": "onboarding_redemption", "tableTo": "onboarding_code", - "columnsFrom": ["code_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "code_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1446,8 +1502,12 @@ "name": "onboarding_redemption_user_id_user_id_fk", "tableFrom": "onboarding_redemption", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1540,8 +1600,12 @@ "name": "organization_requested_by_user_id_fk", "tableFrom": "organization", "tableTo": "user", - "columnsFrom": ["requested_by"], - "columnsTo": ["id"], + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], "onDelete": "set null", "onUpdate": "no action" } @@ -1551,7 +1615,9 @@ "organization_slug_unique": { "name": "organization_slug_unique", "nullsNotDistinct": false, - "columns": ["slug"] + "columns": [ + "slug" + ] } }, "policies": {}, @@ -1661,8 +1727,12 @@ "name": "owner_intent_user_id_user_id_fk", "tableFrom": "owner_intent", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1781,8 +1851,12 @@ "name": "passkey_user_id_user_id_fk", "tableFrom": "passkey", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1896,8 +1970,12 @@ "name": "relayed_transaction_user_id_user_id_fk", "tableFrom": "relayed_transaction", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -2065,8 +2143,12 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -2076,7 +2158,9 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": ["token"] + "columns": [ + "token" + ] } }, "policies": {}, @@ -2155,8 +2239,12 @@ "name": "team_organization_id_organization_id_fk", "tableFrom": "team", "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -2247,8 +2335,12 @@ "name": "team_member_team_id_team_id_fk", "tableFrom": "team_member", "tableTo": "team", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2256,8 +2348,12 @@ "name": "team_member_user_id_user_id_fk", "tableFrom": "team_member", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -2267,7 +2363,9 @@ "team_member_membership_key_unique": { "name": "team_member_membership_key_unique", "nullsNotDistinct": false, - "columns": ["membership_key"] + "columns": [ + "membership_key" + ] } }, "policies": {}, @@ -2381,12 +2479,16 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": ["email"] + "columns": [ + "email" + ] }, "user_phone_number_unique": { "name": "user_phone_number_unique", "nullsNotDistinct": false, - "columns": ["phone_number"] + "columns": [ + "phone_number" + ] } }, "policies": {}, @@ -2472,4 +2574,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/plugins/auth/src/db/migrations/meta/_journal.json b/plugins/auth/src/db/migrations/meta/_journal.json index 5f9b3a4e1..31650eef5 100644 --- a/plugins/auth/src/db/migrations/meta/_journal.json +++ b/plugins/auth/src/db/migrations/meta/_journal.json @@ -75,9 +75,30 @@ { "idx": 11, "version": "7", - "when": 1792047469402, - "tag": "0011_agent-connect", + "when": 1791992588711, + "tag": "0011_team-member-role", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1792004692698, + "tag": "0012_gifted_nicolaos", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1792004827601, + "tag": "0013_sleepy_komodo", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1792048625345, + "tag": "0014_agent-connect", "breakpoints": true } ] -} +} \ No newline at end of file From 239a9ae6219845e6f2ee815da3b02063b2bfc474 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 15 Oct 2026 02:48:46 -0500 Subject: [PATCH 13/20] style(auth): format regenerated migration metadata --- .../src/db/migrations/meta/0014_snapshot.json | 206 +++++------------- .../auth/src/db/migrations/meta/_journal.json | 2 +- 2 files changed, 53 insertions(+), 155 deletions(-) diff --git a/plugins/auth/src/db/migrations/meta/0014_snapshot.json b/plugins/auth/src/db/migrations/meta/0014_snapshot.json index e0aefac71..655050da3 100644 --- a/plugins/auth/src/db/migrations/meta/0014_snapshot.json +++ b/plugins/auth/src/db/migrations/meta/0014_snapshot.json @@ -111,12 +111,8 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -218,12 +214,8 @@ "name": "agent_account_user_id_user_id_fk", "tableFrom": "agent_account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -364,12 +356,8 @@ "name": "agent_grant_user_id_user_id_fk", "tableFrom": "agent_grant", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -686,12 +674,8 @@ "name": "device_code_user_id_user_id_fk", "tableFrom": "device_code", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -859,12 +843,8 @@ "name": "funded_gas_key_user_id_user_id_fk", "tableFrom": "funded_gas_key", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1030,12 +1010,8 @@ "name": "invitation_organization_id_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1043,12 +1019,8 @@ "name": "invitation_inviter_id_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviter_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1132,12 +1104,8 @@ "name": "member_organization_id_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1145,12 +1113,8 @@ "name": "member_user_id_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1215,12 +1179,8 @@ "name": "near_account_user_id_user_id_fk", "tableFrom": "near_account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1374,12 +1334,8 @@ "name": "onboarding_code_organization_id_organization_id_fk", "tableFrom": "onboarding_code", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1387,12 +1343,8 @@ "name": "onboarding_code_team_id_team_id_fk", "tableFrom": "onboarding_code", "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1400,12 +1352,8 @@ "name": "onboarding_code_created_by_user_id_fk", "tableFrom": "onboarding_code", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1489,12 +1437,8 @@ "name": "onboarding_redemption_code_id_onboarding_code_id_fk", "tableFrom": "onboarding_redemption", "tableTo": "onboarding_code", - "columnsFrom": [ - "code_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["code_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1502,12 +1446,8 @@ "name": "onboarding_redemption_user_id_user_id_fk", "tableFrom": "onboarding_redemption", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1600,12 +1540,8 @@ "name": "organization_requested_by_user_id_fk", "tableFrom": "organization", "tableTo": "user", - "columnsFrom": [ - "requested_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["requested_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1615,9 +1551,7 @@ "organization_slug_unique": { "name": "organization_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -1727,12 +1661,8 @@ "name": "owner_intent_user_id_user_id_fk", "tableFrom": "owner_intent", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1851,12 +1781,8 @@ "name": "passkey_user_id_user_id_fk", "tableFrom": "passkey", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1970,12 +1896,8 @@ "name": "relayed_transaction_user_id_user_id_fk", "tableFrom": "relayed_transaction", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2143,12 +2065,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2158,9 +2076,7 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -2239,12 +2155,8 @@ "name": "team_organization_id_organization_id_fk", "tableFrom": "team", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2335,12 +2247,8 @@ "name": "team_member_team_id_team_id_fk", "tableFrom": "team_member", "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2348,12 +2256,8 @@ "name": "team_member_user_id_user_id_fk", "tableFrom": "team_member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2363,9 +2267,7 @@ "team_member_membership_key_unique": { "name": "team_member_membership_key_unique", "nullsNotDistinct": false, - "columns": [ - "membership_key" - ] + "columns": ["membership_key"] } }, "policies": {}, @@ -2479,16 +2381,12 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] }, "user_phone_number_unique": { "name": "user_phone_number_unique", "nullsNotDistinct": false, - "columns": [ - "phone_number" - ] + "columns": ["phone_number"] } }, "policies": {}, @@ -2574,4 +2472,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/plugins/auth/src/db/migrations/meta/_journal.json b/plugins/auth/src/db/migrations/meta/_journal.json index 31650eef5..31d70eb01 100644 --- a/plugins/auth/src/db/migrations/meta/_journal.json +++ b/plugins/auth/src/db/migrations/meta/_journal.json @@ -101,4 +101,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From f3a21f33d3e33969f78226f2a4f4610cc909a934 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 13:14:27 -0500 Subject: [PATCH 14/20] style(auth): format regenerated 0015 snapshot --- .../src/db/migrations/meta/0015_snapshot.json | 212 +++++------------- 1 file changed, 53 insertions(+), 159 deletions(-) diff --git a/plugins/auth/src/db/migrations/meta/0015_snapshot.json b/plugins/auth/src/db/migrations/meta/0015_snapshot.json index 67eacc573..0da78afb0 100644 --- a/plugins/auth/src/db/migrations/meta/0015_snapshot.json +++ b/plugins/auth/src/db/migrations/meta/0015_snapshot.json @@ -111,12 +111,8 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -412,12 +408,8 @@ "name": "custody_wallet_user_id_user_id_fk", "tableFrom": "custody_wallet", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -537,12 +529,8 @@ "name": "device_code_user_id_user_id_fk", "tableFrom": "device_code", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -710,12 +698,8 @@ "name": "funded_gas_key_user_id_user_id_fk", "tableFrom": "funded_gas_key", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -881,12 +865,8 @@ "name": "invitation_organization_id_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -894,12 +874,8 @@ "name": "invitation_inviter_id_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviter_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -983,12 +959,8 @@ "name": "member_organization_id_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -996,12 +968,8 @@ "name": "member_user_id_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1066,12 +1034,8 @@ "name": "near_account_user_id_user_id_fk", "tableFrom": "near_account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1225,12 +1189,8 @@ "name": "onboarding_code_organization_id_organization_id_fk", "tableFrom": "onboarding_code", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1238,12 +1198,8 @@ "name": "onboarding_code_team_id_team_id_fk", "tableFrom": "onboarding_code", "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1251,12 +1207,8 @@ "name": "onboarding_code_created_by_user_id_fk", "tableFrom": "onboarding_code", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1340,12 +1292,8 @@ "name": "onboarding_redemption_code_id_onboarding_code_id_fk", "tableFrom": "onboarding_redemption", "tableTo": "onboarding_code", - "columnsFrom": [ - "code_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["code_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1353,12 +1301,8 @@ "name": "onboarding_redemption_user_id_user_id_fk", "tableFrom": "onboarding_redemption", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1451,12 +1395,8 @@ "name": "organization_requested_by_user_id_fk", "tableFrom": "organization", "tableTo": "user", - "columnsFrom": [ - "requested_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["requested_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1466,9 +1406,7 @@ "organization_slug_unique": { "name": "organization_slug_unique", "nullsNotDistinct": false, - "columns": [ - "slug" - ] + "columns": ["slug"] } }, "policies": {}, @@ -1583,12 +1521,8 @@ "name": "passkey_user_id_user_id_fk", "tableFrom": "passkey", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1702,12 +1636,8 @@ "name": "relayed_transaction_user_id_user_id_fk", "tableFrom": "relayed_transaction", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1875,12 +1805,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1890,9 +1816,7 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -1971,12 +1895,8 @@ "name": "team_organization_id_organization_id_fk", "tableFrom": "team", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2067,12 +1987,8 @@ "name": "team_member_team_id_team_id_fk", "tableFrom": "team_member", "tableTo": "team", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2080,12 +1996,8 @@ "name": "team_member_user_id_user_id_fk", "tableFrom": "team_member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2095,9 +2007,7 @@ "team_member_membership_key_unique": { "name": "team_member_membership_key_unique", "nullsNotDistinct": false, - "columns": [ - "membership_key" - ] + "columns": ["membership_key"] } }, "policies": {}, @@ -2211,16 +2121,12 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] }, "user_phone_number_unique": { "name": "user_phone_number_unique", "nullsNotDistinct": false, - "columns": [ - "phone_number" - ] + "columns": ["phone_number"] } }, "policies": {}, @@ -2385,12 +2291,8 @@ "name": "agent_account_user_id_user_id_fk", "tableFrom": "agent_account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2531,12 +2433,8 @@ "name": "agent_grant_user_id_user_id_fk", "tableFrom": "agent_grant", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2650,12 +2548,8 @@ "name": "owner_intent_user_id_user_id_fk", "tableFrom": "owner_intent", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } From 23451e12b65008c248f68a79462b4cb80e3f9e9b Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 13:48:15 -0500 Subject: [PATCH 15/20] docs(skills): vendor near-intents-agent-api skill from NEAR-Intents-Agent-API/skills --- .../skills/near-intents-agent-api/SKILL.md | 131 +++++ .../assets/python/near_intents_agent_api.py | 481 ++++++++++++++++++ .../assets/python/test_vectors.py | 44 ++ .../assets/rust/Cargo.toml | 33 ++ .../assets/rust/src/lib.rs | 428 ++++++++++++++++ .../assets/rust/tests/vectors.rs | 33 ++ .../assets/test-vectors.json | 122 +++++ .../references/architecture.md | 97 ++++ .../references/concepts.md | 132 +++++ .../references/errors.md | 137 +++++ .../references/examples.md | 63 +++ .../references/executions.md | 134 +++++ .../references/getting-started.md | 165 ++++++ .../references/grants.md | 85 ++++ .../references/http-api.md | 180 +++++++ .../references/other-languages.md | 210 ++++++++ .../references/owner-intents.md | 180 +++++++ .../references/policy.md | 177 +++++++ .../references/python.md | 114 +++++ .../near-intents-agent-api/references/rust.md | 105 ++++ .../references/signing.md | 151 ++++++ .../references/status-and-recovery.md | 119 +++++ .../references/typescript.md | 204 ++++++++ .../scripts/check_api.sh | 58 +++ skills-lock.json | 6 + 25 files changed, 3589 insertions(+) create mode 100644 .agents/skills/near-intents-agent-api/SKILL.md create mode 100644 .agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py create mode 100644 .agents/skills/near-intents-agent-api/assets/python/test_vectors.py create mode 100644 .agents/skills/near-intents-agent-api/assets/rust/Cargo.toml create mode 100644 .agents/skills/near-intents-agent-api/assets/rust/src/lib.rs create mode 100644 .agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs create mode 100644 .agents/skills/near-intents-agent-api/assets/test-vectors.json create mode 100644 .agents/skills/near-intents-agent-api/references/architecture.md create mode 100644 .agents/skills/near-intents-agent-api/references/concepts.md create mode 100644 .agents/skills/near-intents-agent-api/references/errors.md create mode 100644 .agents/skills/near-intents-agent-api/references/examples.md create mode 100644 .agents/skills/near-intents-agent-api/references/executions.md create mode 100644 .agents/skills/near-intents-agent-api/references/getting-started.md create mode 100644 .agents/skills/near-intents-agent-api/references/grants.md create mode 100644 .agents/skills/near-intents-agent-api/references/http-api.md create mode 100644 .agents/skills/near-intents-agent-api/references/other-languages.md create mode 100644 .agents/skills/near-intents-agent-api/references/owner-intents.md create mode 100644 .agents/skills/near-intents-agent-api/references/policy.md create mode 100644 .agents/skills/near-intents-agent-api/references/python.md create mode 100644 .agents/skills/near-intents-agent-api/references/rust.md create mode 100644 .agents/skills/near-intents-agent-api/references/signing.md create mode 100644 .agents/skills/near-intents-agent-api/references/status-and-recovery.md create mode 100644 .agents/skills/near-intents-agent-api/references/typescript.md create mode 100755 .agents/skills/near-intents-agent-api/scripts/check_api.sh diff --git a/.agents/skills/near-intents-agent-api/SKILL.md b/.agents/skills/near-intents-agent-api/SKILL.md new file mode 100644 index 000000000..a680dbb9f --- /dev/null +++ b/.agents/skills/near-intents-agent-api/SKILL.md @@ -0,0 +1,131 @@ +--- +name: near-intents-agent-api +description: Build on the NEAR Intents Agent API (api.agentsonintents.com) in any language — TypeScript, Python, Rust, Go or plain HTTP. Use when integrating AI-agent wallets on NEAR Intents into an app or backend, when code mentions `naa_` API keys, `ngt_` grant tokens, `X-Grant-Token`, `generate-intent`/`submit-intent`, `@near-intents-agent-api/sdk`, owner-signed spending policies, agent swaps/transfers/withdrawals/deposits, or when someone wants an AI agent to hold and move funds under owner-set limits. +--- + +# NEAR Intents Agent API + +A backend HTTP API that gives each AI agent its own **agent account on NEAR Intents**. The end +user (the **owner**) signs the rules once with their own wallet; the agent then swaps, transfers +and withdraws on its own, inside those rules, with no further signatures. Your backend holds one +partner API key and never touches anyone's private keys. + +| Party | Credential | Does | +|---|---|---| +| **Partner** (your backend) | API key `naa_…` from the [partner dashboard](https://partners.near-intents.org/) | Calls every endpoint, prepares owner actions, stores grants | +| **Owner** (end user) | Their wallet: NEAR account, EVM key or passkey | Signs: create account, policy, grants, freeze, approve, delete | +| **Agent** (assistant, bot, session) | Grant token `ngt_…` (held by your backend) | Moves money inside the owner's policy | + +Hosted API: `https://api.agentsonintents.com` (mainnet, real funds), also the TypeScript SDK's +default `baseUrl`. Machine-readable contract: `/openapi.json`; compact LLM guide: `/llms.txt`. + +## Non-negotiable rules + +Follow these in every integration; most bugs and lost-fund incidents come from breaking one. + +1. **API key and grant tokens are backend-only.** Never ship `naa_…`/`ngt_…` to a browser, mobile + app, prompt, log or LLM context. Browsers only see `intent.payload` + `preview` and return the + wallet's output. +2. **The owner's wallet signs `intent.payload` exactly as returned.** Pick the method from + `intent.standard` (`nep413`, `nep366`, `eip712`, `webauthn`). Never rebuild, re-serialize, + hash or reorder it; submit `{...intent, }`. +3. **Amounts are atomic integer strings** (`"1000000"` = 1 USDC at 6 decimals). Get `decimals` + and `asset_id` from `GET /v1/tokens`. Never use floats. +4. **Every money-moving call carries an `Idempotency-Key`** (8–128 chars `[A-Za-z0-9._:-]`). + Create it, persist it with the request, *then* send. Retry only with the same key and body. +5. **`UNCERTAIN` means "maybe executed".** Keep polling the original `correlation_id`. Never + resubmit with a new key; `/recover` is only for provably-undispatched operations. An + execution the provider may have received answers `202 UNCERTAIN`. A server failure + (`internal_error`, `database_busy`, `transaction_unconfirmed`) carries `meta.retryable` and + may have taken effect: repeat it under the **same** key. A missing `meta.retryable` is not + proof that nothing was sent: use a new key only after a proven refusal (see + [errors.md](references/errors.md) retry rules). +6. **`NEEDS_REVIEW` means stop.** Do not poll or retry; inspect `details.reason`, resolve, then + `GET /v1/status?correlation_id=…&refresh=true`. +7. **Policies are always sent complete**, with `expected_revision` from `GET …/policy`. On + `policy_revision_conflict`, re-read and get a fresh signature. +8. **A policy is in force only when `status: "APPLIED"` and `provider_policy_synced: true`.** + Before that, executions fail with `policy_not_ready`. +9. **Branch on `errors[0].code`**, never on `title`/`detail`. Refusal codes name the layer that + refused (grant, policy action, destination, provider policy, USD budget, schedule); only changing that + layer lifts it. +10. **Funds go only to `details.deposit_address`.** A `correlation_id` (`op_…`/`intent_…`) is a + tracking id, never an address. +11. **One grant per session/assistant/bot.** Grants say *who* may act; the account policy says + *what*. Revoke a grant to cut off one agent; `agent_freeze` stops everyone. + +## Quick start (5 minutes, any language) + +1. Create a partner API key in the **[partner dashboard](https://partners.near-intents.org/)** (**API keys**). The `naa_…` token is + shown once — put it in your backend's secret store as `NEAR_INTENTS_AGENT_API_KEY`. +2. Smoke-test: + ```bash + curl -s https://api.agentsonintents.com/v1/network # public: service health + curl -s https://api.agentsonintents.com/v1/whoami -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" + curl -s https://api.agentsonintents.com/v1/quotas -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" + ``` + Or run [scripts/check_api.sh](scripts/check_api.sh). +3. Follow the lifecycle below. Full walkthrough: [references/getting-started.md](references/getting-started.md). + +## The lifecycle + +``` +owner signs ──► agent_create (policy) ──► grant_issue (token commitment) ──► fund (deposit) + │ + agent (with X-Grant-Token) ──► swap / transfer / withdraw ──► GET /v1/status +``` + +1. **Create the account** — `POST /v1/generate-intent {type:"agent_create", name, owner, policy}` + → owner signs → `POST /v1/submit-intent` → poll `GET /v1/status` to `SUCCESS`. +2. **Issue a grant** — your backend makes a random `ngt_…` token, sends only its SHA-256 hex as + `credential` in `grant_issue` with `label` and `expires_at` (≤365 days); owner signs; you store + the token encrypted. +3. **Fund** — `POST /v1/agents/{agent_id}/deposit {origin_asset}` (API key + Idempotency-Key, no + grant; `amount` optional) returns `details.deposit_address`, `min_amount`, `expires_at`, + optional `memo`, and `refund_to` (the agent's own balance). +4. **Act** — `POST /v1/agents/{agent_id}/swap|transfer|withdraw|shield|unshield` with + `X-API-Key`, `X-Grant-Token`, `Idempotency-Key`. Quote first with `dry: true` (swap/withdraw). +5. **Observe** — `GET /v1/status?correlation_id=…&wait_ms=30000` until it stops moving. + +Every owner action (policy edits, freeze, grant revoke, approval votes, cancel, delete) is the +same generate → sign → submit → status loop. + +## Pick your language path + +| Stack | Use | Reference | +|---|---|---| +| TypeScript / Node 24+ | `npm i @near-intents-agent-api/sdk` (typed, thin, no signers) | [references/typescript.md](references/typescript.md) | +| Python | Copy [assets/python/near_intents_agent_api.py](assets/python/near_intents_agent_api.py) (client + NEAR/EVM signers) | [references/python.md](references/python.md) | +| Rust | Copy [assets/rust/](assets/rust/) (async client + signers, `evm` feature) | [references/rust.md](references/rust.md) | +| Go, Java, Kotlin, C#, Ruby, PHP, … | Plain HTTP + generate types from `/openapi.json` | [references/other-languages.md](references/other-languages.md) | + +The Python and Rust signers are verified byte-for-byte against near-api-js and viem using +[assets/test-vectors.json](assets/test-vectors.json); check any new port against the same file. + +## Where to look + +| You need to… | Read | +|---|---| +| Understand accounts, policy vs grant, who enforces what | [references/concepts.md](references/concepts.md) | +| Every endpoint, header, shape, pagination | [references/http-api.md](references/http-api.md) | +| Run owner actions, previews, expiry, cooldowns, BFF split | [references/owner-intents.md](references/owner-intents.md) | +| Sign `nep413` / `nep366` / `eip712` / `webauthn` correctly | [references/signing.md](references/signing.md) | +| Write or edit a policy (limits, destinations, USD budget, timelock, schedule, approval) | [references/policy.md](references/policy.md) | +| Issue, use, store, revoke grants | [references/grants.md](references/grants.md) | +| Swap, transfer, withdraw, deposit, shield, quotes, chains | [references/executions.md](references/executions.md) | +| Statuses, polling, idempotency, `UNCERTAIN`, `/recover`, operation proofs | [references/status-and-recovery.md](references/status-and-recovery.md) | +| Handle an error code | [references/errors.md](references/errors.md) | +| Design the integration (data model, AI tools, key ownership models, security) | [references/architecture.md](references/architecture.md) | +| Find a runnable example for a flow | [references/examples.md](references/examples.md) | + +## When writing code for a user + +- Read `NEAR_INTENTS_AGENT_API_URL`/`NEAR_INTENTS_AGENT_API_KEY` from env; the base URL is `https://api.agentsonintents.com` + unless the user runs a self-hosted or local deployment (then only the base URL changes). +- Show `preview.summary` (and `preview.policy` / `preview.deletion`) to the owner before signing. +- Read `GET /v1/network` before acting and after `quote_unavailable`, `route_unavailable` or + `provider_unavailable`; if a component is `down`, wait rather than change the request. +- Writes against mainnet move real money: build in dry-run/quote mode first and gate live + execution behind an explicit flag, as the examples do (`AGENT_ALLOW_WRITES`, `AGENT_EXECUTE`). +- Do not invent endpoints or fields. If unsure, fetch `/openapi.json` or `/llms.txt` from the + deployment and use what it says; the server is the source of truth. diff --git a/.agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py b/.agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py new file mode 100644 index 000000000..16f8c99f0 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/python/near_intents_agent_api.py @@ -0,0 +1,481 @@ +""" +Minimal Python client for the NEAR Intents Agent API, plus owner-side signers. + +Copy this file into your project. It mirrors the TypeScript SDK (`@near-intents-agent-api/sdk`): +one thin HTTP layer, JSON:API errors, explicit idempotency keys, per-grant clients. It contains no +retries and no hidden state. + + pip install httpx cryptography # client + NEAR signing + pip install eth-account # only for EVM (EIP-712) owners + +Backend only: the API key (`naa_...`) and grant tokens (`ngt_...`) never go to a browser. + +Signing helpers (`sign_nep413`, `sign_nep366`, `sign_eip712`) exist for scripts, tests and +server-held owner keys. In a real product the OWNER's wallet signs in the browser; your backend +only forwards `intent` to the frontend and the wallet output back to `submit_intent`. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import secrets +import struct +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Iterable + +import httpx + +DEFAULT_BASE_URL = "https://api.agentsonintents.com" +API_KEY_PATTERN = re.compile(r"^naa_[A-Za-z0-9_-]{43}$") +GRANT_TOKEN_PATTERN = re.compile(r"^ngt_[A-Za-z0-9_-]{43}$") +SETTLEMENT_PATHS = ("/swap", "/withdraw", "/transfer", "/shield", "/unshield") +STILL_MOVING = {"PROCESSING", "QUEUED"} + + +# ------------------------------------------------------------------------------------- errors + + +class AgentApiError(Exception): + """A non-2xx response. Branch on `code`, never on `title`.""" + + def __init__(self, status: int, document: Any, idempotency_key: str | None): + errors = document.get("errors", []) if isinstance(document, dict) else [] + first = errors[0] if errors else {} + meta = first.get("meta") or {} + self.status = status + self.code: str = first.get("code", "http_error") + self.title: str = first.get("title", f"HTTP {status}") + self.detail: str | None = first.get("detail") + self.retryable: bool = bool(meta.get("retryable", False)) + self.available_at: str | None = meta.get("available_at") + self.request_id: str | None = (document.get("meta") or {}).get("request_id") if isinstance(document, dict) else None + self.errors: list[dict[str, Any]] = errors + self.idempotency_key = idempotency_key + super().__init__(f"{status} {self.code}: {self.detail or self.title}") + + +class AgentApiRequestError(Exception): + """Transport failure. A write may still have reached the API: reconcile before retrying, + and retry only with the same `idempotency_key`.""" + + def __init__(self, cause: Exception, idempotency_key: str | None): + super().__init__(str(cause)) + self.__cause__ = cause + self.idempotency_key = idempotency_key + + +# ------------------------------------------------------------------------------------ helpers + + +def create_idempotency_key() -> str: + """One key per logical request. Persist it with the request BEFORE sending.""" + return str(uuid.uuid4()) + + +def grant_commitment(token: str) -> str: + """SHA-256 hex of the grant token: the `credential` field of a `grant_issue` intent.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_grant_credential() -> dict[str, str]: + """New grant token (`ngt_` + 32 random bytes, unpadded base64url) and its commitment. + Store the token encrypted on your backend; the API never sees it.""" + token = "ngt_" + base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + return {"token": token, "commitment": grant_commitment(token)} + + +def to_atomic(amount: str, decimals: int) -> str: + """'1.5' with 6 decimals -> '1500000'. Never use floats for money.""" + whole, _, fraction = amount.partition(".") + if len(fraction) > decimals: + raise ValueError(f"amount has more than {decimals} decimals") + return str(int((whole or "0") + fraction.ljust(decimals, "0"))) + + +def to_decimal(amount_raw: str, decimals: int) -> str: + padded = amount_raw.rjust(decimals + 1, "0") + whole, fraction = padded[: len(padded) - decimals] or "0", padded[len(padded) - decimals :] + return whole if decimals == 0 else f"{whole}.{fraction}".rstrip("0").rstrip(".") + + +# ------------------------------------------------------------------------------------- client + + +@dataclass +class AgentApi: + api_key: str + base_url: str = DEFAULT_BASE_URL + grant_token: str | None = None + timeout_s: float = 65.0 + settlement_timeout_s: float = 150.0 + _http: httpx.Client = field(default=None, repr=False) # type: ignore[assignment] + + def __post_init__(self) -> None: + if not API_KEY_PATTERN.match(self.api_key): + raise ValueError("api_key is not a naa_ key") + if self.grant_token is not None and not GRANT_TOKEN_PATTERN.match(self.grant_token): + raise ValueError("grant_token is not an ngt_ token") + self.base_url = self.base_url.rstrip("/") + if self._http is None: + self._http = httpx.Client(follow_redirects=False) + + @classmethod + def from_env(cls) -> "AgentApi": + return cls( + api_key=os.environ["NEAR_INTENTS_AGENT_API_KEY"], + base_url=os.environ.get("NEAR_INTENTS_AGENT_API_URL", DEFAULT_BASE_URL), + ) + + def for_grant(self, grant_token: str) -> "AgentApi": + """A client that sends `X-Grant-Token` on delegated calls. One per session/assistant/bot.""" + return AgentApi(self.api_key, self.base_url, grant_token, self.timeout_s, self.settlement_timeout_s, self._http) + + # -- transport --------------------------------------------------------------------------- + + def request( + self, + method: str, + path: str, + *, + query: dict[str, Any] | None = None, + body: Any = None, + idempotency_key: str | None = None, + grant: bool = False, + ) -> Any: + headers = {"accept": "application/json", "x-api-key": self.api_key} + if body is not None: + headers["content-type"] = "application/json" + if idempotency_key is not None: + headers["idempotency-key"] = idempotency_key + if grant: + if not self.grant_token: + raise ValueError("this call needs a grant: use api.for_grant(token)") + headers["x-grant-token"] = self.grant_token + params = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in (query or {}).items() if v is not None} + settlement = path.endswith(SETTLEMENT_PATHS) and not (isinstance(body, dict) and body.get("dry")) + try: + response = self._http.request( + method, + self.base_url + path, + params=params, + headers=headers, + content=None if body is None else json.dumps(body), + timeout=self.settlement_timeout_s if settlement else self.timeout_s, + ) + except httpx.HTTPError as error: + raise AgentApiRequestError(error, idempotency_key) from error + document = response.json() if response.content else None + if response.status_code >= 400: + raise AgentApiError(response.status_code, document, idempotency_key) + return document + + # -- owner intents ----------------------------------------------------------------------- + + def generate_intent(self, request: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]: + return self.request("POST", "/v1/generate-intent", body=request, idempotency_key=idempotency_key) + + def submit_intent(self, intent_type: str, correlation_id: str, signed_data: dict[str, Any]) -> dict[str, Any]: + body = {"type": intent_type, "correlation_id": correlation_id, "signed_data": signed_data} + return self.request("POST", "/v1/submit-intent", body=body) + + def get_status(self, correlation_id: str, wait_ms: int | None = None, refresh: bool | None = None) -> dict[str, Any]: + query = {"correlation_id": correlation_id, "wait_ms": wait_ms, "refresh": refresh} + return self.request("GET", "/v1/status", query=query) + + def wait_for_status(self, correlation_id: str, deadline_s: float = 300.0) -> dict[str, Any]: + """Long-polls until the operation stops moving (anything but PROCESSING/QUEUED). + UNCERTAIN: keep observing the SAME id later; never resubmit under a new key.""" + end = time.monotonic() + deadline_s + while True: + status = self.get_status(correlation_id, wait_ms=30_000) + if status["status"] not in STILL_MOVING or time.monotonic() > end: + return status + + # -- reads ------------------------------------------------------------------------------- + + def get_network(self) -> dict[str, Any]: + return self.request("GET", "/v1/network") + + def get_tokens(self) -> list[dict[str, Any]]: + return self.request("GET", "/v1/tokens")["data"] + + def whoami(self) -> dict[str, Any]: + return self.request("GET", "/v1/whoami") + + def get_quotas(self) -> dict[str, Any]: + return self.request("GET", "/v1/quotas") + + def list_agents(self, external_user_id: str | None = None, cursor: str | None = None) -> dict[str, Any]: + return self.request("GET", "/v1/agents", query={"external_user_id": external_user_id, "cursor": cursor}) + + def get_agent(self, agent_id: str) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}") + + def get_balances(self, agent_id: str, source: str = "public", asset: str | None = None) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/balances", query={"source": source, "asset": asset}) + + def get_policy(self, agent_id: str) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/policy") + + def list_grants(self, agent_id: str) -> list[dict[str, Any]]: + return self.request("GET", f"/v1/agents/{agent_id}/grants")["data"] + + def get_history( + self, agent_id: str, limit: int = 25, cursor: str | None = None, include_unpaid: bool | None = None + ) -> dict[str, Any]: + query = {"limit": limit, "cursor": cursor, "include_unpaid": include_unpaid} + return self.request("GET", f"/v1/agents/{agent_id}/history", query=query) + + def list_scheduled(self, agent_id: str, limit: int = 25, cursor: str | None = None) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/executions/scheduled", query={"limit": limit, "cursor": cursor}) + + def list_approvals(self, agent_id: str) -> list[dict[str, Any]]: + return self.request("GET", f"/v1/agents/{agent_id}/approvals")["data"] + + def get_containment(self, agent_id: str) -> dict[str, Any]: + return self.request("GET", f"/v1/agents/{agent_id}/containment") + + # -- executions (grant) ------------------------------------------------------------------ + + def quote_swap(self, agent_id: str, request: dict[str, Any]) -> dict[str, Any]: + """Dry run: no grant, no idempotency key, no budget charge.""" + return self.request("POST", f"/v1/agents/{agent_id}/swap", body={**request, "dry": True}) + + def quote_withdraw(self, agent_id: str, request: dict[str, Any]) -> dict[str, Any]: + return self.request("POST", f"/v1/agents/{agent_id}/withdraw", body={**request, "dry": True}) + + def _execute(self, agent_id: str, action: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self.request( + "POST", f"/v1/agents/{agent_id}/{action}", body=request, idempotency_key=idempotency_key, grant=True + ) + + def swap(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "swap", request, idempotency_key) + + def withdraw(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "withdraw", request, idempotency_key) + + def transfer(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "transfer", request, idempotency_key) + + def shield(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "shield", request, idempotency_key) + + def unshield(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + return self._execute(agent_id, "unshield", request, idempotency_key) + + def recover(self, agent_id: str, correlation_id: str, original: dict[str, Any], original_key: str) -> dict[str, Any]: + """Only for UNCERTAIN operations that provably never reached the provider. + `original` = the original body plus `type`; `original_key` = the original Idempotency-Key.""" + body = {"correlation_id": correlation_id, "request": original} + return self._execute(agent_id, "recover", body, original_key) + + def deposit(self, agent_id: str, request: dict[str, Any], idempotency_key: str) -> dict[str, Any]: + """Inbound address: API key + Idempotency-Key only, no grant.""" + return self.request("POST", f"/v1/agents/{agent_id}/deposit", body=request, idempotency_key=idempotency_key) + + +# ------------------------------------------------------------------------------ NEAR signing +# Exact byte formats; verified against near-api-js. Never rebuild or reorder a payload. + +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +NEP413_TAG = 2**31 + 413 # NEP-413 prefix +NEP366_TAG = 2**30 + 366 # NEP-461 prefix for delegate actions + + +def b58decode(value: str) -> bytes: + number = 0 + for char in value: + number = number * 58 + _B58.index(char) + raw = number.to_bytes((number.bit_length() + 7) // 8, "big") + return b"\x00" * (len(value) - len(value.lstrip("1"))) + raw + + +def b58encode(data: bytes) -> str: + number = int.from_bytes(data, "big") + out = "" + while number: + number, rem = divmod(number, 58) + out = _B58[rem] + out + return "1" * (len(data) - len(data.lstrip(b"\x00"))) + out + + +@dataclass +class NearKey: + """A NEAR ed25519 key from `ed25519:` (near-cli / near-api-js format).""" + + secret: str + + def __post_init__(self) -> None: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + raw = b58decode(self.secret.removeprefix("ed25519:")) + self._key = Ed25519PrivateKey.from_private_bytes(raw[:32]) + self.public_bytes = self._key.public_key().public_bytes_raw() + self.public_key = "ed25519:" + b58encode(self.public_bytes) + + def sign(self, message: bytes) -> bytes: + return self._key.sign(message) + + +def _borsh_string(value: str) -> bytes: + data = value.encode("utf-8") + return struct.pack(" bytes: + return struct.pack(" bytes: + return value.to_bytes(16, "little") + + +def sign_nep413(key: NearKey, payload: dict[str, str]) -> dict[str, str]: + """NEP-413 message signature (used for off-chain owner consent: grants, local policy edits, + approval votes, ...). Returns the extra `signed_data` fields.""" + nonce = base64.b64decode(payload["nonce"]) + assert len(nonce) == 32 + body = ( + struct.pack(" bytes: + """Byte-identical to JavaScript `JSON.stringify(value)` for API payloads (key order kept).""" + return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def encode_delegate_action( + sender_id: str, payload: dict[str, Any], public_key: bytes, nonce: int, max_block_height: int +) -> bytes: + """Borsh `DelegateAction` (without the NEP-461 prefix).""" + actions = b"" + for action in payload["actions"]: + params = action["params"] + actions += ( + b"\x02" # Action::FunctionCall + + _borsh_string(params["methodName"]) + + _borsh_bytes(js_json(params["args"])) + + struct.pack(" str: + """Base64 borsh `SignedDelegate` for an `nep366` payload, given the owner's access-key state.""" + action = encode_delegate_action(sender_id, payload, key.public_bytes, access_key_nonce + 1, block_height + 120) + signature = key.sign(hashlib.sha256(struct.pack(" dict[str, str]: + """NEP-366 gasless delegate action (on-chain owner changes; the API's sponsor pays gas).""" + rpc = httpx.post( + rpc_url, + json={ + "jsonrpc": "2.0", + "id": "access-key", + "method": "query", + "params": { + "request_type": "view_access_key", + "finality": "final", + "account_id": account_id, + "public_key": key.public_key, + }, + }, + timeout=20, + ).json() + if "error" in rpc or "error" in rpc.get("result", {}): + raise RuntimeError(f"view_access_key failed: {rpc}") + result = rpc["result"] + signed = sign_delegate(key, account_id, payload, int(result["nonce"]), int(result["block_height"])) + return {"signed_delegate": signed} + + +def sign_eip712(private_key: str, payload: dict[str, Any]) -> dict[str, str]: + """EIP-712 typed data (EVM owners). `payload` already contains `EIP712Domain`.""" + from eth_account import Account + from eth_account.messages import encode_typed_data + + signed = Account.sign_message(encode_typed_data(full_message=payload), private_key=private_key) + return {"signature": "0x" + signed.signature.hex().removeprefix("0x")} + + +def evm_owner(private_key: str, chain_id: int = 1) -> dict[str, Any]: + """Public `OwnerWallet` for an EVM key: lowercase address + 64-byte uncompressed pubkey (no 04).""" + from eth_account import Account + from eth_keys import keys + + account = Account.from_key(private_key) + public = keys.PrivateKey(bytes.fromhex(private_key.removeprefix("0x"))).public_key.to_hex() + return {"type": "evm", "address": account.address.lower(), "chain_id": chain_id, "public_key": public} + + +def near_owner(account_id: str, key: NearKey) -> dict[str, str]: + return {"type": "near", "account_id": account_id, "public_key": key.public_key} + + +def sign_intent( + generated: dict[str, Any], + *, + near_key: NearKey | None = None, + rpc_url: str = "https://free.rpc.fastnear.com", + evm_private_key: str | None = None, +) -> dict[str, Any]: + """Turns a `generate-intent` response into `signed_data`, picking the method from + `intent.standard`. The payload is echoed back unchanged.""" + intent = generated["intent"] + standard, payload = intent["standard"], intent["payload"] + if standard == "nep413": + assert near_key, "nep413 needs the NEAR owner key" + return {**intent, **sign_nep413(near_key, payload)} + if standard == "nep366": + assert near_key, "nep366 needs the NEAR owner key" + return {**intent, **sign_nep366(near_key, generated["signer"]["account_id"], payload, rpc_url)} + if standard == "eip712": + assert evm_private_key, "eip712 needs the EVM owner key" + return {**intent, **sign_eip712(evm_private_key, payload)} + raise ValueError(f"{standard} must be signed by the owner's authenticator in the browser") + + +def run_owner_intent(api: AgentApi, request: dict[str, Any], sign: Any, idempotency_key: str | None = None) -> dict[str, Any]: + """generate -> sign -> submit -> wait. `sign(generated) -> signed_data`.""" + generated = api.generate_intent(request, idempotency_key or create_idempotency_key()) + api.submit_intent(generated["type"], generated["correlation_id"], sign(generated)) + return api.wait_for_status(generated["correlation_id"]) + + +def iterate_pages(fetch: Any) -> Iterable[dict[str, Any]]: + """`for agent in iterate_pages(lambda c: api.list_agents(cursor=c))`.""" + cursor = None + while True: + page = fetch(cursor) + yield from page["data"] + cursor = page.get("next_cursor") + if not cursor: + return diff --git a/.agents/skills/near-intents-agent-api/assets/python/test_vectors.py b/.agents/skills/near-intents-agent-api/assets/python/test_vectors.py new file mode 100644 index 000000000..e5631856a --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/python/test_vectors.py @@ -0,0 +1,44 @@ +""" +Checks the Python signers against ../test-vectors.json (generated with near-api-js and viem). + + python test_vectors.py # NEAR + grant vectors; EIP-712 too if eth-account is installed + python test_vectors.py path/to/vectors.json +""" + +import json +import struct +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import near_intents_agent_api as n # noqa: E402 + +path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent.parent / "test-vectors.json" +v = json.loads(path.read_text()) + +key = n.NearKey(v["near"]["secret"]) +assert key.public_key == v["near"]["public_key"], "public key" + +assert n.sign_nep413(key, v["nep413"]["payload"])["signature"] == v["nep413"]["signature_b64"], "nep413" + +d = v["nep366"] +nonce, height = int(d["access_key_nonce"]), int(d["block_height"]) +encoded = n.encode_delegate_action(v["near"]["account_id"], d["payload"], key.public_bytes, nonce + 1, height + 120) +assert (struct.pack(", + retryable: bool, + available_at: Option, + request_id: Option, + errors: Vec, + idempotency_key: Option, + }, + /// Transport failure. A write may still have reached the API: reconcile the original + /// operation, and retry only with the same idempotency key. + #[error("transport: {source}")] + Transport { + #[source] + source: reqwest::Error, + idempotency_key: Option, + }, + #[error("{0}")] + Invalid(String), +} + +impl Error { + pub fn code(&self) -> Option<&str> { + match self { + Error::Api { code, .. } => Some(code), + _ => None, + } + } +} + +pub type Result = std::result::Result; + +// ----------------------------------------------------------------------------------- helpers + +/// One key per logical request. Persist it with the request BEFORE sending. +pub fn create_idempotency_key() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// SHA-256 hex of a grant token: the `credential` of a `grant_issue` intent. +pub fn grant_commitment(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +/// A new grant token (`ngt_` + 32 random bytes, unpadded base64url) and its commitment. +/// Store the token encrypted; the API only ever sees the commitment. +pub fn create_grant_credential() -> (String, String) { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + let token = format!("ngt_{}", B64URL.encode(bytes)); + let commitment = grant_commitment(&token); + (token, commitment) +} + +fn valid_token(value: &str, prefix: &str) -> bool { + value.len() == 47 + && value.starts_with(prefix) + && value[4..].bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +// ------------------------------------------------------------------------------------ client + +#[derive(Clone)] +pub struct AgentApi { + http: reqwest::Client, + base_url: String, + api_key: String, + grant_token: Option, +} + +impl AgentApi { + pub fn new(api_key: impl Into, base_url: Option<&str>) -> Result { + let api_key = api_key.into(); + if !valid_token(&api_key, "naa_") { + return Err(Error::Invalid("api_key is not a naa_ key".into())); + } + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|source| Error::Transport { source, idempotency_key: None })?; + Ok(Self { + http, + base_url: base_url.unwrap_or(DEFAULT_BASE_URL).trim_end_matches('/').to_string(), + api_key, + grant_token: None, + }) + } + + pub fn from_env() -> Result { + let key = std::env::var("NEAR_INTENTS_AGENT_API_KEY").map_err(|_| Error::Invalid("NEAR_INTENTS_AGENT_API_KEY missing".into()))?; + let url = std::env::var("NEAR_INTENTS_AGENT_API_URL").ok(); + Self::new(key, url.as_deref()) + } + + /// A client that sends `X-Grant-Token` on delegated calls. One per session/assistant/bot. + pub fn for_grant(&self, grant_token: impl Into) -> Result { + let grant_token = grant_token.into(); + if !valid_token(&grant_token, "ngt_") { + return Err(Error::Invalid("grant_token is not an ngt_ token".into())); + } + Ok(Self { grant_token: Some(grant_token), ..self.clone() }) + } + + pub async fn request( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option<&Value>, + idempotency_key: Option<&str>, + grant: bool, + ) -> Result { + let dry = body.and_then(|b| b.get("dry")).and_then(Value::as_bool).unwrap_or(false); + let settlement = ["/swap", "/withdraw", "/transfer", "/shield", "/unshield"] + .iter() + .any(|suffix| path.ends_with(suffix)) + && !dry; + let mut request = self + .http + .request(method, format!("{}{}", self.base_url, path)) + .query(query) + .header("accept", "application/json") + .header("x-api-key", &self.api_key) + .timeout(Duration::from_secs(if settlement { 150 } else { 65 })); + if let Some(body) = body { + request = request.json(body); + } + if let Some(key) = idempotency_key { + request = request.header("idempotency-key", key); + } + if grant { + let token = self + .grant_token + .as_deref() + .ok_or_else(|| Error::Invalid("this call needs a grant: use for_grant(token)".into()))?; + request = request.header("x-grant-token", token); + } + let transport = |source| Error::Transport { source, idempotency_key: idempotency_key.map(String::from) }; + let response = request.send().await.map_err(transport)?; + let status = response.status().as_u16(); + let bytes = response.bytes().await.map_err(transport)?; + let document: Value = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap_or(Value::Null) }; + if status >= 400 { + let first = &document["errors"][0]; + return Err(Error::Api { + status, + code: first["code"].as_str().unwrap_or("http_error").into(), + title: first["title"].as_str().unwrap_or("HTTP error").into(), + detail: first["detail"].as_str().map(String::from), + retryable: first["meta"]["retryable"].as_bool().unwrap_or(false), + available_at: first["meta"]["available_at"].as_str().map(String::from), + request_id: document["meta"]["request_id"].as_str().map(String::from), + errors: document["errors"].as_array().cloned().unwrap_or_default(), + idempotency_key: idempotency_key.map(String::from), + }); + } + Ok(document) + } + + async fn get(&self, path: &str, query: &[(&str, String)]) -> Result { + self.request(Method::GET, path, query, None, None, false).await + } + + // -- owner intents ------------------------------------------------------------------------ + + pub async fn generate_intent(&self, request: &Value, idempotency_key: &str) -> Result { + self.request(Method::POST, "/v1/generate-intent", &[], Some(request), Some(idempotency_key), false).await + } + + pub async fn submit_intent(&self, intent_type: &str, correlation_id: &str, signed_data: Value) -> Result { + let body = json!({ "type": intent_type, "correlation_id": correlation_id, "signed_data": signed_data }); + self.request(Method::POST, "/v1/submit-intent", &[], Some(&body), None, false).await + } + + pub async fn get_status(&self, correlation_id: &str, wait_ms: Option) -> Result { + let mut query = vec![("correlation_id", correlation_id.to_string())]; + if let Some(wait) = wait_ms { + query.push(("wait_ms", wait.to_string())); + } + self.get("/v1/status", &query).await + } + + /// Long-polls until the operation stops moving (anything but PROCESSING/QUEUED). + /// UNCERTAIN: keep observing the same id; never resubmit under a new key. + pub async fn wait_for_status(&self, correlation_id: &str, deadline: Duration) -> Result { + let end = Instant::now() + deadline; + loop { + let status = self.get_status(correlation_id, Some(30_000)).await?; + let moving = matches!(status["status"].as_str(), Some("PROCESSING" | "QUEUED")); + if !moving || Instant::now() > end { + return Ok(status); + } + } + } + + // -- reads --------------------------------------------------------------------------------- + + pub async fn get_network(&self) -> Result { + self.get("/v1/network", &[]).await + } + pub async fn get_tokens(&self) -> Result { + Ok(self.get("/v1/tokens", &[]).await?["data"].take()) + } + pub async fn whoami(&self) -> Result { + self.get("/v1/whoami", &[]).await + } + pub async fn get_agent(&self, agent_id: &str) -> Result { + self.get(&format!("/v1/agents/{agent_id}"), &[]).await + } + pub async fn get_policy(&self, agent_id: &str) -> Result { + self.get(&format!("/v1/agents/{agent_id}/policy"), &[]).await + } + pub async fn get_balances(&self, agent_id: &str, source: &str) -> Result { + self.get(&format!("/v1/agents/{agent_id}/balances"), &[("source", source.into())]).await + } + + // -- executions ----------------------------------------------------------------------------- + + /// Dry quote: no grant, no idempotency key, no budget charge. + pub async fn quote_swap(&self, agent_id: &str, mut request: Value) -> Result { + request["dry"] = json!(true); + self.request(Method::POST, &format!("/v1/agents/{agent_id}/swap"), &[], Some(&request), None, false).await + } + + /// `action`: swap | withdraw | transfer | shield | unshield | recover. Needs `for_grant`. + pub async fn execute(&self, agent_id: &str, action: &str, request: &Value, idempotency_key: &str) -> Result { + let path = format!("/v1/agents/{agent_id}/{action}"); + self.request(Method::POST, &path, &[], Some(request), Some(idempotency_key), true).await + } + + /// Inbound deposit address: API key + Idempotency-Key only, no grant. + pub async fn deposit(&self, agent_id: &str, request: &Value, idempotency_key: &str) -> Result { + let path = format!("/v1/agents/{agent_id}/deposit"); + self.request(Method::POST, &path, &[], Some(request), Some(idempotency_key), false).await + } +} + +// ------------------------------------------------------------------------------ NEAR signing +// Exact byte formats; verified against near-api-js. Never rebuild or reorder a payload. + +/// A NEAR ed25519 key from `ed25519:` (near-cli / near-api-js format). +pub struct NearKey { + signing: SigningKey, +} + +impl NearKey { + pub fn from_secret(secret: &str) -> Result { + let raw = bs58::decode(secret.trim_start_matches("ed25519:")) + .into_vec() + .map_err(|e| Error::Invalid(e.to_string()))?; + let seed: [u8; 32] = raw.get(..32).and_then(|s| s.try_into().ok()).ok_or_else(|| Error::Invalid("bad key".into()))?; + Ok(Self { signing: SigningKey::from_bytes(&seed) }) + } + pub fn public_bytes(&self) -> [u8; 32] { + self.signing.verifying_key().to_bytes() + } + pub fn public_key(&self) -> String { + format!("ed25519:{}", bs58::encode(self.public_bytes()).into_string()) + } + fn sign(&self, message: &[u8]) -> [u8; 64] { + self.signing.sign(message).to_bytes() + } +} + +fn borsh_string(out: &mut Vec, value: &str) { + out.extend((value.len() as u32).to_le_bytes()); + out.extend(value.as_bytes()); +} + +fn str_field<'a>(value: &'a Value, name: &str) -> Result<&'a str> { + value[name].as_str().ok_or_else(|| Error::Invalid(format!("payload.{name} missing"))) +} + +fn u_field(value: &Value, name: &str) -> Result { + str_field(value, name)?.parse().map_err(|_| Error::Invalid(format!("{name} is not an integer"))) +} + +/// NEP-413 signature for an `nep413` payload. Returns `{ public_key, signature }`. +pub fn sign_nep413(key: &NearKey, payload: &Value) -> Result { + let nonce = B64.decode(str_field(payload, "nonce")?).map_err(|e| Error::Invalid(e.to_string()))?; + if nonce.len() != 32 { + return Err(Error::Invalid("nonce must be 32 bytes".into())); + } + let mut body = NEP413_TAG.to_le_bytes().to_vec(); + borsh_string(&mut body, str_field(payload, "message")?); + body.extend(&nonce); + borsh_string(&mut body, str_field(payload, "recipient")?); + body.push(0); // callbackUrl: None + let signature = key.sign(&Sha256::digest(&body)); + Ok(json!({ "public_key": key.public_key(), "signature": B64.encode(signature) })) +} + +/// Borsh `DelegateAction` (without the NEP-461 prefix) for an `nep366` payload. +pub fn encode_delegate_action( + sender_id: &str, + payload: &Value, + public_key: &[u8; 32], + nonce: u64, + max_block_height: u64, +) -> Result> { + let actions = payload["actions"].as_array().ok_or_else(|| Error::Invalid("payload.actions missing".into()))?; + let mut out = Vec::new(); + borsh_string(&mut out, sender_id); + borsh_string(&mut out, str_field(payload, "receiverId")?); + out.extend((actions.len() as u32).to_le_bytes()); + for action in actions { + let params = &action["params"]; + out.push(2); // Action::FunctionCall + borsh_string(&mut out, str_field(params, "methodName")?); + // Same bytes as JavaScript JSON.stringify(args): compact, key order preserved. + let args = serde_json::to_vec(¶ms["args"]).map_err(|e| Error::Invalid(e.to_string()))?; + out.extend((args.len() as u32).to_le_bytes()); + out.extend(args); + out.extend((u_field(params, "gas")? as u64).to_le_bytes()); + out.extend(u_field(params, "deposit")?.to_le_bytes()); + } + out.extend(nonce.to_le_bytes()); + out.extend(max_block_height.to_le_bytes()); + out.push(0); // KeyType::ED25519 + out.extend(public_key); + Ok(out) +} + +/// Base64 borsh `SignedDelegate`, given the owner's access-key nonce and the latest block height. +pub fn sign_delegate(key: &NearKey, sender_id: &str, payload: &Value, access_key_nonce: u64, block_height: u64) -> Result { + let action = encode_delegate_action(sender_id, payload, &key.public_bytes(), access_key_nonce + 1, block_height + 120)?; + let mut prefixed = NEP366_TAG.to_le_bytes().to_vec(); + prefixed.extend(&action); + let signature = key.sign(&Sha256::digest(&prefixed)); + let mut signed = action; + signed.push(0); // KeyType::ED25519 + signed.extend(signature); + Ok(B64.encode(signed)) +} + +/// NEP-366 delegate action for an `nep366` payload. Returns `{ signed_delegate }`. +pub async fn sign_nep366(key: &NearKey, account_id: &str, payload: &Value, rpc_url: &str) -> Result { + let body = json!({ + "jsonrpc": "2.0", "id": "access-key", "method": "query", + "params": { "request_type": "view_access_key", "finality": "final", + "account_id": account_id, "public_key": key.public_key() } + }); + let rpc: Value = reqwest::Client::new() + .post(rpc_url) + .json(&body) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|source| Error::Transport { source, idempotency_key: None })? + .json() + .await + .map_err(|source| Error::Transport { source, idempotency_key: None })?; + let result = &rpc["result"]; + let (Some(nonce), Some(height)) = (result["nonce"].as_u64(), result["block_height"].as_u64()) else { + return Err(Error::Invalid(format!("view_access_key failed: {rpc}"))); + }; + Ok(json!({ "signed_delegate": sign_delegate(key, account_id, payload, nonce, height)? })) +} + +/// EIP-712 signature (0x + 65 bytes) for an `eip712` payload. The payload includes `EIP712Domain`. +#[cfg(feature = "evm")] +pub async fn sign_eip712(private_key_hex: &str, payload: &Value) -> Result { + use alloy_signer::Signer as _; + let typed: alloy_dyn_abi::TypedData = serde_json::from_value(payload.clone()).map_err(|e| Error::Invalid(e.to_string()))?; + let hash = typed.eip712_signing_hash().map_err(|e| Error::Invalid(e.to_string()))?; + let signer: alloy_signer_local::PrivateKeySigner = private_key_hex.parse().map_err(|e: alloy_signer_local::LocalSignerError| Error::Invalid(e.to_string()))?; + let signature = signer.sign_hash(&hash).await.map_err(|e| Error::Invalid(e.to_string()))?; + Ok(json!({ "signature": format!("0x{}", hex::encode(signature.as_bytes())) })) +} + +/// `signed_data` for a `generate-intent` response, chosen by `intent.standard`. +/// `webauthn` (passkeys) can only be signed by the owner's authenticator in a browser. +pub async fn sign_intent(generated: &Value, near_key: Option<&NearKey>, rpc_url: &str, evm_key: Option<&str>) -> Result { + let intent = &generated["intent"]; + let payload = &intent["payload"]; + let extra = match intent["standard"].as_str() { + Some("nep413") => sign_nep413(near_key.ok_or_else(|| Error::Invalid("nep413 needs the NEAR key".into()))?, payload)?, + Some("nep366") => { + let key = near_key.ok_or_else(|| Error::Invalid("nep366 needs the NEAR key".into()))?; + sign_nep366(key, str_field(&generated["signer"], "account_id")?, payload, rpc_url).await? + } + #[cfg(feature = "evm")] + Some("eip712") => sign_eip712(evm_key.ok_or_else(|| Error::Invalid("eip712 needs the EVM key".into()))?, payload).await?, + other => return Err(Error::Invalid(format!("cannot sign {other:?} here (evm_key: {})", evm_key.is_some()))), + }; + let mut signed = intent.clone(); + for (name, value) in extra.as_object().into_iter().flatten() { + signed[name] = value.clone(); + } + Ok(signed) +} diff --git a/.agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs b/.agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs new file mode 100644 index 000000000..8521ffc84 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/rust/tests/vectors.rs @@ -0,0 +1,33 @@ +//! Byte-for-byte checks against near-api-js / viem output. +//! Run: cargo test --features evm (reads ../test-vectors.json) +use near_intents_agent_api::*; +use serde_json::Value; + +fn vectors() -> Option { + let path = std::env::var("VECTORS").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../test-vectors.json").into()); + Some(serde_json::from_str(&std::fs::read_to_string(path).ok()?).unwrap()) +} + +#[tokio::test] +async fn matches_reference_signers() { + let Some(v) = vectors() else { return }; + let key = NearKey::from_secret(v["near"]["secret"].as_str().unwrap()).unwrap(); + assert_eq!(key.public_key(), v["near"]["public_key"]); + let nep413 = sign_nep413(&key, &v["nep413"]["payload"]).unwrap(); + assert_eq!(nep413["signature"], v["nep413"]["signature_b64"]); + let d = &v["nep366"]; + let nonce: u64 = d["access_key_nonce"].as_str().unwrap().parse().unwrap(); + let height: u64 = d["block_height"].as_str().unwrap().parse().unwrap(); + let signed = sign_delegate(&key, "owner.near", &d["payload"], nonce, height).unwrap(); + assert_eq!(signed, d["signed_delegate"].as_str().unwrap()); + #[cfg(feature = "evm")] + { + let e = &v["eip712"]; + let sig = sign_eip712(e["private_key"].as_str().unwrap(), &e["payload"]).await.unwrap(); + assert_eq!(sig["signature"], e["signature"]); + } + assert_eq!(grant_commitment(v["grant"]["token"].as_str().unwrap()), v["grant"]["commitment"]); + let (token, commitment) = create_grant_credential(); + assert_eq!(token.len(), 47); + assert_eq!(grant_commitment(&token), commitment); +} diff --git a/.agents/skills/near-intents-agent-api/assets/test-vectors.json b/.agents/skills/near-intents-agent-api/assets/test-vectors.json new file mode 100644 index 000000000..9fc6000d1 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/assets/test-vectors.json @@ -0,0 +1,122 @@ +{ + "near": { + "secret": "ed25519:2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSdZd8hbDHTd21as7EAsg7ypityqfsw2pMQKJcVDVcAEsd", + "public_key": "ed25519:9C6hybhQ6Aycep9jaUnP6uL9ZYvDjUp1aSkFWPUFJtpj", + "account_id": "owner.near" + }, + "nep413": { + "payload": { + "message": "{\"purpose\":\"grant_issue\",\"x\":\"ü\"}", + "recipient": "api.example.near", + "nonce": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=" + }, + "signature_b64": "kRPuoOdP8ZZ98mIhp8OUPlgePp2QKGXFpipHRPEl6tRUs7f+OOSrRYHVcEuzB8v8QbxspB3MPV9B2dMCdS8VCg==" + }, + "nep366": { + "payload": { + "receiverId": "outlayer.near", + "actions": [ + { + "type": "FunctionCall", + "params": { + "methodName": "set_policy", + "args": { + "z": 1, + "a": { + "b": "c", + "list": [ + 1, + "two" + ] + }, + "uni": "é" + }, + "gas": "100000000000000", + "deposit": "0" + } + }, + { + "type": "FunctionCall", + "params": { + "methodName": "freeze_wallet", + "args": {}, + "gas": "30000000000000", + "deposit": "1" + } + } + ] + }, + "access_key_nonce": "123456789", + "block_height": "987654321", + "encoded_hex": "6e0100400a0000006f776e65722e6e6561720d0000006f75746c617965722e6e65617202000000020a0000007365745f706f6c696379310000007b227a223a312c2261223a7b2262223a2263222c226c697374223a5b312c2274776f225d7d2c22756e69223a22c3a9227d00407a10f35a000000000000000000000000000000000000020d000000667265657a655f77616c6c6574020000007b7d00e057eb481b00000100000000000000000000000000000016cd5b07000000002969de3a000000000079b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "signed_delegate": "CgAAAG93bmVyLm5lYXINAAAAb3V0bGF5ZXIubmVhcgIAAAACCgAAAHNldF9wb2xpY3kxAAAAeyJ6IjoxLCJhIjp7ImIiOiJjIiwibGlzdCI6WzEsInR3byJdfSwidW5pIjoiw6kifQBAehDzWgAAAAAAAAAAAAAAAAAAAAAAAAINAAAAZnJlZXplX3dhbGxldAIAAAB7fQDgV+tIGwAAAQAAAAAAAAAAAAAAAAAAABbNWwcAAAAAKWneOgAAAAAAebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQATwCh7bJVOnxWKroYQQ8wdIfg7Pp8q1bGZD4KHFl0EGYJwoij7o7PjMVEUjPKcUYcngqx7dcJRm3ULIdczVCpDg==" + }, + "eip712": { + "private_key": "0x1111111111111111111111111111111111111111111111111111111111111111", + "address": "0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a", + "public_key": "0x4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa385b6b1b8ead809ca67454d9683fcf2ba03456d6fe2c4abe2b07f0fbdbb2f1c1", + "payload": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "WalletMessage": [ + { + "name": "chainId", + "type": "string" + }, + { + "name": "signerId", + "type": "string" + }, + { + "name": "nonce", + "type": "uint32" + }, + { + "name": "createdAt", + "type": "string" + }, + { + "name": "timeoutSecs", + "type": "uint32" + }, + { + "name": "ops", + "type": "string" + }, + { + "name": "out", + "type": "string" + } + ] + }, + "primaryType": "WalletMessage", + "domain": { + "name": "NEAR Wallet Contract", + "version": "1" + }, + "message": { + "chainId": "mainnet", + "signerId": "0s1234.near", + "nonce": 42, + "createdAt": "2026-10-07T00:00:00.000Z", + "timeoutSecs": 300, + "ops": "[]", + "out": "{\"after\":[]}" + } + }, + "signature": "0x011bb890ad576627ab6dbce119777912693a1bb16ef0dcd673d08198108cb5b245a4db114560088db56b209680788b4b5d02fcd933947b01912f59da6c1d62111c" + }, + "grant": { + "token": "ngt_CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk", + "commitment": "d632d3d1f5df65812a61000fa4cca20cce14fe14a2e3fe5d3e6bab1a445fc43f" + } +} \ No newline at end of file diff --git a/.agents/skills/near-intents-agent-api/references/architecture.md b/.agents/skills/near-intents-agent-api/references/architecture.md new file mode 100644 index 000000000..884f756bf --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/architecture.md @@ -0,0 +1,97 @@ +# Designing an integration + +## Reference architecture + +``` +┌──────────── browser / app ────────────┐ ┌──────────── your backend ────────────┐ ┌── NEAR Intents Agent API ──┐ +│ owner wallet (NEAR/EVM/passkey) │ │ naa_ key (secret store) │ │ api.agentsonintents.com │ +│ shows preview, signs intent.payload │◄──────►│ BFF routes: generate / submit / read │◄──────►│ policy, grants, dispatch │ +│ never sees naa_ / ngt_ │ │ grant tokens (encrypted) │ │ → agent wallet, 1Click │ +└───────────────────────────────────────┘ │ AI agent runtime / tools │ └───────────────────────────┘ + │ jobs: status poller, network watch │ + └──────────────────────────────────────┘ +``` + +The API never sees owner keys; the browser never sees API keys or grant tokens; the AI model +never sees either. + +## What to persist + +| Table | Columns | Why | +|---|---|---| +| `agent_accounts` | `agent_id`, `user_id`, `name`, `owner_type`, `owner_descriptor`, `created_at` | map users ↔ agents (also send `external_user_id`) | +| `owner_intents` | `correlation_id`, `type`, `agent_id`, `user_id`, `status`, `expires_at` | BFF authorization for submit; UI state | +| `grants` | `grant_id`, `agent_id`, `label`, `token_ciphertext`, `expires_at`, `revoked_at`, `actor` (session/bot id) | the agent's credential; one per actor | +| `operations` | `correlation_id`, `agent_id`, `grant_id`, `action`, `idempotency_key`, `request_body`, `status`, `failure_code`, `created_at`, `updated_at` | idempotent retries, recovery, activity feed | + +Write the `operations` row (key + body) **before** the HTTP call; update it from responses and +the poller. The API is the source of truth; your table is a cache plus your retry ledger. + +## Background jobs + +- **Status poller**: for rows not in a terminal state, `GET /v1/status?wait_ms=…` with bounded + concurrency (≤10 long-polls per key). Skip `NEEDS_REVIEW` (alert instead). +- **Network watch**: `GET /v1/network` every minute; show degraded components in your UI and + pause automated agents when `custody` or `one_click` is `down`. +- **Grant hygiene**: revoke grants of ended sessions; rotate long-lived ones. + +## Key ownership models + +| Model | Owner key held by | Use when | Notes | +|---|---|---|---| +| **User-owned key** (recommended) | the end user's wallet | consumer apps, wallets, AI assistants | user signs in the browser; you never touch the key | +| **Company-as-owner** | your company (KMS/HSM key, EVM recommended) | treasury bots, B2B automation, internal agents | sign `eip712` digests with KMS; you hold that key and control its approvals; add your own approvals around it | +| **Passkey owner** | user's authenticator | mobile/web without crypto wallets | needs your WebAuthn registration flow; no owner approval votes | + +With company-as-owner, the policy still bounds the agent, so a compromised agent runtime cannot +exceed it; protect the owner key separately from the runtime that holds grant tokens. + +## Multi-tenant SaaS + +- One API key per environment; tag every agent with `external_user_id` = your user id and + filter with `GET /v1/agents?external_user_id=`. +- Enforce in your BFF that a user can only generate/submit for their own agents and intents. +- Default limits: 100 agents / 24 h per tenant, 100 deposit addresses / 24 h per agent; plan onboarding + bursts accordingly (`/v1/quotas`). + +## AI agent tool design + +Expose a small, safe tool surface to the model; your backend maps tools to API calls with the +grant of that conversation. + +| Tool | API | Notes | +|---|---|---| +| `get_balances` | `GET …/balances` (public + confidential) | format with `decimals` | +| `get_rules` | `GET …/policy` | summarize actions, destinations, budget remaining, timelock, schedule | +| `list_tokens` | `GET /v1/tokens` | cache; resolve symbols → `asset_id` | +| `quote_swap` / `quote_withdraw` | `dry: true` | always before executing | +| `swap` / `transfer` / `withdraw` | executions | server creates the idempotency key; require explicit user confirmation for amounts above a threshold | +| `deposit_address` | `POST …/deposit` | show address, memo, min amount, expiry | +| `check_operation` | `GET /v1/status` | returns status + hashes | +| `network_status` | `GET /v1/network` | when a route/quote fails | + +Do **not** give the model tools for owner intents (policy changes, grants, freeze, delete); those +belong to the owner's UI with a wallet signature. If the model is asked to "raise my limit", +reply with what the owner must sign and link to the UI. + +Convert amounts at the boundary: the model speaks decimal ("0.5 NEAR"), the tool converts with +token `decimals` to atomic strings, rejecting more fractional digits than `decimals`. + +For MCP servers: run the MCP server on your backend (or a local process for a single user) that +holds the grant token in its environment; one grant per MCP client installation. + +## Security checklist + +- [ ] `naa_` key only in server secret storage; separate keys per environment; rotate by + creating a new key, deploying, then revoking the old one. +- [ ] Grant tokens encrypted at rest, never logged, never in prompts, URLs or client bundles. +- [ ] One grant per actor; short expiry for sessions; revoke on logout/uninstall. +- [ ] BFF checks user ↔ agent ↔ correlation_id ownership on every route. +- [ ] Owner sees `preview.summary` + full policy before signing; deletes show `assets_lost`. +- [ ] Default policy is tight: explicit assets, per-transaction limits, `destinations: only`, + USD budget, and a timelock or owner approval for payouts. +- [ ] Idempotency keys persisted before sending; no new key for `UNCERTAIN`. +- [ ] `NEEDS_REVIEW` alerts a human. +- [ ] Mainnet writes behind explicit configuration flags in dev tooling. +- [ ] Logs carry `request_id` and `correlation_id`, never secrets or signatures. +- [ ] Freeze button available to the owner at all times (no cooldown on freeze). diff --git a/.agents/skills/near-intents-agent-api/references/concepts.md b/.agents/skills/near-intents-agent-api/references/concepts.md new file mode 100644 index 000000000..164e096a1 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/concepts.md @@ -0,0 +1,132 @@ +# Concepts + +## The problem it solves + +An AI agent that pays, swaps or withdraws needs money it can move without asking a human each +time, but the human must stay in control and the integrating app must not hold the owner's key. +The API splits that into three roles: + +- **Owner** — the human who owns the funds. Their wallet signs the account's rules and every + change to them. The API and the partner never hold the owner's key. +- **Agent account** — one wallet on NEAR Intents per agent, run by the wallet provider + (a TEE-backed signer). Funds sit there. It moves money only inside the owner-signed + policy. +- **Partner** — your backend. It holds the `naa_` API key, prepares owner actions, stores grant + tokens and calls executions for its agents. + +## Account, policy, grant + +``` +agent account (1 per agent) +├── owner one wallet: NEAR | EVM | passkey (fixed at creation, no rotation) +├── policy ONE complete, owner-signed rulebook: WHAT may happen +│ actions, assets, limits, destinations, USD budget, timelock, approval, frozen +└── grants 0..50 live grants: WHO may act + each = { grant_id, label, expires_at, sha256(token) } — token held by the partner +``` + +- **Policy = what.** Shared by every grant. Turning on `withdraw` or adding a destination is one + owner signature and every grant can use it immediately. There are no per-grant limits; all + grants draw on the same limits and USD budget. +- **Grant = who.** A bearer credential (`ngt_…`) for one session, assistant, bot or device. + It carries identity and expiry only. Revoke it to cut off that one actor. +- **Freeze** stops everyone (policy `frozen: true`) and takes effect at once. + +When an execution is refused, the error code names the layer that refused it, and only that +layer can lift it: + +| Code | Layer | Who fixes it | +|---|---|---| +| `agent_grant_required` | grant (missing, revoked, expired, wrong agent) | issue a grant / use the right token | +| `wallet_frozen` | policy `frozen` | owner `agent_unfreeze` | +| `policy_action_denied` | policy `actions` / `confidential` | owner `policy_update` | +| `policy_destination_denied` | policy `destinations` | owner `policy_update` | +| `spend_budget_exceeded` | policy `budget` (USD, shared) | owner raises cap, or wait for the window | +| `policy_schedule_denied` | policy `schedule` (owner's weekly hours) | submit again at `meta.available_at`, or owner changes `schedule` | +| `policy_denied` | provider-enforced policy (assets, per-asset limits, rate, …) | owner `policy_update` | +| `policy_not_ready` | latest policy not yet in force | wait for the pending policy intent | + +A bigger USD budget never lifts a per-asset limit and a new grant never lifts a policy rule. + +## Who enforces what + +| Policy field | Enforced by | Changing only these needs | +|---|---|---| +| `destinations`, `budget`, `timelock_ms`, `schedule` | the API, before dispatch | one **off-chain** signature (`nep413`/`eip712`/`webauthn`), no transaction | +| `frozen`, `actions`, `confidential`, `owner_approval`, `assets`, `limits`, `max_actions_per_hour` | the wallet provider, on chain | one **on-chain** signature (`nep366` for NEAR owners; EVM/passkey sign `eip712`/`webauthn` and the API sponsors the call) | + +The API picks which, based on what changed; you just sign whatever `intent.standard` says. +A policy is in force only when `GET …/policy` shows `status: "APPLIED"` and +`provider_policy_synced: true`. + +## Two kinds of writes + +| | Owner intents | Executions | +|---|---|---| +| Endpoints | `POST /v1/generate-intent` → wallet signs → `POST /v1/submit-intent` | `POST /v1/agents/{id}/swap|withdraw|transfer|shield|unshield|deposit` | +| Authorized by | owner's signature on that exact payload | `X-Grant-Token` + the policy (deposit: API key only) | +| Ids | `intent_…` | `op_…` | +| Retry safety | resubmitting the same signature is idempotent | `Idempotency-Key` | + +Both return a `correlation_id`; `GET /v1/status?correlation_id=` reads either. + +## Execution pipeline + +1. **Authorize** — grant live, not frozen, action/asset/limits/destination/budget allowed, and + the `schedule` open at the moment it would run (now + `timelock_ms`). + Refusal = error response, nothing recorded. +2. **Wait** — `timelock_ms > 0` → `QUEUED` until `execute_after` (owner may `execution_cancel`). + `owner_approval` → `PENDING_APPROVAL` until the owner's `approval_vote`. An `approve` + re-runs step 1 (grant live, not frozen, destination allowed; not the schedule) before the + release and refuses the vote when it fails; a `reject` releases the budget charge. A release + outside the `schedule` fails the operation; nothing is sent. +3. **Dispatch commit** — the `schedule` is checked once more, then `dispatch_committed_at` is + set and the request goes to the provider. + From here, revoking the grant/key or freezing does **not** stop this operation. +4. **Settle** — `PROCESSING` → `SUCCESS` | `REFUNDED` | `FAILED`, or `UNCERTAIN` / + `NEEDS_REVIEW` when the outcome cannot be proven. + +## Owners + +- **NEAR**: a named account (`alice.near`) and one of its **FullAccess** ed25519 keys. Signs + `nep366` (on-chain changes, gasless: the API's sponsor pays) and `nep413` (off-chain). +- **EVM**: any secp256k1 EOA (MetaMask, Rabby, a KMS key). The API derives a deterministic NEAR + account from the public key and deploys it on first use, sponsored. Signs `eip712`. No + EIP-1271 contract wallets. **Off by default for new agents** while the EVM wallet build is a + pre-release: a deployment without it answers `owner_type_unsupported` and leaves `evm` out of + `supported_owner_types` on `GET /v1/network`. Existing EVM-owned agents keep working. +- **Passkey**: WebAuthn ES256 credential; also gets a derived NEAR account. Signs `webauthn` in + the browser only. + +The owner is fixed for the account's life: no key rotation, no recovery, no transfer of +ownership. Pick the owner type deliberately. Every owner type can use `owner_approval` and vote +with `approval_vote`; EVM and passkey votes are confirmed by the owner's `0s` wallet. + +## Money and assets + +- Assets are NEAR Intents assets identified by `asset_id` (`nep141:…`, `nep245:…`) from + `GET /v1/tokens`. Balances are per asset inside the agent's NEAR Intents account. +- **Public** balance is normal. **Confidential** balance is shielded (requires policy + `confidential: true`); `shield`/`unshield` move between them; `confidential: true` on + swap/withdraw/transfer spends from it. +- **Deposits** come in through 1Click deposit addresses on any supported chain, or directly on + NEAR. Deposits never need a grant and are always allowed. +- **Withdrawals** leave NEAR Intents to an external chain address. **Transfers** go to another + NEAR Intents account (no bridge). +- Swaps and withdrawals route through 1Click; `dry: true` returns its quote. + +## Lifecycle states + +`AgentView.status`: `PENDING` (created, not yet signed) → `ACTIVE`; `ARCHIVED` (hidden, nothing +changes on chain); `DELETED`; `ABANDONED` (creation expired or failed, never usable). + +## Cooldowns + +- Ordinary policy change: next one allowed 5 minutes after the last (per agent) → + `policy_change_throttled` (429, `meta.available_at`). Creating the account does not start it. +- Pure `agent_freeze` is never throttled (it is the emergency brake). +- `agent_unfreeze` has its own cooldown: an unfreeze is allowed 5 minutes after the previous + unfreeze → `agent_unfreeze_throttled`. So a freeze right after an unfreeze can leave the + account frozen for up to 5 minutes; check before freezing in tests and demos. +- `GET /v1/agents/{id}` → `cooldowns.policy_change_available_at` / `unfreeze_available_at` tell + you before you ask the owner to sign. diff --git a/.agents/skills/near-intents-agent-api/references/errors.md b/.agents/skills/near-intents-agent-api/references/errors.md new file mode 100644 index 000000000..b5b4851e4 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/errors.md @@ -0,0 +1,137 @@ +# Errors + +Every error is a JSON:API document: + +```json +{ + "errors": [{ + "status": "409", + "code": "policy_revision_conflict", + "title": "Policy revision conflict", + "detail": "The policy changed since you read it. Read GET /agents/{agent_id}/policy and generate again with its revision.", + "source": { "pointer": "/expected_revision" }, + "meta": { "retryable": false, "available_at": "2026-10-07T10:10:00Z" } + }], + "meta": { "request_id": "…" } +} +``` + +- Branch on `errors[0].code`. `title`/`detail` are for humans and may change. +- Validation failures (`400 validation_failed`) return **one entry per field** with + `source.pointer`; show all of them. +- `meta.retryable: true` → the same request (same idempotency key and body) can succeed after + `Retry-After`. Server failures carry it too: they may have taken effect, so only the same key is + safe. Absent → repeating will not help now. It says **nothing** about whether an earlier + attempt took effect; a new key is allowed only after a proven refusal (retry rule 4). Never + decide from `detail`. +- `meta.available_at` → when a cooldown/quota/throttle lifts. 429/503 also send `Retry-After`. +- Unknown code → fall back to the HTTP status class. Always log `meta.request_id`. + +## Decision table + +| Code | HTTP | What to do | +|---|---|---| +| **Auth & request** | | | +| `invalid_api_key` | 401 | wrong or revoked key, stray whitespace, or a base URL from a different deployment; fix config, don't retry | +| `validation_failed` | 400 | fix the fields in `source.pointer` | +| `invalid_json`, `invalid_cursor` | 400 | fix the request | +| `idempotency_key_required` | 400 | add `Idempotency-Key` | +| `idempotency_conflict` | 409 | same key used with a different body; resend original body or use a new key for a genuinely new request | +| `rate_limited` | 429 | wait `Retry-After`, retry the same request | +| `database_busy` | 503 | retryable; may have taken effect → retry with the **same** idempotency key, never a new one | +| **Refusals (layer named; only that layer can lift it)** | | | +| `agent_grant_required` | 403 | missing/revoked/expired/foreign grant → issue a grant or use the right token | +| `policy_action_denied` | 403 | action (or confidential form) not in policy → owner `policy_update` | +| `policy_destination_denied` | 403 | address not allowed → owner adds it to `destinations` | +| `policy_schedule_denied` | 403 | outside the owner's `schedule` when it would run (after the timelock); nothing recorded → submit the same request at `meta.available_at`, or owner changes `schedule` | +| `spend_budget_exceeded` | 403 | shared USD budget used up → wait for window (`usage.budget.*.resets_at`) or owner raises cap; retry with a **new** key | +| `policy_denied` | 409 | provider-side policy refused (asset, per-asset limit, hourly rate, …) → owner `policy_update` | +| `wallet_frozen` | 409 | owner froze the account → only `agent_unfreeze` resumes | +| `policy_not_ready` | 409 | latest policy not yet in force → wait for the policy intent to settle | +| `signing_policy_denied` | 403 | recipient not in the policy's `sign.recipients` | +| `signing_recipient_forbidden` | 403 | `intents.near`/`intents.far` are never signing recipients | +| **Execution / routing** | | | +| `insufficient_balance` | 409 | fund the account or lower amount | +| `amount_too_low` | 400 | below route minimum → quote a larger amount | +| `route_unavailable` | 400 | no route/liquidity now; nothing submitted → other pair or later, new key | +| `quote_unavailable` | 503 | upstream quote got no answer; nothing submitted → retry shortly, new key; check `/v1/network` | +| `unsupported_chain`, `unsupported_token`, `invalid_address` | 400/409 | change chain/asset/address | +| `wrapped_unsupported` | 400 | drop `wrapped`; it only applies to NEAR withdrawn to chain `near` | +| `wallet_busy` | 409 | another money op on this wallet is running; nothing sent, refusal recorded (not retryable) → after it finishes, a **new** key | +| `spend_price_unavailable` | 503 | no fresh USD price for budget accounting → retry later, new key | +| `authorization_stale` | 409 | grant/key revoked or policy/owner changed between admission and dispatch → re-evaluate | +| `deposit_quota_exceeded` | 429 | 100 deposit addresses / 24 h for this agent → wait `available_at` | +| **Provider (wallet service/1Click) state** | | | +| `provider_unavailable` | 503 | provider down/breaker open → wait; read `/v1/network` | +| `provider_limit_reached` | 429 | custody provider's daily quota for this wallet: 100 operations / rolling 24 h, `dry` previews included, quotes not; nothing sent → new work at `available_at` (don't burn the quota on repeated previews) | +| `provider_rate_limited` | 429 | reads: wait `Retry-After`, same request; executions: recorded refusal → new key after a short wait | +| `provider_refused` | 409 | provider refused for its own reason → inspect `detail`, don't blind-retry | +| `provider_response_invalid`, `provider_request_mismatch` | 502 | treat outcome as unknown → poll status of the same id | +| **Owner intents** | | | +| `intent_expired` | 409 | generate a new intent | +| `intent_payload_mismatch`, `intent_standard_mismatch`, `intent_type_mismatch` | 409 | submit exactly what was generated, with the right standard/type | +| `signature_invalid` | 400 | malformed wallet output → submit exactly what the wallet returned | +| `signer_mismatch`, `owner_proof_invalid`, `policy_delegate_signature_invalid` | 401/409 | wrong key or altered bytes (see [signing.md](signing.md)) | +| `owner_key_not_full_access` | 403 | NEAR owner must sign with a FullAccess key | +| `owner_nonce_invalid` | 409 | intent used/expired → regenerate | +| `intent_generation_recovery_required` | 409 | retry `generate-intent` with the same idempotency key | +| `policy_revision_conflict` | 409 | re-read policy, regenerate with new `expected_revision`, re-sign | +| `policy_reconciliation_required` | 409 | another policy intent still open → wait | +| `policy_change_throttled`, `agent_unfreeze_throttled` | 429 | cooldown → wait `available_at` | +| `wallet_already_frozen`, `wallet_already_unfrozen` | 409 | no-op; state already as requested | +| `owner_type_unsupported` | 400 | this deployment does not accept that owner type for new agents (check `supported_owner_types` on `GET /v1/network`; EVM is off by default until the final EVM wallet build) | +| `agent_creation_quota_exceeded` | 429 | 100 agents/24 h → wait or request quota | +| `agent_not_bound` | 409 | onboarding not finished → wait for `agent_create` SUCCESS | +| `agent_archived`, `agent_deleted`, `agent_deletion_pending` | 409 | lifecycle state forbids it; restore or stop | +| `pause_before_archive`, `unpause_before_restore` | 409 | `agent_freeze` before `agent_archive`; `agent_unfreeze` before `agent_restore` | +| `sponsor_busy`, `sponsor_balance_insufficient` | 503 | retry the same submit later | +| `transaction_unconfirmed` | 503 | retryable; broadcast but unconfirmed → poll status, or repeat with the **same** key (never sends twice); never as new work | +| `execution_not_cancellable` | 409 | op already left `QUEUED` | +| `approval_closed`, `approval_already_voted`, `approval_not_allowed` | 409 | read the approval's current state | +| `approval_signature_invalid` | 401 | the vote was not signed by the owner's key or wallet → regenerate and sign again | +| `approval_vote_unsupported` | 409 | the provider lists no such owner wallet build on this network → ask the operator; nothing was stored | +| **Grants** | | | +| `grant_limit_reached` | 409 | 50 live grants → revoke unused | +| `grant_credential_reused` | 409 | new token per grant | +| `grant_expiry_invalid` | 409 | `expires_at` must be future and ≤365 days | +| **Recovery** | | | +| `operation_not_recoverable` | 409 | not UNCERTAIN, already dispatched, non-recoverable kind, or other API key → keep polling | +| `operation_recovery_unavailable` | 409 | another recovery running → poll | +| **Not found** | 404 | `agent_not_found`, `grant_not_found`, `intent_not_found`, `operation_not_found`, `status_not_found`, `approval_not_found` | +| **Disabled** | | | +| `transparency_log_disabled` | 501 | this deployment keeps no transparency log; operation proofs are unavailable | + +## Retry rules + +1. **Client timeout or network error on a write**: you don't know if it landed. Retry with the + **same** `Idempotency-Key` and identical body (safe), or read status if you got a + `correlation_id`. Never invent a new key for the same intent to "unstick" it. +2. **Refusal codes (403, most 409)**: the request is wrong for the current state. Retrying the + same thing won't help; change state (policy, grant, balance) or the request. +3. **Server failures** (`internal_error` 500, `database_busy` 503, `transaction_unconfirmed` 503, + all with `meta.retryable`): the request may have taken effect. Repeat it with the **same** key, + which replays or reconciles it, or read status. Never a new key: that can spend twice. +4. **Proven refusals** (`route_unavailable`, `quote_unavailable`, `spend_budget_exceeded`, + `spend_price_unavailable`, `wallet_busy`, and the policy/grant/validation refusals in the + decision table): nothing was sent and the key replays the refusal; a later attempt is a new + request → **new** idempotency key. Only these permit a new key. A `FAILED` or `REFUNDED` + status is **not** such proof: a partial failure is `FAILED`. Read its `details` and the + balances, and send new work only as an explicit decision, never as an automatic retry. +5. **Earlier work under the key** (`intent_generation_recovery_required`, + `operation_not_recoverable`, `operation_not_pending`, any error from `/recover`, and any error + after a server failure or lost response): the original outcome may still be unknown. Keep the original key and + `correlation_id`, read status, and never start the same work under a new key. +6. **Throttles** (429): wait until `available_at` / `Retry-After`. +7. **Status-level outcomes** (`FAILED`, `REFUNDED`, `UNCERTAIN`, `NEEDS_REVIEW`) are not HTTP + errors; see [status-and-recovery.md](status-and-recovery.md). + +## Wrapping errors in your code + +TypeScript SDK: `AgentApiError { status, code, title, detail, retryable, availableAt, requestId, +errors, idempotencyKey }`; transport failures throw `AgentApiRequestError { idempotencyKey }`. +The Python/Rust assets mirror this (`AgentApiError.code`, `.available_at`, …; Rust +`Error::Api { … }` with `error.code()`). + +For AI agents, convert errors into short tool results that name the layer and the fix, e.g. +`"refused by account policy: withdraw is not enabled. Ask the owner to allow withdrawals."` — +never echo API keys, grant tokens, or full request headers into model context. diff --git a/.agents/skills/near-intents-agent-api/references/examples.md b/.agents/skills/near-intents-agent-api/references/examples.md new file mode 100644 index 000000000..f61a75040 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/examples.md @@ -0,0 +1,63 @@ +# Runnable examples + +Repository: (TypeScript, Node 24+, pnpm, +`@near-intents-agent-api/sdk`, `near-api-js`, `viem`). Every flow in this skill has a script there; +port the script when writing another language. + +```bash +git clone https://github.com/NEAR-Intents-Agent-API/examples && cd examples +cp .env.example .env # NEAR_INTENTS_AGENT_API_KEY from the partner dashboard; owner keys for writes +pnpm install +pnpm 01:check-api +``` + +Safety switches: writes refuse to run unless `AGENT_ALLOW_WRITES=true`; swaps, withdrawals and +other live actions stay in preview unless `AGENT_EXECUTE=true`. The hosted API is mainnet: use a +dedicated low-value owner account. + +| Script | File | Shows | +|---|---|---| +| `01:check-api` | `01-getting-started/01-check-api.ts` | network, whoami, quotas, token catalogue | +| `01:list-tokens` | `01-getting-started/02-list-tokens.ts` | token catalogue, decimals, prices | +| `02:create-agent` | `02-your-first-agent/01-create-agent.ts` | `agent_create` with a NEAR owner (NEP-366) | +| `02:inspect-agent` | `02-your-first-agent/02-inspect-agent.ts` | agent, wallet, NEAR address, policy usage and history, balances, grants, history, provider records | +| `02:multiple-agents` | `02-your-first-agent/03-multiple-agents.ts` | several agents per owner | +| `03:near-policy-local` | `03-owner-wallets/near/01-policy-local.ts` | budget/timelock-only edit (NEP-413, off chain) | +| `03:near-policy-provider` | `03-owner-wallets/near/02-policy-provider.ts` | provider-rule edit (NEP-366, on chain) | +| `03:evm-create-agent` | `03-owner-wallets/evm/01-create-agent.ts` | EVM owner onboarding (EIP-712) | +| `03:evm-policy-update` | `03-owner-wallets/evm/02-policy-update.ts` | EVM policy edit | +| `04:issue-grant` | `04-grants/01-issue-grant.ts` | token + commitment, `grant_issue` | +| `04:delegate-client` | `04-grants/02-delegate-client.ts` | `forGrant` client, dry swap | +| `04:revoke-and-audit` | `04-grants/03-revoke-and-audit.ts` | `grant_revoke`, containment | +| `05:quote-and-swap` | `05-money-moves/01-quote-and-swap.ts` | dry quote, swap | +| `05:transfer` | `05-money-moves/02-transfer.ts` | transfer to a NEAR Intents account | +| `05:withdraw` | `05-money-moves/03-withdraw.ts` | dry withdraw, withdraw | +| `05:deposit` | `05-money-moves/04-deposit.ts` | deposit address, min amount, memo, expiry | +| `05:private-balance` | `05-money-moves/05-private-balance.ts` | shield / unshield | +| `05:approvals` | `05-money-moves/06-approvals.ts` | `owner_approval`, approval list, `approval_vote` | +| `06:edit-policy` | `06-policy-and-lifecycle/01-edit-policy.ts` | read → modify → sign complete policy | +| `06:freeze` | `06-policy-and-lifecycle/02-freeze-and-unfreeze.ts` | emergency stop, cooldown-aware | +| `06:archive-and-delete` | `06-policy-and-lifecycle/03-archive-and-delete.ts` | archive, restore, delete preview | +| `06:schedule` | `06-policy-and-lifecycle/04-schedule.ts` | weekly hours, `policy_schedule_denied` | +| `07:enable-signing` | `07-identity-signing/01-enable-signing.ts` | owner adds a `sign` recipient (on chain) | +| `07:sign-and-verify` | `07-identity-signing/02-sign-and-verify.ts` | challenge → `sign` → relying-party verification | +| `07:refusals` | `07-identity-signing/03-refusals.ts` | every signing refusal, incl. `intents.near`/`intents.far` | +| `07:disable-signing` | `07-identity-signing/04-disable-signing.ts` | remove one recipient or `sign` entirely | +| `08:error-taxonomy` | `08-errors-and-recovery/01-error-taxonomy.ts` | real error documents | +| `08:status-and-uncertain` | `08-errors-and-recovery/02-status-and-uncertain.ts` | classify a status; when recovery is legal | +| `08:idempotency` | `08-errors-and-recovery/03-idempotency.ts` | replay with the same key | +| `08:recover` | `08-errors-and-recovery/04-recover.ts` | when `/recover` is legal, with the original key and grant | +| `09:reads` / `09:owner-flow` / `09:execution` / `09:sign` | `09-raw-http/*` | the same flows with plain `fetch` (best template for other languages) | +| `10:bff-pattern` | `10-recipes/01-bff-pattern.ts` | browser ↔ backend boundary | +| `10:timelocked-payout` | `10-recipes/02-timelocked-payout.ts` | timelock, scheduled list, cancel | +| `10:ai-assistant` | `10-recipes/03-ai-assistant.ts` | grant per assistant, tool → API mapping | +| `10:full-journey` | `10-recipes/04-full-journey.ts` | create → grant → fund → quote → swap | +| `11:verify-operation` | `11-verify-operations/01-verify-operation.ts` | operation proof and its evidence, `verifyOperationProof` offline | + +Useful support modules to copy: `support/sign-near-intent.ts` (server-side NEAR signer: +NEP-413 + NEP-366), `support/evm-owner.ts` (EVM owner descriptor + EIP-712), +`support/policy.ts` (policy builders), `support/destinations.ts`, `support/flow.ts` +(generate → sign → submit → wait), `support/raw-http.ts` (fetch client with JSON:API errors), +`support/identity.ts` (identity challenges and NEP-413 signature verification). + +Python and Rust equivalents of the client and signers ship with this skill in `assets/`. diff --git a/.agents/skills/near-intents-agent-api/references/executions.md b/.agents/skills/near-intents-agent-api/references/executions.md new file mode 100644 index 000000000..8c33e639a --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/executions.md @@ -0,0 +1,134 @@ +# Executions: moving money + +All are `POST /v1/agents/{agent_id}/` and return `202` with a `StatusResponse` +(`correlation_id: "op_…"`), except dry quotes (`200`). + +| Action | Headers | Body | Policy needs | +|---|---|---|---| +| `swap` | key, grant, idem | `origin_asset`, `destination_asset`, `amount`, `min_amount_out?`, `confidential?`, `dry?` | `swap` (+`confidential`) | +| `withdraw` | key, grant, idem | `asset`, `amount`, `chain`, `recipient`, `memo?`, `confidential?`, `wrapped?`, `async?`, `dry?` | `withdraw`, destination allowed | +| `transfer` | key, grant, idem | `asset`, `amount`, `recipient`, `confidential?` | `transfer`, destination allowed | +| `shield` / `unshield` | key, grant, idem | `asset`, `amount` | `confidential: true` | +| `deposit` | key, idem | `origin_asset`, `destination_asset?`, `amount?`, `confidential?` | nothing (always allowed) | +| `recover` | key, grant, original idem | `correlation_id`, `request: {type, …original body}` | re-authorized in full | + +`amount` is atomic (integer string). Asset ids come from `GET /v1/tokens`. + +## Quote first + +```json +POST …/swap { "origin_asset": "nep141:wrap.near", "destination_asset": "", "amount": "1000000000000000000000000", "dry": true } +POST …/withdraw { "asset": "", "amount": "5000000", "chain": "base", "recipient": "0xabc…", "dry": true } +→ 200 { "dry": true, "type": "swap", "quote": { …1Click quote, snake_cased… } } +``` + +Dry runs need no grant and no idempotency key, charge no budget and move nothing. The `quote` +object belongs to 1Click (amount out, fees, time estimate); treat its fields as informational +and do not hard-code its shape. Use it to show the user expected output and to choose +`min_amount_out` (slippage floor). + +## Execute + +```http +POST /v1/agents/{agent_id}/swap +X-API-Key: naa_… +X-Grant-Token: ngt_… +Idempotency-Key: swap:user123:2026-10-07T10:00:00Z:7f3c +Content-Type: application/json + +{ "origin_asset": "nep141:wrap.near", "destination_asset": "", "amount": "1000000000000000000000000", "min_amount_out": "2900000" } +``` + +Then poll `GET /v1/status?correlation_id=op_…&wait_ms=30000` (see +[status-and-recovery.md](status-and-recovery.md)). Typical paths: + +- no timelock/approval: `PROCESSING` → `SUCCESS` (`details.amount_out`, `intent_hash`, `tx_hash`) +- timelock: `QUEUED` (`details.execute_after`) → `PROCESSING` → … +- approval: `PENDING_APPROVAL` (`details.approval_id`) → owner votes → `PROCESSING` → … +- route refunded: `REFUNDED` (`details.refund_tx_hash`); never filled: `FAILED` with `failure_code` + +The execution call holds the HTTP request open up to ~2 minutes while the provider answers; give +the client a ~150 s timeout. `withdraw` with `async: true` returns as soon as it is accepted. + +## Swap + +- Inside the account; funds never leave NEAR Intents. Needs no destination rule. +- `min_amount_out`: atomic floor of the destination asset; the swap refunds rather than fill + below it. +- `amount_too_low`: below the route minimum (confidential swaps have their own minimum). +- `route_unavailable` / `quote_unavailable`: nothing was submitted; retry later with a **new** + idempotency key or pick another pair. + +## Withdraw + +- Leaves NEAR Intents to `recipient` on `chain`. Public and confidential withdrawals reach every + `blockchain` value `GET /v1/tokens` lists (`near`, `eth`, `sol`, `btc`, `tron`, …). +- `memo` for chains/exchanges that need one (the policy's `only` rule matches memo exactly). +- NEAR (`nep141:wrap.near`, or `near`) withdrawn to chain `near` arrives as **native NEAR**, as from + a NEAR wallet or 1Click: no `wrap.near` registration needed, and what exchanges and invoices + expect. `wrapped: true` delivers wNEAR instead; only for that route (`wrapped_unsupported`). +- `unsupported_chain` means a chain the catalog doesn't list; `route_unavailable` means no + liquidity for that asset now; `invalid_address` when the provider rejects the address format. +- `details.destination_tx_hash` appears once the destination chain confirms. + +## Transfer + +- To another NEAR Intents account (`recipient` = NEAR account id), no bridge, near-instant. + A recipient is a named account (`alice.near`), a 64-hex implicit account or a lowercase `0x` + EVM address. +- `confidential: true` spends from and to confidential balances. Shield first if the agent's + confidential balance is short. +- **near.com addresses.** near.com shows its users' Intents account as `nearcom:` + (e.g. `nearcom:0x20b6…2367`). The API takes only the bare account: strip `nearcom:` and + lowercase the rest. near.com users see their confidential balance, so send them a + `confidential: true` transfer by default. A public transfer still works when the user asks for + one, but it lands in the recipient's legacy public balance, which near.com does not show by + default: tell the user before sending. A `nearcom:` address is not on any chain: never + `withdraw` to it. + +## Shield / unshield + +Move `amount` of `asset` between the public and confidential balance of the same account. +Read both with `GET …/balances?source=public|confidential`. + +## Deposit (fund the account) + +```http +POST /v1/agents/{agent_id}/deposit +X-API-Key: naa_… +Idempotency-Key: dep:user123: +{ "origin_asset": "", "amount": "25000000" } +``` + +- `origin_asset` is required: the token and chain the payer sends from. +- `amount` is optional. Without it, any payment ≥ `details.min_amount` before + `details.expires_at` is credited; with it, the quote is exact. +- `destination_asset` optional: swap to another asset on arrival. `confidential: true` credits the + confidential balance. +- There is **no `refund_to` request field**: refunds always go back to the agent account; + `details.refund_to` reports where. +- Response `PENDING_DEPOSIT` with `details.deposit_address` (send funds **only** there), + `details.memo` (include it if present), `details.min_amount`, `details.expires_at`. +- Then poll the `op_…` id: `PENDING_DEPOSIT` → `PROCESSING` → `SUCCESS`. +- Without `amount` an address stays open 3 days; with it, 24 h. Each lookup issues a new address. +- Quota: 100 deposit addresses per agent per rolling 24 h → `deposit_quota_exceeded` (429). +- Native NEAR-side funding: `GET …/addresses/near` returns the account; send NEP-141 tokens to it. + +Never show the `correlation_id` as a payment address. Use a fresh idempotency key per deposit +request you want (reusing the key returns the same address). + +## Recover (rare) + +Only for `UNCERTAIN` operations that provably never reached the provider (no +`provider_request_id`, no `dispatch_committed_at`). Same API key, same grant token, same +`Idempotency-Key`, and `request` = the original body plus `type`. See +[status-and-recovery.md](status-and-recovery.md). + +## Pre-flight checklist for an agent tool + +1. `GET …/policy` in force? action allowed? destination listed? budget `remaining_usd` enough? +2. Balance enough? (`GET …/balances`) +3. Dry quote (swap/withdraw) and show it. +4. Create + persist the idempotency key and body, then send. +5. Poll to a stable status, report `SUCCESS`/`REFUNDED`/`FAILED` with hashes; for `UNCERTAIN` + say "outcome pending" and keep observing. diff --git a/.agents/skills/near-intents-agent-api/references/getting-started.md b/.agents/skills/near-intents-agent-api/references/getting-started.md new file mode 100644 index 000000000..254ad308c --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/getting-started.md @@ -0,0 +1,165 @@ +# Getting started: from partner key to a working agent + +This walks a new partner from zero to an agent that can move money. Every step is plain HTTP, so +it works the same from any language; SDK equivalents are in the language references. + +## 0. What you need + +| Thing | Where it comes from | Where it lives | +|---|---|---| +| API base URL | `https://api.agentsonintents.com` (mainnet) | config `NEAR_INTENTS_AGENT_API_URL` | +| Partner API key `naa_…` | Partner dashboard → **API keys** | backend secret store `NEAR_INTENTS_AGENT_API_KEY` | +| Owner wallet | The end user: NEAR account, EVM wallet or passkey | the user's device; never your server (unless you deliberately run the owner key, see [architecture.md](architecture.md)) | +| Grant token `ngt_…` | Generated by your backend, one per agent session | your database, encrypted | + +The hosted API is **mainnet with real funds**. Use small amounts and a dedicated owner account +while building. + +## 1. Get a partner API key + +1. Open the [partner dashboard](https://partners.near-intents.org/) and sign in. +2. Go to **API keys** → create a key, name it after the environment (`prod-backend`, + `staging`). +3. Copy the `naa_…` token **now**. It is shown once and only its prefix is stored. Keys do not + expire; revoke and replace them from the same page. + +The dashboard also shows **Overview** (operation counters), **Agents** (every agent your keys +created, with cooldowns), **Health** and **Settings**. A tenant can hold up to 10 unrevoked keys. +Revoking a key stops new requests at once; operations already past the dispatch commit still +finish and the revoke response lists them. + +Use one key per backend environment. All keys of a tenant see the same agents; agents belong to +the tenant, not the key, except that `/recover` must use the key that admitted the operation. + +## 2. Smoke test + +```bash +export NEAR_INTENTS_AGENT_API_URL=https://api.agentsonintents.com +export NEAR_INTENTS_AGENT_API_KEY=naa_... + +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/network" # public health + owner types +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/whoami" -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" # {api_key_id, tenant_id} +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/quotas" -H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" # limits and usage +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/tokens" | head -c 600 # asset catalogue +``` + +Or run `scripts/check_api.sh` from this skill. A `401 invalid_api_key` means the key is wrong, +revoked, has stray whitespace, or was sent to a different deployment than the one that issued it. + +Default partner limits (check `/v1/quotas` for yours): 100 new agents per rolling 24 h, 10 API +keys, a per-minute request rate per tenant and per key, 100 deposit addresses per agent per 24 h, 10 +concurrent `wait_ms` long-polls per key, 50 live grants per agent. + +## 3. Pick tokens + +`GET /v1/tokens` returns `{ data: [{ asset_id, symbol, decimals, blockchain, price, … }] }`. +Everything (policy assets, limits, executions) uses `asset_id`, for example +`nep141:wrap.near` (wNEAR, 24 decimals) or the USDC id for the chain you want. Amounts are +atomic strings: `to_atomic("1.5", 24)`. `price` is USD per whole token, `null` when unknown. + +## 4. Create the agent account (owner signs once) + +```http +POST /v1/generate-intent +X-API-Key: naa_… +Content-Type: application/json + +{ + "type": "agent_create", + "name": "Trading assistant", + "external_user_id": "user_123", + "owner": { "type": "near", "account_id": "alice.near", "public_key": "ed25519:…" }, + "policy": { + "frozen": false, + "actions": ["swap"], + "confidential": false, + "owner_approval": false, + "assets": ["nep141:wrap.near", ""], + "limits": { "per_transaction": { "nep141:wrap.near": "5000000000000000000000000" } }, + "max_actions_per_hour": 20, + "destinations": { "mode": "only", "list": [] }, + "budget": { "daily_usd": "50", "weekly_usd": null, "monthly_usd": "500" }, + "timelock_ms": 0 + } +} +``` + +Response (201): `correlation_id` (`intent_…`), `agent_id`, `expires_at`, `signer`, +`intent: { standard, payload }`, `preview: { summary, policy, … }`. + +1. Show `preview.summary` and the policy to the owner. +2. The owner's wallet signs `intent.payload` unchanged (method by `intent.standard`, see + [signing.md](signing.md)). +3. Submit: + +```http +POST /v1/submit-intent +{ "type": "agent_create", "correlation_id": "intent_…", + "signed_data": { "standard": "nep366", "payload": { …unchanged… }, "signed_delegate": "…" } } +``` + +4. Poll `GET /v1/status?correlation_id=intent_…&wait_ms=30000` until `SUCCESS`. Creation is on + chain (the API's sponsor pays gas); it usually takes a few seconds to a minute. +5. `GET /v1/agents/{agent_id}/policy` → `status: "APPLIED"` and `provider_policy_synced: true`. + +`owner` shapes: + +```json +{ "type": "near", "account_id": "alice.near", "public_key": "ed25519:…" } +{ "type": "evm", "address": "0xabc…(lowercase)", "chain_id": 1, "public_key": "0x<128 hex, uncompressed, no 04>" } +{ "type": "passkey", "credential_id": "", "public_key": "", "rp_id": "example.com", "origin": "https://example.com" } +``` + +A NEAR owner key must be a **FullAccess** key on that account. EVM and passkey owners need no +NEAR account: the API derives one and deploys it on first use, sponsored. + +## 5. Issue a grant for the agent + +```text +token = "ngt_" + base64url_nopad(32 random bytes) # keep secret, backend only +credential = sha256_hex(token) # hash of the token STRING +``` + +```http +POST /v1/generate-intent +{ "type": "grant_issue", "agent_id": "…", "label": "assistant-session-42", + "credential": "<64 hex>", "expires_at": "2027-01-01T00:00:00Z" } +``` + +Owner signs, you submit, status `SUCCESS` returns `details.grant.grant_id`. Store +`{grant_id, agent_id, encrypted token, label, expires_at}`. See [grants.md](grants.md). + +## 6. Fund the account + +```http +POST /v1/agents/{agent_id}/deposit +X-API-Key: naa_… +Idempotency-Key: dep_7f3c… +{ "origin_asset": "" } +``` + +Returns `PENDING_DEPOSIT` with `details.deposit_address`, `details.memo` (send it when present), +`details.min_amount`, `details.expires_at`, `details.refund_to` (the agent account). Show those +to the payer, then poll the `correlation_id` until `SUCCESS`. On NEAR you can also send NEP-141 +tokens straight to `GET /v1/agents/{agent_id}/addresses/near`. + +## 7. Act as the agent + +```http +POST /v1/agents/{agent_id}/swap +X-API-Key: naa_… +X-Grant-Token: ngt_… +Idempotency-Key: swap_9a1e… +{ "origin_asset": "nep141:wrap.near", "destination_asset": "", "amount": "1000000000000000000000000", "dry": true } +``` + +`dry: true` returns `{dry: true, type: "swap", quote}` and nothing else (no key, no grant needed). +Drop `dry` to execute: you get `202` with a `StatusResponse` (`op_…`). Poll it to a terminal +status. See [executions.md](executions.md) and [status-and-recovery.md](status-and-recovery.md). + +## 8. Before going live + +- Walk the checklist in [architecture.md](architecture.md#security-checklist). +- Handle every code in [errors.md](errors.md) you can hit, at least the refusal and retry ones. +- Persist `correlation_id` + `Idempotency-Key` + request body before sending. +- Read `GET /v1/network` on a schedule and surface degraded components to users. diff --git a/.agents/skills/near-intents-agent-api/references/grants.md b/.agents/skills/near-intents-agent-api/references/grants.md new file mode 100644 index 000000000..297850ff8 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/grants.md @@ -0,0 +1,85 @@ +# Grants + +A grant is the owner's signed permission for **one actor** (a chat session, an assistant, a bot, +a device) to run money actions on the account. It says *who*; the policy says *what*. All grants +of an account share the same policy, limits and USD budget. + +## Token and commitment + +Your backend creates the token; the API only ever sees its hash. + +```text +token = "ngt_" + base64url_no_padding(32 cryptographically random bytes) # 47 chars +credential = lowercase_hex(sha256(utf8(token))) # hash the STRING, prefix included +``` + +| Language | Code | +|---|---| +| TypeScript | `const { token, commitment } = createGrantCredential()` (SDK) | +| Python | `"ngt_" + base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()`; `hashlib.sha256(token.encode()).hexdigest()` | +| Rust | `create_grant_credential()` → `(token, commitment)` in the asset crate | +| Go | `"ngt_" + base64.RawURLEncoding.EncodeToString(b)`; `hex.EncodeToString(sha256.Sum256([]byte(token)))` | + +Use a **new token for every grant** (`grant_credential_reused` otherwise). Check your +implementation with `grant.token` / `grant.commitment` in `assets/test-vectors.json`. + +## Issue + +```json +POST /v1/generate-intent +{ "type": "grant_issue", "agent_id": "…", "label": "telegram-bot", + "credential": "", "expires_at": "2027-04-01T00:00:00Z" } +``` + +- `label`: what the owner sees in lists and previews; name the actor ("Claude Desktop on + Alice's laptop", "support-bot prod"). +- `expires_at`: required, at most 365 days ahead. Prefer short lives for sessions (hours/days) + and renew. +- Owner signs → submit → `SUCCESS` → `details.grant.grant_id`. +- Limit: 50 live (unrevoked, unexpired) grants per agent → `grant_limit_reached`. + +**Store the token before you ask the owner to sign**, encrypted (KMS envelope or app-level +AES-GCM), keyed by `grant_id` once known. If you lose it, the grant is useless: revoke it and +issue another. Never log it, never put it in an LLM prompt, URL or client bundle. + +## Use + +Send it as `X-Grant-Token` on swap, withdraw, transfer, shield, unshield, recover (and +sign). One client per grant, so concurrent sessions never borrow each other's token: + +```ts +const agentClient = api.forGrant(token); // TS SDK +``` +```python +agent_client = api.for_grant(token) # Python asset +``` + +Every execution's `StatusResponse.grant` = `{ id, label }` shows which grant acted; use it in +activity feeds and audits. + +## Revoke + +```json +{ "type": "grant_revoke", "agent_id": "…", "grant_id": "<64 hex>" } +``` + +Takes effect for anything not yet dispatched. Operations already past the dispatch commit finish; +`details.committed_correlation_ids` lists them (`committed_truncated` if the list was cut). +Expired grants stop working by themselves. + +Audit: `GET /v1/agents/{id}/grants` (revoked included, each with the canonical `owner_message` +the owner signed) and `GET /v1/agents/{id}/containment` (live grants + unfinished operations). + +## Patterns + +| Situation | Do | +|---|---| +| Per chat/assistant session | one grant per session, label with the session, expire in hours/days | +| Long-running bot | one grant per bot deployment, rotate (issue new, then revoke old) every N months | +| Third-party integration (MCP client, partner tool) | its own grant so it can be cut off alone | +| User says "stop that assistant" | `grant_revoke` that one grant | +| User says "stop everything" | `agent_freeze` | +| Agent needs a new capability | `policy_update`; do **not** issue a new grant (grants carry no permissions) | + +Errors: `agent_grant_required` (403: missing, unknown, revoked, expired or other agent's token), +`grant_limit_reached`, `grant_credential_reused`, `grant_not_found`. diff --git a/.agents/skills/near-intents-agent-api/references/http-api.md b/.agents/skills/near-intents-agent-api/references/http-api.md new file mode 100644 index 000000000..56312f9a4 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/http-api.md @@ -0,0 +1,180 @@ +# HTTP API reference + +Base URL `https://api.agentsonintents.com`. The live contract is `GET /openapi.json`; a compact +guide is `GET /llms.txt`. If this file and the server disagree, the server wins. + +## Conventions + +- **JSON, snake_case** everywhere (fields, query, errors). Statuses are UPPER_SNAKE. Exception: + `intent.payload` keeps its wallet standard's own field names (`receiverId`, `methodName`, + `primaryType`, …) because the wallet consumes it as is. +- **Headers** + | Header | When | + |---|---| + | `X-API-Key: naa_…` | every endpoint except `/v1/network`, `/v1/tokens` | + | `X-Grant-Token: ngt_…` | swap, withdraw, transfer, shield, unshield, recover, sign (not dry quotes, not deposit) | + | `Idempotency-Key` | required on executions and deposit (not on `dry: true`); optional on `generate-intent`. 8–128 chars `[A-Za-z0-9._:-]` | + | `Content-Type: application/json` | every body | +- Responses carry `x-request-id`; errors repeat it as `meta.request_id`. Log it. +- **Amounts**: atomic integer strings, up to 78 digits. **USD**: decimal strings (`"50"`, `"12.5"`). +- **Durations**: milliseconds (`timelock_ms`, `wait_ms`, `delay_ms`). **Times**: ISO-8601 UTC. +- **Ids**: `agent_id`, `grant_id` = 64 hex; `correlation_id` = `op_…` (execution) or + `intent_…` (owner intent); `approval_id` = UUID. +- **Pagination**: `{ data: [...], next_cursor: string | null }`; pass `cursor=` + until null. Policy history uses an integer cursor. +- **Timeouts**: give settlement writes (non-dry swap/withdraw/transfer/shield/unshield) ~150 s, + everything else ~65 s (long-poll is 30 s max). Don't retry on client timeout with a new key. +- **Errors**: JSON:API `{ errors: [{ status, code, title, detail?, source?, meta? }], meta: { request_id } }`. + Branch on `errors[0].code`. `meta.retryable`, `meta.available_at`, and `Retry-After` header on + 429/503. See [errors.md](errors.md). + +## Endpoint catalogue + +### Meta (no side effects) + +| Method & path | Auth | Returns | +|---|---|---| +| `GET /v1/network` | none | `{ network: "mainnet", contract_id, supported_owner_types, status: { overall, checked_at, components[] } }` | +| `GET /v1/tokens` | none | `{ data: [{ asset_id, symbol, decimals, blockchain, price, price_updated_at, price_expires_at }] }` | +| `GET /v1/whoami` | key | `{ api_key_id, tenant_id }` | +| `GET /v1/quotas` | key | `{ revision, overrides, limits: { daily_agents, api_keys, requests_per_minute, key_requests_per_minute }, usage: {…}, observed_at }` | +| `GET /openapi.json`, `GET /llms.txt`, `GET /health` | none | contract, guide, liveness | + +`status.components[]`: `{ id: "one_click" | "custody" | "near", status: "operational" | "degraded" | "down" | "unknown", reason, affects[], since, checked_at }`. +Advisory only; the API never refuses a request because of it. + +### Owner intents + +| Method & path | Auth | Body | Returns | +|---|---|---|---| +| `POST /v1/generate-intent` → 201 | key (+ optional idem) | `{ type, agent_id?, …type fields }` | `GenerateIntentResponse` | +| `POST /v1/submit-intent` → 202 | key | `{ type, correlation_id, signed_data }` | `StatusResponse` | + +Details: [owner-intents.md](owner-intents.md), signing: [signing.md](signing.md). + +### Status + +| Method & path | Auth | Query | Returns | +|---|---|---|---| +| `GET /v1/status` | key | `correlation_id`, `wait_ms` (0–30000), `refresh` (bool) | `StatusResponse` | +| `GET /v1/agents/{agent_id}/history` | key | `cursor`, `limit` (1–100, default 25), `include_unpaid` (bool, default false) | page of `StatusResponse` | +| `GET /v1/agents/{agent_id}/operations/{correlation_id}/proof` | key | path `correlation_id` is an `op_…` id | `{ correlation_id, origin, notary, events }`: each audit event of the execution with a `c2sp.org/tlog-proof@v1` (`PROVEN`) and its `evidence` (the recorded result, hashed into the opening), `PENDING` until the next checkpoint, or `UNLOGGED`; `notary` holds the verifier keys and TDX birth attestation. A deployment without a transparency log answers 501 `transparency_log_disabled` | + +### Agents and reads + +| Method & path | Query | Returns | +|---|---|---| +| `GET /v1/agents` | `external_user_id`, `cursor` | page of `AgentView` | +| `GET /v1/agents/{agent_id}` | | `AgentView` | +| `GET /v1/agents/{agent_id}/wallet` | | `{ wallet_id, near_account_id }` | +| `GET /v1/agents/{agent_id}/balances` | `source` = `public` \| `confidential`, `asset` | `{ near_account_id, source, balances: BalanceEntry[] }` | +| `GET /v1/agents/{agent_id}/addresses/near` | | `{ chain: "near", address }` | +| `GET /v1/agents/{agent_id}/policy` | | `PolicyView` | +| `GET /v1/agents/{agent_id}/policy/history` | `cursor` (int), `limit` | page of policy revisions | +| `GET /v1/agents/{agent_id}/grants` | | `{ data: GrantView[] }` (revoked included) | +| `GET /v1/agents/{agent_id}/executions/scheduled` | `cursor`, `limit` | `{ data: [{ correlation_id, execute_after, state, action }], next_cursor }` | +| `GET /v1/agents/{agent_id}/approvals` | | `{ data: ApprovalView[] }` | +| `GET /v1/agents/{agent_id}/approvals/{approval_id}` | | `ApprovalView` | +| `GET /v1/agents/{agent_id}/containment` | `grants_cursor`, `operations_cursor` | live grants + unfinished operations ("what is still able to act") | +| `GET /v1/agents/{agent_id}/provider/{kind}` | `kind` = requests \| audit \| deposits \| deposit_history; `limit`, `offset`, `type` | provider's raw records (`{ data }`) | + +### Executions (all `POST /v1/agents/{agent_id}/…`, 202) + +| Path | Auth | Body | +|---|---|---| +| `/swap` | key, grant, idem | `origin_asset`, `destination_asset`, `amount`, `min_amount_out?`, `confidential?`, `dry?` | +| `/withdraw` | key, grant, idem | `asset`, `amount`, `chain`, `recipient`, `memo?`, `confidential?`, `wrapped?`, `async?`, `dry?` | +| `/transfer` | key, grant, idem | `asset`, `amount`, `recipient` (NEAR Intents account), `confidential?` | +| `/shield`, `/unshield` | key, grant, idem | `asset`, `amount` | +| `/deposit` | key, idem | `origin_asset`, `destination_asset?`, `amount?`, `confidential?` | +| `/recover` | key, grant, idem (original) | `correlation_id`, `request: { type, …original body }` | +| `/sign` → 200 | key, grant | `message`, `encoding?`, `recipient` (must be in the policy's `sign.recipients`; never `intents.near`/`intents.far`) | + +`dry: true` on swap/withdraw → 200 `{ dry: true, type, quote }`; no grant, no idempotency key +needed. Details: [executions.md](executions.md). + +## Core shapes + +```ts +StatusResponse = { + correlation_id: string, // op_… | intent_… + agent_id: string | null, + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "agent_archive" + | "agent_restore" | "agent_delete" | "grant_issue" | "grant_revoke" | "execution_cancel" + | "approval_vote" | "swap" | "withdraw" | "transfer" | "shield" | "unshield" | "deposit", + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" + | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW", + failure_code: string | null, + created_at, updated_at, + dispatch_committed_at: string | null, + grant: { id, label } | null, + details: { … by type … } +} +``` + +`details` by type: + +| type | details | +|---|---| +| executions, `deposit`, `agent_delete` | `action`, `near_account_id?`, `provider_request_id?`, `chain?`, `execute_after?`, `delay_ms?`, `approval_id?`, `intent_hash?`, `tx_hash?`, `destination_tx_hash?`, `amount_out?`, `deposit_address?`, `memo?`, `min_amount?`, `min_amount_out?`, `expires_at?`, `refund_to?`, `refund_tx_hash?`, `failure_reason?`, `reason?` (NEEDS_REVIEW), `never_executed?`, `never_submitted?`, `settled_late?`, + provider fields | +| `agent_create` | `agent_id`, `revision`, `policy_hash`, `transaction_hash` | +| `policy_update`, `agent_freeze`, `agent_unfreeze` | `revision`, `policy_hash`, `transaction_hash` (null for off-chain changes) | +| `grant_issue` | `grant: GrantView \| null` | +| `grant_revoke` | `grant_id`, `committed_correlation_ids`, `committed_truncated` | +| `execution_cancel` | `cancelled_correlation_id` | +| `agent_archive`, `agent_restore` | `archived` | +| `approval_vote` | `approval_id`, `verdict` | + +Narrow on `type` before reading `details`. + +```ts +AgentView = { + id, name, external_user_id: string | null, + status: "PENDING" | "ACTIVE" | "ARCHIVED" | "DELETED" | "ABANDONED", + archived, deleted, + owner: OwnerWallet | null, + owner_account: { account_id, public_key, authority: "wallet" } | null, + wallet: { wallet_id, near_account_id } | null, + created_at, + cooldowns: { policy_change_available_at: string | null, unfreeze_available_at: string | null } +} + +PolicyView = { + wallet_id, revision: int | null, policy_hash, status: "NONE" | "SIGNED" | "APPLIED" | "FAILED", + applied_at, transaction_hash, + provider_policy_synced: boolean, // in force only when APPLIED && true + cooldowns, policy: Policy | null, + usage: { + budget: { daily|weekly|monthly: { limit_usd, spent_usd, remaining_usd, resets_at } }, + timelock: { delay_ms, scheduled_count } + } +} + +BalanceEntry = { asset_id, symbol, decimals, blockchain, price, …, balance_raw: string, balance: string | null } + +GrantView = { grant_id, agent_id, wallet_id, label, issued_at, expires_at, + revoked_at, revoked_reason, owner_epoch, owner_message } + +ApprovalView = { approval_id, wallet_id, wallet_pubkey, request_hash, + status: "PENDING" | "APPROVED" | "REJECTED" | "EXPIRED", + request_type, request_data, required_approvals, expires_at } + +OwnerWallet = + | { type: "near", account_id, public_key: "ed25519:…" } + | { type: "evm", address: "0x…" (lowercase), chain_id: number, public_key: "0x" + 128 hex } + | { type: "passkey", credential_id, public_key (b64url DER SPKI), rp_id, origin } +``` + +The `Policy` shape is in [policy.md](policy.md). + +## curl cheatsheet + +```bash +H=(-H "X-API-Key: $NEAR_INTENTS_AGENT_API_KEY" -H "Content-Type: application/json") +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/agents?external_user_id=user_123" "${H[@]}" +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/agents/$AGENT_ID/policy" "${H[@]}" +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/agents/$AGENT_ID/balances?source=public" "${H[@]}" +curl -s "$NEAR_INTENTS_AGENT_API_URL/v1/status?correlation_id=$CID&wait_ms=30000" "${H[@]}" +curl -s -X POST "$NEAR_INTENTS_AGENT_API_URL/v1/agents/$AGENT_ID/swap" "${H[@]}" -H "X-Grant-Token: $GRANT" \ + -d '{"origin_asset":"nep141:wrap.near","destination_asset":"'"$USDC"'","amount":"100000000000000000000000","dry":true}' +``` diff --git a/.agents/skills/near-intents-agent-api/references/other-languages.md b/.agents/skills/near-intents-agent-api/references/other-languages.md new file mode 100644 index 000000000..e1c35fa98 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/other-languages.md @@ -0,0 +1,210 @@ +# Other languages (Go, Java/Kotlin, C#, Ruby, PHP, Elixir, …) + +The API is plain HTTPS + JSON. Any language works; you need: + +1. An HTTP client with per-request timeouts (65 s normal, 150 s for settlement writes). +2. JSON that can keep `intent.payload` verbatim (raw bytes or an order-preserving tree). +3. SHA-256, random bytes, base64url, hex — for grant tokens and idempotency keys. +4. Only if you sign on the server: ed25519 + borsh-style byte packing (NEAR owners) or + secp256k1 + EIP-712 (EVM owners). See [signing.md](signing.md). + +## Generate types from OpenAPI + +```bash +curl -s https://api.agentsonintents.com/openapi.json -o openapi.json +# Go +oapi-codegen -generate types,client -package agentapi openapi.json > agentapi.gen.go +# Java / Kotlin / C# / PHP / Ruby +openapi-generator-cli generate -i openapi.json -g kotlin -o ./agentapi +openapi-generator-cli generate -i openapi.json -g csharp -o ./agentapi +``` + +Treat `intent.payload` and `signed_data.payload` as opaque JSON (`json.RawMessage`, +`JsonNode`, `JsonElement`) in generated models; typed re-serialization can reorder or drop +fields and break signature checks. + +## Minimal client contract + +``` +headers: X-API-Key, Content-Type: application/json, Accept: application/json + + X-Grant-Token on grant endpoints, + Idempotency-Key on executions/deposit +success: 2xx JSON +error: ≥400 JSON:API → raise {status, code=errors[0].code, detail, retryable, available_at, request_id, idempotency_key} +transport: raise with the idempotency key so the caller can retry with it +``` + +## Go example + +```go +package agentapi + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const DefaultBaseURL = "https://api.agentsonintents.com" + +type Client struct { + BaseURL, APIKey, GrantToken string + HTTP *http.Client +} + +type APIError struct { + Status int + Code, Detail string + Retryable bool + AvailableAt string + RequestID string + IdempotencyKey string +} + +func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Detail) } + +func New(apiKey string) *Client { + return &Client{BaseURL: DefaultBaseURL, APIKey: apiKey, HTTP: &http.Client{Timeout: 150 * time.Second}} +} + +func (c *Client) ForGrant(token string) *Client { cp := *c; cp.GrantToken = token; return &cp } + +// Do sends one request. body may be nil, a value, or json.RawMessage (sent verbatim). +func (c *Client) Do(ctx context.Context, method, path string, body any, idemKey string, grant bool, out any) error { + var reader *bytes.Reader + if body != nil { + raw, ok := body.(json.RawMessage) + if !ok { + var err error + if raw, err = json.Marshal(body); err != nil { + return err + } + } + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) + if err != nil { + return err + } + req.Header.Set("X-API-Key", c.APIKey) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + if grant { + req.Header.Set("X-Grant-Token", c.GrantToken) + } + res, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("transport (idempotency key %q): %w", idemKey, err) + } + defer res.Body.Close() + if res.StatusCode >= 400 { + var doc struct { + Errors []struct { + Code, Detail string + Meta struct { + Retryable bool `json:"retryable"` + AvailableAt string `json:"available_at"` + } + } + Meta struct { + RequestID string `json:"request_id"` + } + } + _ = json.NewDecoder(res.Body).Decode(&doc) + e := &APIError{Status: res.StatusCode, RequestID: doc.Meta.RequestID, IdempotencyKey: idemKey} + if len(doc.Errors) > 0 { + e.Code, e.Detail = doc.Errors[0].Code, doc.Errors[0].Detail + e.Retryable, e.AvailableAt = doc.Errors[0].Meta.Retryable, doc.Errors[0].Meta.AvailableAt + } + return e + } + if out == nil { + return nil + } + return json.NewDecoder(res.Body).Decode(out) +} + +func NewGrantCredential() (token, commitment string) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + token = "ngt_" + base64.RawURLEncoding.EncodeToString(b) + sum := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(sum[:]) +} + +func NewIdempotencyKey() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return "idem_" + hex.EncodeToString(b) +} +``` + +Owner intent round trip, keeping the payload verbatim: + +```go +type Generated struct { + CorrelationID string `json:"correlation_id"` + Type string `json:"type"` + AgentID string `json:"agent_id"` + Intent json.RawMessage `json:"intent"` // {standard, payload} — never re-marshal from a map + Preview json.RawMessage `json:"preview"` + ExpiresAt string `json:"expires_at"` +} + +var g Generated +err := c.Do(ctx, "POST", "/v1/generate-intent", request, NewIdempotencyKey(), false, &g) +// send g.Intent + g.Preview to the browser; receive signedData (json.RawMessage) back +submit := map[string]any{"type": g.Type, "correlation_id": g.CorrelationID, "signed_data": signedData} +err = c.Do(ctx, "POST", "/v1/submit-intent", submit, "", false, nil) +``` + +To add wallet output fields server-side without reordering the payload, decode `g.Intent` into a +`map[string]json.RawMessage` (the inner `payload` stays raw bytes), add `"signature"` etc., and +marshal that map: top-level key order does not matter, only the payload bytes do. When you +produce bytes you will **sign** (NEP-366 `args`), use `json.Compact` on the raw bytes or an +`Encoder` with `SetEscapeHTML(false)`: plain `json.Marshal` escapes `<`, `>`, `&` and would +differ from `JSON.stringify`. + +Go signing building blocks: `crypto/ed25519` (NEAR keys: `ed25519.NewKeyFromSeed(seed[:32])`), +`github.com/mr-tron/base58`, `encoding/binary` (little-endian lengths), and for EVM +`github.com/ethereum/go-ethereum/signer/core/apitypes` (`TypedDataAndHash`) + +`crypto.Sign`. Validate against `assets/test-vectors.json`. + +## Java / Kotlin notes + +- `java.net.http.HttpClient` or OkHttp; Jackson `JsonNode` for payloads (keeps order). +- Grant token: `SecureRandom` 32 bytes → `Base64.getUrlEncoder().withoutPadding()`; SHA-256 via + `MessageDigest`, hex lowercase. +- NEAR: `net.i2p.crypto:eddsa` or BouncyCastle Ed25519; EVM: web3j `StructuredDataEncoder` + + `Sign.signMessage(hash, keyPair, false)`. + +## C# notes + +- `HttpClient` + `System.Text.Json` `JsonNode`; serialize with + `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` when you must re-emit JSON you will sign. +- NEAR: `NSec.Cryptography` Ed25519; EVM: Nethereum `Eip712TypedDataSigner`. + +## Porting checklist + +- [ ] Base URL configurable; default hosted. +- [ ] API key from secret storage; grant tokens per client instance. +- [ ] Idempotency key generated, persisted, sent; returned on errors. +- [ ] JSON:API error mapping with `code`, `retryable`, `available_at`, `request_id`. +- [ ] Long-poll status helper with deadline. +- [ ] Payload pass-through without reordering; tests against `test-vectors.json` if signing. +- [ ] Amount conversion with integer/decimal types only. diff --git a/.agents/skills/near-intents-agent-api/references/owner-intents.md b/.agents/skills/near-intents-agent-api/references/owner-intents.md new file mode 100644 index 000000000..52dc1cd1a --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/owner-intents.md @@ -0,0 +1,180 @@ +# Owner intents + +Every change the owner authorizes uses one loop: + +``` +backend: POST /v1/generate-intent {type, …} → { correlation_id, intent, preview, expires_at, signer } +browser: show preview → wallet signs intent.payload (method from intent.standard) +backend: POST /v1/submit-intent {type, correlation_id, signed_data: {...intent, }} +backend: GET /v1/status?correlation_id=…&wait_ms=30000 until SUCCESS / FAILED +``` + +The API builds the exact bytes, so the partner never assembles anything security-relevant; +`preview.summary` is one human sentence describing what is signed. + +## Intent types + +| type | Extra fields (besides `agent_id`) | Notes | +|---|---|---| +| `agent_create` | `name`, `external_user_id?`, `owner`, `policy` (no `agent_id`) | reserves a `PENDING` agent and returns its `agent_id` immediately | +| `policy_update` | `policy` (complete), `expected_revision` (current applied revision) | 10-min cooldown between ordinary changes | +| `agent_freeze` | — | emergency stop; immediate; never throttled | +| `agent_unfreeze` | — | own cooldown (`agent_unfreeze_throttled`) | +| `agent_archive` / `agent_restore` | — | hides/unhides from default lists; nothing changes on chain. `agent_archive` needs a frozen account, and `agent_restore` needs an unfrozen one; otherwise `pause_before_archive` / `unpause_before_restore` | +| `agent_delete` | — | `preview.deletion` lists balances and `assets_lost`; withdraw first, there is no sweep. Open deposit addresses never block it: deletion closes them (`agent_deleted`), and funds sent later are lost | +| `grant_issue` | `label`, `credential` (sha256 hex of token), `expires_at` (≤365 days) | see [grants.md](grants.md) | +| `grant_revoke` | `grant_id` | committed operations still finish | +| `execution_cancel` | `correlation_id` of a `QUEUED` op | only while the timelock holds it | +| `approval_vote` | `approval_id`, `verdict: "approve" \| "reject"` | any owner; EVM/passkey votes go through their `0s` wallet | + +## Generate response + +```ts +{ + correlation_id: "intent_…", + type, agent_id, + status: "PENDING_SIGNATURE", + expires_at: string, // submit before this (minutes, not hours) + signer: OwnerWallet, // the wallet that must sign + intent: { standard: "nep413" | "nep366" | "eip712" | "webauthn", payload }, + preview: { + summary: string, + revision?, previous_revision?, policy_hash?, policy?, // policy changes + deletion?: { near_account_id, beneficiary, native_balance, public[], confidential[], assets_lost, retirement }, + approval?: { approval_id, request_type, request_hash, verdict } + } +} +``` + +The SDK and the reference clients also return the idempotency key they used. If +`generate-intent` is interrupted (timeout, crash), retry with the **same** `Idempotency-Key`; +`intent_generation_recovery_required` means keep that key and retry it rather than starting over. + +## Submit + +```json +{ + "type": "policy_update", + "correlation_id": "intent_…", + "signed_data": { "standard": "nep413", "payload": { "…": "exactly as generated" }, + "public_key": "ed25519:…", "signature": "" } +} +``` + +| standard | `signed_data` = intent + | +|---|---| +| `nep413` | `public_key: "ed25519:…"`, `signature` (base64, base58, `ed25519:` or hex) | +| `nep366` | `signed_delegate` (base64 borsh `SignedDelegate`) | +| `eip712` | `signature: "0x…"` (65 bytes, 130 hex) | +| `webauthn` | `credential: AuthenticationResponseJSON` (from `@simplewebauthn/browser`) | + +Submitting the same signature twice is safe and returns the same operation. The response is +usually `PROCESSING`; on-chain intents (`nep366`, sponsored EVM/passkey calls) settle in seconds +to a minute; off-chain ones settle almost immediately. + +## Which standard you get + +| Owner | Change enforced by provider (on chain) | Change enforced by API (off chain) | +|---|---|---| +| NEAR | `nep366` delegate action (sponsor pays gas): `agent_create`, provider policy edits, freeze/unfreeze | `nep413`: destinations/budget/timelock/schedule-only edits, grants, cancel, archive, votes, … | +| EVM | `eip712` | `eip712` | +| Passkey | `webauthn` | `webauthn` | + +Always switch on `intent.standard`; never assume from the type. + +## Backend-for-frontend (BFF) split + +The browser never sees the API key or any grant token. A typical pair of routes: + +```text +POST /app/agents/:id/intents (your auth: user owns agent :id) + → api.generate_intent({...}) → returns { correlation_id, intent, preview, expires_at } to the browser + +POST /app/intents/:cid/submit (your auth: user owns the intent you stored for :cid) + body: { signed_data } ← wallet output from the browser + → api.submit_intent(type, cid, signed_data) + → api.get_status(cid, wait_ms=30000) → return status +``` + +Rules: +- Persist `{correlation_id, type, agent_id, user_id, created_at}` when you generate, and on + submit check that the user owns that correlation id. Never let a client pick `type` or + `correlation_id` you did not issue to them. +- Validate the user owns `agent_id` (your `external_user_id` mapping) before generating. +- Let the browser only *forward* the wallet output; don't let it supply a different `payload` + (the API refuses mismatches with `intent_payload_mismatch` anyway). +- Show `preview.summary`, the full `preview.policy`, and for deletes `preview.deletion.assets_lost`. + +Browser signing: + +```ts +switch (intent.standard) { + case "eip712": + return { ...intent, signature: await walletClient.signTypedData({ account, ...intent.payload }) }; + case "nep366": { + const r = await wallet.signDelegateActions({ delegateActions: [intent.payload] }); + return { ...intent, signed_delegate: r.signedDelegateActions[0] }; + } + case "nep413": { + const nonce = Uint8Array.from(atob(intent.payload.nonce), (c) => c.charCodeAt(0)); + const r = await wallet.signMessage({ ...intent.payload, nonce }); + return { ...intent, public_key: r.publicKey, signature: r.signature }; + } + case "webauthn": + return { ...intent, credential: await startAuthentication({ optionsJSON: intent.payload }) }; +} +``` + +Adapt to your wallet library's result shape (near-connect / wallet-selector return `publicKey` and +`signature`; the server normalizes base58/base64/hex encodings). + +## Errors you should handle here + +| Code | Meaning / action | +|---|---| +| `intent_expired` (409) | signing window closed → generate again | +| `intent_payload_mismatch`, `intent_standard_mismatch` | the payload or standard was altered → sign the original | +| `signature_invalid` | malformed wallet output → submit exactly what the wallet returned | +| `signer_mismatch` | wrong key/account signed → have the `signer` wallet sign | +| `owner_key_not_full_access` (403) | NEAR key is a function-call key → use a FullAccess key | +| `owner_nonce_invalid` | intent already used/expired → generate again | +| `policy_revision_conflict` (409) | re-read `GET …/policy`, regenerate with its `revision`, re-sign | +| `policy_reconciliation_required` | another policy intent is still open → wait for it to settle/expire | +| `policy_change_throttled` / `agent_unfreeze_throttled` (429) | wait until `meta.available_at` (also on `GET /v1/agents/{id}` cooldowns) | +| `wallet_already_frozen` / `wallet_already_unfrozen` | already in that state | +| `agent_creation_quota_exceeded` (429) | 100 agents/24h → wait for `available_at` or ask for a higher quota | +| `sponsor_busy` / `sponsor_balance_insufficient` (503) | retry the same submit later | +| `transaction_unconfirmed` (503) | broadcast but not confirmed → poll status, or submit the same signed intent again (never sends twice); never generate a new intent for it | +| `agent_not_bound` | the agent is still onboarding → wait for `agent_create` SUCCESS | + +## Recipes + +**Edit policy safely** + +```text +view = GET /v1/agents/{id}/policy +assert view.status == "APPLIED" +new = modify(copy(view.policy)) # complete policy, never a diff +gen = generate_intent({type:"policy_update", agent_id, policy:new, expected_revision:view.revision}) +… owner signs, submit, wait SUCCESS … +poll GET …/policy until status APPLIED && provider_policy_synced +``` + +**Emergency stop**: `agent_freeze` (no cooldown, takes effect at once). To stop one actor only, +`grant_revoke`. To see what is still live: `GET …/containment`. + +**Cancel a timelocked payment**: list `GET …/executions/scheduled`, then `execution_cancel` +with that `correlation_id` before `execute_after`. + +**Approve a payment** (policy `owner_approval: true`, any owner type): list `GET …/approvals`, show +`request_data`, then `approval_vote` with `verdict`. The execution moves out of +`PENDING_APPROVAL`; keep polling its `op_…` id. `reject` ends it and releases its budget +charge (`FAILED`, `details.never_executed`). `approve` re-runs the admission checks first: a +revoked or expired grant, a frozen or changed policy, or a destination no longer allowed refuses +the vote (`authorization_stale`, `wallet_frozen`, `policy_destination_denied`), nothing is +sent, and the request stays held for a `reject`. `approval_not_recorded` means the API has not +yet recorded the request under its approval (its dispatch reply was lost): send the same vote +again a few minutes later. + +**Delete**: withdraw balances first, `agent_delete`, show `preview.deletion`; refuse to proceed +in your UI when `assets_lost` is true unless the owner explicitly accepts. diff --git a/.agents/skills/near-intents-agent-api/references/policy.md b/.agents/skills/near-intents-agent-api/references/policy.md new file mode 100644 index 000000000..d9eebcc0b --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/policy.md @@ -0,0 +1,177 @@ +# Policy + +One complete, owner-signed rulebook per agent account. Every grant acts within it. Each +`agent_create` and `policy_update` carries the **whole** policy, never a diff; every field below +is required except `schedule` and `sign`. + +```ts +Policy = { + frozen: boolean, + actions: ("swap" | "transfer" | "withdraw")[], // unique; deposits are always allowed + confidential: boolean, // shield/unshield + confidential form of each action + owner_approval: boolean, // every outgoing money action waits for the owner's vote + assets: "any" | string[], // asset_id values, ≤128 + limits: { // per-asset caps, atomic strings (1–78 digits) + per_transaction?: Record, + hourly?: Record, + daily?: Record, + monthly?: Record, + }, + max_actions_per_hour: number | null, // positive int ≤ 1_000_000, null = no cap + destinations: { mode: "only" | "except" | "any", list: Destination[] }, // list ≤256; "any" ⇒ [] + budget: { daily_usd: string | null, weekly_usd: string | null, monthly_usd: string | null }, + timelock_ms: number, // 0 … 2_592_000_000 (30 days) + schedule?: Schedule, // omit = money actions may run any time + sign?: { recipients: string[] } // omit = the agent signs nothing; 1–256 NEAR accounts +} + +Destination = + | { action: "withdraw", chain: string, address: string, memo: string | null } // external chain address + | { action: "transfer", address: string, confidential: boolean } // NEAR Intents account + +Schedule = { + mode: "only" | "except", // run only inside the windows, or pause inside them + time_zone: string, // IANA zone, e.g. "Europe/Berlin", "UTC" + windows: { // 1 … 28 + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[], // unique, 1 … 7 + start: string, // "HH:MM", 24-hour, inclusive + end: string, // "HH:MM" or "24:00", exclusive, ≠ start + }[] +} +``` + +Unknown fields are rejected (strict objects). Field names are snake_case. + +## Field by field + +| Field | Effect | Enforced by | +|---|---|---| +| `frozen` | kill switch: every money action refused (`wallet_frozen`). Use `agent_freeze`/`agent_unfreeze` intents, not a policy edit | provider | +| `actions` | which money actions exist at all. Missing → `policy_action_denied` | provider | +| `confidential` | enables `shield`, `unshield`, and `confidential: true` on swap/withdraw/transfer | provider | +| `owner_approval` | outgoing actions go `PENDING_APPROVAL` until the owner votes; deposits unaffected. Hard limits still reject first | provider | +| `assets` | tokens the agent may spend (`"any"` or explicit ids). Swaps may buy unlisted tokens but not sell them | provider | +| `limits` | per-asset caps per transaction and per rolling hour/day/month. Needs named assets to be meaningful | provider | +| `max_actions_per_hour` | rate cap on money actions regardless of amount | provider | +| `destinations` | where withdrawals/transfers may go | **API** | +| `budget` | total USD value across all assets, rolling 24 h / 7 d / 30 d; shared by every grant | **API** | +| `timelock_ms` | every outgoing money action is `QUEUED` this long; owner can `execution_cancel` | **API** | +| `schedule` | weekly windows on the owner's clock when money actions may run (`only`) or are paused (`except`) | **API** | + +`budget` notes: charged atomically at dispatch using 1Click USD prices; an asset with no fresh +price fails with `spend_price_unavailable` (only while a budget is set). A larger budget never +lifts a per-asset limit; both must pass. Live usage is in `GET …/policy` → +`usage.budget.{daily,weekly,monthly}.{limit_usd, spent_usd, remaining_usd, resets_at}`. + +`destinations` notes: +- `only` + `[]` (the safe default) keeps funds inside the account: swaps only. +- `only` matches the **exact** entry, memo included. `except` blocks an account under any memo. +- EVM addresses must be canonical lowercase hex; other chains keep their exact case. +- `transfer` entries are NEAR Intents account ids (`alice.near`, implicit hex accounts). + +`schedule` notes: +- A window is `start` inclusive, `end` exclusive, in `time_zone`. `00:00`–`24:00` is a whole day. +- An `end` earlier than `start` runs past midnight and belongs to the day it starts on: + `{"days":["fri"],"start":"22:00","end":"02:00"}` covers Friday night into Saturday morning. +- Daylight saving follows the time zone: a window means the same local hours all year. +- Judged when the action would **run**: at submit for `now + timelock_ms`, again when the timelock + releases, and again at dispatch. A timelocked action whose release falls outside the schedule + fails; nothing is sent. +- Refusal: `403 policy_schedule_denied` with `meta.available_at` (next allowed moment) and + `Retry-After`. Submit again at `available_at` with a **new** `Idempotency-Key`, or the owner + changes `schedule`. A same-key replay returns the original operation, not a fresh judgement. +- Exempt: deposits, `sign` and owner approval votes. +- Not sent to the provider: a schedule-only edit is an off-chain owner signature. + +## Signing (`sign`) + +`sign: { recipients: ["login.example.near"] }` lets any grant ask the agent's key to sign an +identity challenge for those NEAR accounts (`POST /v1/agents/{agent_id}/sign`), so the agent can +prove it controls the account, for example to log in to a service. Omit it and nothing is signed. + +- Only the canonical `near-intents-agent-api.identity.v1` challenge is signed (exact fields + `domain, purpose, chain, audience, challenge, issued_at_ms, expires_at_ms`; `audience` = + `recipient`; lives ≤ 5 minutes). Never a transaction, an intent or free text. +- `intents.near` and `intents.far` (the NEAR Intents contracts) are never recipients: the policy is + refused with `validation_failed`, a request with `403 signing_recipient_forbidden`. +- Moves no value: `timelock_ms`, `schedule`, `budget` and `owner_approval` do not apply. +- Off unless the owner adds it. Warn the owner before signing it: a listed service may treat the + signature as the account logging in, so list only trusted services. +- Changing `sign` is an on-chain policy update (the wallet provider enforces the same list). + +## Ready-made policies + +**Swap-only trading assistant** (funds never leave the account): + +```json +{ + "frozen": false, "actions": ["swap"], "confidential": false, "owner_approval": false, + "assets": ["nep141:wrap.near", ""], + "limits": { "per_transaction": { "nep141:wrap.near": "10000000000000000000000000" } }, + "max_actions_per_hour": 30, + "destinations": { "mode": "only", "list": [] }, + "budget": { "daily_usd": "100", "weekly_usd": null, "monthly_usd": "1000" }, + "timelock_ms": 0 +} +``` + +**Payroll / payouts to an allow-list with a review window**: + +```json +{ + "frozen": false, "actions": ["transfer", "withdraw"], "confidential": false, "owner_approval": false, + "assets": [""], + "limits": { "per_transaction": { "": "500000000" }, "daily": { "": "2000000000" } }, + "max_actions_per_hour": 10, + "destinations": { "mode": "only", "list": [ + { "action": "transfer", "address": "vendor.near", "confidential": false }, + { "action": "withdraw", "chain": "base", "address": "0xabc…", "memo": null } + ] }, + "budget": { "daily_usd": "2000", "weekly_usd": "5000", "monthly_usd": null }, + "timelock_ms": 3600000 +} +``` + +**Business hours only** (any policy plus a schedule; here Mon–Fri 09:00–17:00 Berlin time): + +```json +"schedule": { + "mode": "only", "time_zone": "Europe/Berlin", + "windows": [{ "days": ["mon","tue","wed","thu","fri"], "start": "09:00", "end": "17:00" }] +} +``` + +Pause overnight instead: `{"mode":"except","time_zone":"UTC","windows":[{"days":["mon","tue","wed","thu","fri","sat","sun"],"start":"22:00","end":"07:00"}]}`. + +**Human-in-the-loop** (any owner type): as above with `"owner_approval": true`. + +**Broad, capped by USD**: `"actions": ["swap","transfer","withdraw"]`, `"assets": "any"`, +`"limits": {}`, `"destinations": {"mode":"any","list":[]}`, budget set. Only for owners who +explicitly accept that the agent can send anywhere. + +## Changing a policy + +1. `view = GET /v1/agents/{id}/policy`. Require `status == "APPLIED"`. +2. Check `GET /v1/agents/{id}` → `cooldowns.policy_change_available_at` is null or past. +3. Build the complete new policy from `view.policy` (to remove a schedule, omit `schedule`). +4. `generate-intent {type: "policy_update", agent_id, policy, expected_revision: view.revision}`. +5. Owner signs (`nep413`/`eip712` when only API-enforced fields changed, otherwise on chain). +6. Submit, wait `SUCCESS`, then poll `GET …/policy` until `APPLIED` and + `provider_policy_synced: true`. Until then executions fail with `policy_not_ready`. + +Cooldowns (per agent, default 5 minutes): +- any ordinary change (anything other than a pure freeze/unfreeze) → next ordinary change waits + → `policy_change_throttled` with `meta.available_at`; +- `agent_freeze` is never throttled; +- an unfreeze waits 5 minutes after the previous unfreeze → `agent_unfreeze_throttled`; +- `agent_create` does not start a cooldown, so a freshly created agent can be edited at once. + +Plan UX around it: batch edits into one signature, and show `available_at` instead of a +generic error. + +## Validation errors + +A malformed policy returns `400 validation_failed` with one error per field +(`source.pointer` like `/policy/destinations/list/0/address`). Other policy-related codes: +`policy_revision_conflict`, `policy_reconciliation_required`, +`policy_blocks_delete` (an old-shape policy must be re-signed before deletion). diff --git a/.agents/skills/near-intents-agent-api/references/python.md b/.agents/skills/near-intents-agent-api/references/python.md new file mode 100644 index 000000000..e4a238c48 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/python.md @@ -0,0 +1,114 @@ +# Python + +There is no published Python package. Copy +[../assets/python/near_intents_agent_api.py](../assets/python/near_intents_agent_api.py) into +your project (one file, mirrors the TypeScript SDK). + +```bash +pip install httpx cryptography # client + NEAR signing +pip install eth-account # only for EVM (EIP-712) owners +python assets/python/test_vectors.py # signer self-check against assets/test-vectors.json +``` + +Python 3.10+. + +## Client + +```python +from near_intents_agent_api import AgentApi, AgentApiError, AgentApiRequestError, create_idempotency_key + +api = AgentApi.from_env() # NEAR_INTENTS_AGENT_API_KEY, NEAR_INTENTS_AGENT_API_URL (default hosted) +# or AgentApi(api_key="naa_…", base_url="https://api.agentsonintents.com") + +api.get_network(); api.whoami(); api.get_quotas() +tokens = {t["asset_id"]: t for t in api.get_tokens()} +``` + +Methods: `generate_intent`, `submit_intent`, `get_status(cid, wait_ms, refresh)`, +`wait_for_status(cid, deadline_s)`, `list_agents`, `get_agent`, `get_balances(agent_id, source, asset)`, +`get_policy`, `list_grants`, `get_history`, `list_scheduled`, `list_approvals`, +`get_containment`, `quote_swap`, `quote_withdraw`, `swap`, `withdraw`, `transfer`, `shield`, +`unshield`, `deposit`, `recover`, plus the generic `request(method, path, query=, body=, idempotency_key=, grant=)` +for anything else. Responses are plain dicts with the API's snake_case fields. + +Helpers: `create_idempotency_key()`, `create_grant_credential()` → `{token, commitment}`, +`grant_commitment(token)`, `to_atomic("1.5", 24)`, `to_decimal(raw, decimals)`, +`iterate_pages(lambda c: api.list_agents(cursor=c))`. + +## Owner flow with a NEAR key (script / company-held key) + +```python +from near_intents_agent_api import NearKey, near_owner, sign_intent, run_owner_intent + +key = NearKey(os.environ["AGENT_OWNER_PRIVATE_KEY"]) # ed25519: +owner = near_owner(os.environ["AGENT_OWNER_ACCOUNT_ID"], key) + +status = run_owner_intent( + api, + {"type": "agent_create", "name": "Treasury bot", "owner": owner, "policy": policy}, + lambda generated: sign_intent(generated, near_key=key, rpc_url="https://free.rpc.fastnear.com"), +) +agent_id = status["details"]["agent_id"] +``` + +EVM owner: + +```python +from near_intents_agent_api import evm_owner, sign_intent +owner = evm_owner(os.environ["AGENT_OWNER_EVM_PRIVATE_KEY"]) +sign = lambda g: sign_intent(g, evm_private_key=os.environ["AGENT_OWNER_EVM_PRIVATE_KEY"]) +``` + +In a web product, skip the signers: return `generated["intent"]` and `generated["preview"]` to +the browser and pass the wallet's output to `api.submit_intent(type, correlation_id, signed_data)`. + +## Grant and execute + +```python +from near_intents_agent_api import create_grant_credential +from datetime import datetime, timedelta, timezone + +cred = create_grant_credential() # store cred["token"] encrypted +expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat().replace("+00:00", "Z") +done = run_owner_intent(api, {"type": "grant_issue", "agent_id": agent_id, "label": "bot", + "credential": cred["commitment"], "expires_at": expires}, sign) +grant_id = done["details"]["grant"]["grant_id"] + +agent = api.for_grant(cred["token"]) +quote = agent.quote_swap(agent_id, {"origin_asset": "nep141:wrap.near", "destination_asset": usdc, + "amount": to_atomic("0.1", 24)}) +key = create_idempotency_key() # persist with the body first +op = agent.swap(agent_id, {"origin_asset": "nep141:wrap.near", "destination_asset": usdc, + "amount": to_atomic("0.1", 24)}, idempotency_key=key) +final = api.wait_for_status(op["correlation_id"]) +``` + +## Errors + +```python +try: + agent.withdraw(agent_id, body, idempotency_key=key) +except AgentApiError as e: + if e.code == "policy_destination_denied": ... + elif e.code in ("policy_change_throttled", "rate_limited"): retry_at = e.available_at + else: raise +except AgentApiRequestError as e: + # network/timeout: retry with e.idempotency_key and the same body + ... +``` + +`AgentApiError` has `status`, `code`, `title`, `detail`, `retryable`, `available_at`, +`request_id`, `errors`, `idempotency_key`. + +## Async / frameworks + +The asset uses a sync `httpx.Client`. For asyncio (FastAPI, aiohttp), switch to +`httpx.AsyncClient` and `await` in `request()` — the rest is unchanged. Run long-polls in +background tasks (Celery, RQ, arq) rather than request handlers. + +## Pitfalls + +- Use `Decimal`/strings for amounts, never floats (`to_atomic` refuses excess precision). +- Don't `json.dumps(..., sort_keys=True)` anything you sign; `js_json` reproduces + `JSON.stringify` for NEP-366 args. +- EIP-712 needs `eth-account>=0.13` (`encode_typed_data(full_message=payload)`). diff --git a/.agents/skills/near-intents-agent-api/references/rust.md b/.agents/skills/near-intents-agent-api/references/rust.md new file mode 100644 index 000000000..ed1adde9c --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/rust.md @@ -0,0 +1,105 @@ +# Rust + +No crate is published. Copy [../assets/rust/](../assets/rust/) into your workspace (or vendor +`src/lib.rs` into an existing crate and merge the dependencies from `Cargo.toml`). + +```bash +cd assets/rust +cargo test # NEAR + grant vectors +cargo test --features evm # + EIP-712 vector (pulls in alloy) +``` + +Key dependencies: `reqwest` (rustls), `serde_json` **with `preserve_order`** (required: NEP-366 +args must be serialized in the API's key order), `ed25519-dalek`, `sha2`, `bs58`, `base64`; +optional `alloy-*` behind feature `evm`. + +## Client + +```rust +use near_intents_agent_api::{AgentApi, Error, create_idempotency_key}; +use serde_json::json; + +let api = AgentApi::from_env()?; // NEAR_INTENTS_AGENT_API_KEY, NEAR_INTENTS_AGENT_API_URL (default hosted) +// or AgentApi::new("naa_…", None)? + +let network = api.get_network().await?; +let tokens = api.get_tokens().await?; +let agent = api.get_agent(&agent_id).await?; +let policy = api.get_policy(&agent_id).await?; +let balances = api.get_balances(&agent_id, "public").await?; +``` + +Bodies and responses are `serde_json::Value`. For anything without a helper use +`api.request(Method, path, query, body, idempotency_key, grant)`. Generate typed models from +`/openapi.json` (e.g. `openapi-generator -g rust` or `progenitor`) if you want structs; keep +`intent.payload` as `Value` either way so you never re-order it. + +## Owner flow + +```rust +use near_intents_agent_api::{NearKey, sign_intent}; + +let key = NearKey::from_secret(&std::env::var("AGENT_OWNER_PRIVATE_KEY")?)?; +let owner = json!({ "type": "near", "account_id": account_id, "public_key": key.public_key() }); + +let generated = api.generate_intent(&json!({ + "type": "agent_create", "name": "Treasury bot", "owner": owner, "policy": policy +}), &create_idempotency_key()).await?; + +let signed = sign_intent(&generated, Some(&key), "https://free.rpc.fastnear.com", None).await?; +let cid = generated["correlation_id"].as_str().unwrap(); +api.submit_intent(generated["type"].as_str().unwrap(), cid, signed).await?; +let status = api.wait_for_status(cid, std::time::Duration::from_secs(300)).await?; +``` + +EVM owner (feature `evm`): `sign_intent(&generated, None, "", Some(&evm_private_key_hex))`. +Passkeys can only be signed in a browser. + +## Grant and execute + +```rust +use near_intents_agent_api::create_grant_credential; + +let (token, commitment) = create_grant_credential(); // store token encrypted +// grant_issue with "credential": commitment … owner signs … wait SUCCESS + +let agent = api.for_grant(token)?; +let quote = agent.quote_swap(&agent_id, json!({ + "origin_asset": "nep141:wrap.near", "destination_asset": usdc, "amount": "100000000000000000000000" +})).await?; + +let key = create_idempotency_key(); // persist with the body first +let op = agent.execute(&agent_id, "swap", &json!({ + "origin_asset": "nep141:wrap.near", "destination_asset": usdc, "amount": "100000000000000000000000" +}), &key).await?; +let final_status = api.wait_for_status(op["correlation_id"].as_str().unwrap(), std::time::Duration::from_secs(300)).await?; +``` + +`execute` covers `swap`, `withdraw`, `transfer`, `shield`, `unshield`; `deposit` has its own +method (no grant). + +## Errors + +```rust +match agent.execute(&agent_id, "withdraw", &body, &key).await { + Ok(op) => track(op), + Err(Error::Api { code, available_at, .. }) if code == "spend_budget_exceeded" => { + /* show remaining budget; retry after `available_at` with a NEW key */ + } + Err(e) if e.code() == Some("policy_destination_denied") => { /* ask the owner to allow it */ } + Err(Error::Transport { idempotency_key, .. }) => { + /* outcome unknown: retry with the same key and body */ + } + Err(e) => return Err(e.into()), +} +``` + +`Error::Api { status, code, title, detail, retryable, available_at, request_id, errors, idempotency_key }`, +`Error::Transport { source, idempotency_key }`, `Error::Invalid(String)`. + +## Pitfalls + +- Without `preserve_order`, `serde_json::Map` is a `BTreeMap` and sorts keys: NEP-366 args + bytes change and the API rejects the signature. +- Use `u128` (or strings) for amounts; 24-decimal NEAR amounts overflow `u64`. +- The crate allows `clippy::result_large_err` on purpose (errors carry the full document). diff --git a/.agents/skills/near-intents-agent-api/references/signing.md b/.agents/skills/near-intents-agent-api/references/signing.md new file mode 100644 index 000000000..70a60ecc1 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/signing.md @@ -0,0 +1,151 @@ +# Signing owner intents + +The owner's wallet signs `intent.payload` exactly as `generate-intent` returned it, using the +method named by `intent.standard`. You submit `signed_data = { ...intent, }`. + +In a product, **the owner's wallet in the browser signs** and your backend only forwards bytes. +Server-side signers (below) exist for scripts, tests, CI, and integrations where the company +deliberately operates the owner key (see [architecture.md](architecture.md#key-ownership-models)). + +Never: rebuild the payload, re-serialize it with different key order, hash it yourself before a +wallet that hashes again, sign with a function-call key, or guess the standard from the type. + +All byte formats below are pinned by [../assets/test-vectors.json](../assets/test-vectors.json), +generated with near-api-js and viem. Any new port must reproduce every vector. + +## nep413 — off-chain NEAR message + +Payload: `{ message: string, recipient: string, nonce: base64(32 bytes) }`. + +```text +tag = u32_le(2^31 + 413) # 0x9d 0x01 0x00 0x80 +body = borsh_string(message) # u32_le(len(utf8)) || utf8 + || nonce_bytes[32] # raw, decoded from base64 (fixed array: no length) + || borsh_string(recipient) + || 0x00 # callbackUrl: Option = None +digest = sha256(tag || body) +sig = ed25519_sign(owner_secret, digest) +signed_data = { ...intent, public_key: "ed25519:", signature: base64(sig) } +``` + +Browser wallets: `wallet.signMessage({ message, recipient, nonce: Uint8Array })` — decode the +base64 nonce to bytes first. The key must be a **FullAccess** key of `signer.account_id` +(otherwise `owner_key_not_full_access`). + +## nep366 — gasless NEAR delegate action (on chain) + +Payload: `{ receiverId, actions: [ { type: "FunctionCall", params: { methodName, args, gas, deposit } } ] }` +(1–2 actions; `args` is a JSON object; `gas` and `deposit` are decimal strings). + +The signer needs chain state: query the owner's access key (`view_access_key`, finality +`final`) on a NEAR mainnet RPC (e.g. `https://free.rpc.fastnear.com`, +`https://rpc.mainnet.near.org`). + +```text +nonce = access_key.nonce + 1 +max_block_height = access_key_query.block_height + 120 # ~2 minutes + +DelegateAction (borsh) = + borsh_string(sender_id = signer.account_id) + || borsh_string(receiverId) + || u32_le(len(actions)) + || for each action: + 0x02 # Action::FunctionCall + || borsh_string(methodName) + || u32_le(len(argsBytes)) || argsBytes # argsBytes = JSON.stringify(args) as UTF-8 + || u64_le(gas) + || u128_le(deposit) + || u64_le(nonce) + || u64_le(max_block_height) + || 0x00 || pubkey[32] # PublicKey::ED25519 + +digest = sha256(u32_le(2^30 + 366) || DelegateAction) # NEP-461 prefix 0x6e 0x01 0x00 0x40 +signature = ed25519_sign(owner_secret, digest) +SignedDelegate = DelegateAction || 0x00 || signature[64] # Signature::ED25519 +signed_data = { ...intent, signed_delegate: base64(SignedDelegate) } +``` + +**The args trap.** The API compares the base64 of `argsBytes` against what it generated, so +`argsBytes` must equal JavaScript `JSON.stringify(args)`: keys in the order received, no spaces, +non-ASCII characters as raw UTF-8 (not `\uXXXX`), integers without `.0`. In practice: + +| Language | Do | +|---|---| +| Python | `json.loads` keeps order; `json.dumps(args, separators=(",", ":"), ensure_ascii=False).encode()` | +| Rust | `serde_json` with feature **`preserve_order`**, then `serde_json::to_vec(&args)` | +| Go | decode into an ordered structure (e.g. keep `json.RawMessage` of `args` and compact it with `json.Compact`), never `map[string]any` (Go sorts map keys) | +| Java/Kotlin | Jackson `ObjectMapper` reading into `JsonNode` keeps order; write without pretty printing; disable `ESCAPE_NON_ASCII` | +| C# | `System.Text.Json` `JsonNode` keeps order; use `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` so non-ASCII is not escaped | + +The simplest robust approach in any language: keep `args` as the raw JSON bytes from the +response and minify them without reordering. + +Browser wallets: `wallet.signDelegateActions({ delegateActions: [intent.payload] })` and submit +`signedDelegateActions[0]`. The API's sponsor relays it; the owner pays no gas. + +`policy_delegate_signature_invalid` / `intent_payload_mismatch` on nep366 almost always means args bytes, +nonce (stale access-key read: use finality `final` and sign right away) or the wrong key. + +## eip712 — EVM owners + +Payload is a complete typed-data document: `{ types: { EIP712Domain: [...], : [...] }, primaryType, domain, message }`. +Domain is `{ name: "NEAR Wallet Contract", version: "1" }` (no chainId / verifyingContract); +`primaryType` is `Authorization` or `WalletMessage`. + +```text +signature = secp256k1_sign(eip712_hash(payload)) # standard eth_signTypedData_v4 +signed_data = { ...intent, signature: "0x" + r(32) + s(32) + v(1) } # 130 hex chars +``` + +| Language | Call | +|---|---| +| Browser | viem `walletClient.signTypedData({ account, ...payload })` / `eth_signTypedData_v4` | +| TypeScript server | viem `privateKeyToAccount(pk).signTypedData(payload)` | +| Python | `Account.sign_message(encode_typed_data(full_message=payload), private_key=pk)` (eth-account ≥0.13) | +| Rust | alloy `TypedData` (from the JSON) → `eip712_signing_hash()` → `PrivateKeySigner::sign_hash` | +| Go | go-ethereum `apitypes.TypedDataAndHash(typedData)` → `crypto.Sign`, then add 27 to `v` | + +Owner descriptor for creation: `{ type: "evm", address: lowercase, chain_id: 1, +public_key: "0x" + uncompressed point without the 04 prefix (128 hex) }`. EOAs only. + +KMS/HSM keys (AWS KMS, GCP KMS, Fireblocks raw signing) work: compute the EIP-712 digest +locally, have the KMS sign the 32-byte digest, normalize `s` to low-S, derive `v` by recovery. + +## webauthn — passkey owners + +Payload is WebAuthn request options: `{ challenge, rpId, allowCredentials, userVerification: "required", timeout }`. +Only the user's authenticator can sign: + +```ts +import { startAuthentication } from "@simplewebauthn/browser"; +const credential = await startAuthentication({ optionsJSON: intent.payload }); +signed_data = { ...intent, credential }; +``` + +Registration: ES256 (`alg: -7`), user verification required; store the credential id and the +**SPKI DER** public key (base64url), `rp_id` and exact `origin`. Convert COSE keys to SPKI in +your backend. Cross-origin iframes are rejected. + +## Server-side signer implementations in this skill + +| Language | File | Functions | +|---|---|---| +| TypeScript | examples repo `support/sign-near-intent.ts`, `support/evm-owner.ts` | `signNearIntent`, `signEvmIntent` (near-api-js, viem) | +| Python | [../assets/python/near_intents_agent_api.py](../assets/python/near_intents_agent_api.py) | `sign_intent`, `sign_nep413`, `sign_nep366`, `sign_delegate`, `sign_eip712`, `NearKey` | +| Rust | [../assets/rust/src/lib.rs](../assets/rust/src/lib.rs) | `sign_intent`, `sign_nep413`, `sign_nep366`, `sign_delegate`, `sign_eip712` (feature `evm`), `NearKey` | + +NEAR secret key format everywhere: `ed25519:` (near-cli / +near-api-js key files). Public key: `ed25519:`. + +## Verifying a port + +1. Load `test-vectors.json`. +2. `NearKey(near.secret).public_key == near.public_key`. +3. `sign_nep413(near, nep413.payload).signature == nep413.signature_b64` (ed25519 is deterministic). +4. `hex(encode_delegate_action(...))` with `access_key_nonce + 1` and `block_height + 120`, + prefixed by the NEP-461 tag, `== nep366.encoded_hex`; `signed_delegate` matches. +5. EIP-712 signature over `eip712.payload` with `eip712.private_key` `== eip712.signature`. +6. `sha256_hex(grant.token) == grant.commitment`. + +Python: `python assets/python/test_vectors.py`. Rust: `cargo test --features evm` in +`assets/rust`. diff --git a/.agents/skills/near-intents-agent-api/references/status-and-recovery.md b/.agents/skills/near-intents-agent-api/references/status-and-recovery.md new file mode 100644 index 000000000..5c1e71760 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/status-and-recovery.md @@ -0,0 +1,119 @@ +# Status, idempotency and recovery + +## Statuses + +| Status | Meaning | Your move | +|---|---|---| +| `PENDING_SIGNATURE` | owner intent generated, not yet signed | get the signature or let it expire | +| `QUEUED` | held by the policy timelock until `details.execute_after` | wait; owner may `execution_cancel` | +| `PENDING_APPROVAL` | waiting for the owner's `approval_vote` (`details.approval_id`) | ask the owner; poll slowly | +| `PENDING_DEPOSIT` | deposit address issued, waiting for funds | show `deposit_address`; poll until `expires_at` | +| `PROCESSING` | submitted, settlement not yet observed | keep polling | +| `SUCCESS` | settled with evidence | done | +| `REFUNDED` | route refunded the input (`refund_tx_hash`) | done; tell the user | +| `FAILED` | never executed, or failed with `failure_code` | done; new request needs a new key | +| `UNCERTAIN` | outcome unknown, **may have executed** | keep polling the same id; never resubmit under a new key | +| `NEEDS_REVIEW` | automatic settlement stopped | stop; inspect `details.reason`; then `refresh=true` | + +Terminal: `SUCCESS`, `REFUNDED`, `FAILED`. `UNCERTAIN` usually resolves on its own as the API +reconciles with the provider. + +## Polling + +```text +GET /v1/status?correlation_id=&wait_ms=30000 +``` + +- Long-poll: returns early when the status becomes terminal, needs a signature, or needs review; + otherwise after `wait_ms` (max 30000). Reading also advances the operation (the API checks + chain/provider on read), so polling is how slow operations make progress — don't stop early. +- Max 10 concurrent long-polls per API key; further reads answer immediately (no error), so + don't build a tight loop on top of them. Add a small sleep when a read returns instantly with + a non-terminal status. +- Typical loop: repeat while status ∈ {`PROCESSING`, `QUEUED`} up to your deadline (a few + minutes for swaps/transfers, longer for cross-chain withdrawals and deposits). For + `PENDING_APPROVAL` / `PENDING_DEPOSIT` hand control back to the user and poll on a slower + schedule (or when they return). +- `GET /v1/agents/{id}/history` lists everything for an activity feed (newest first). Deposit + addresses nothing was paid into (open, expired, closed by deletion) are left out unless + `include_unpaid=true`; read one by its `op_…` id with `GET /v1/status`. +- A single long poll does not promise settlement; `SUCCESS` means reconciled evidence, not just + a broadcast hash. + +```python +def settle(api, cid, deadline_s=300): + end = time.monotonic() + deadline_s + while True: + s = api.get_status(cid, wait_ms=30_000) + if s["status"] not in ("PROCESSING", "QUEUED") or time.monotonic() > end: + return s +``` + +## Idempotency keys + +- Required on every non-dry execution and deposit; optional on `generate-intent`. +- 8–128 characters from `[A-Za-z0-9._:-]`. A UUIDv4 or `":"` works. + Don't build keys from unbounded user data (length) or from values that repeat across distinct + requests (e.g. `transfer-${agentId}-${recipient}` would block the second payment to the same + person). +- **Create → persist with the body → send.** If the process dies after sending, the stored key + lets you find or safely repeat the request. +- Same key + same body → the original operation (no double spend), even across restarts. +- Same key + different body → `409 idempotency_conflict`. +- After a definitive refusal or `FAILED`, a retry is a new request → new key. +- SDK/assets return the key they used (`result.idempotencyKey`, `AgentApiRequestError.idempotencyKey`) + so you can retry with it after a transport error. + +## UNCERTAIN + +The API lost the provider's answer around dispatch, so it cannot prove whether the money moved. + +1. Keep polling the same `correlation_id`. The API reconciles via the provider's records. +2. Never resubmit under a new idempotency key — that can pay twice. +3. If it stays `UNCERTAIN`, look at the operation: + - has `provider_request_id` or `dispatch_committed_at` → **the provider got it**. Only + polling resolves it. Never recover. + - neither → **the provider never got it**. `POST …/recover` may dispatch it, once. + +### `POST /v1/agents/{agent_id}/recover` + +```http +POST /v1/agents/{agent_id}/recover +X-API-Key: +X-Grant-Token: +Idempotency-Key: +{ "correlation_id": "op_…", "request": { "type": "withdraw", …the original body, byte-identical after canonicalization… } } +``` + +Authorization runs again in full: if the grant was revoked, the account frozen or the policy +tightened since, recovery is refused. `operation_not_recoverable` means it was not eligible (not +UNCERTAIN, already reached the provider, cross-chain deposit, or another API key); +`operation_recovery_unavailable` means a recovery is already running. This is why you persist +the original body and key. + +## NEEDS_REVIEW + +Automatic settlement stopped because something did not add up (e.g. evidence conflicts). + +1. Stop polling and stop retrying. +2. Read `details.reason`; cross-check `GET …/provider/requests`, `…/provider/audit`, + balances. +3. After resolving, `GET /v1/status?correlation_id=…&refresh=true` to re-observe. +4. Surface it to an operator; don't tell the user the payment failed or succeeded. + +## Operation proofs + +`GET /v1/agents/{agent_id}/operations/{correlation_id}/proof` (an `op_…` id) returns every +retained audit event of one execution, each `PROVEN` with a `c2sp.org/tlog-proof@v1` against the +notary-signed checkpoint of the API's transparency log, `PENDING` until the next checkpoint covers +it, or `UNLOGGED`. Each event's `evidence` is the result it recorded, with any settlement +transaction hashes, committed by the opening's `evidenceHash`. Use it to audit an execution offline without trusting the API; it does not +change the execution's status. Check it with the SDK's `verifyOperationProof(proof, origin)`, pinning +the origin yourself (`/log`, `api.agentsonintents.com/log` for the hosted API); see +[typescript.md](typescript.md#operation-proofs) and the examples' `11:verify-operation`. + +## Revocation and freezing vs in-flight work + +`dispatch_committed_at` is the point of no return. Before it, revoking the grant, revoking the +API key or freezing refuses the operation. After it, the operation completes; `grant_revoke` +details and API-key revocation list those committed ids. diff --git a/.agents/skills/near-intents-agent-api/references/typescript.md b/.agents/skills/near-intents-agent-api/references/typescript.md new file mode 100644 index 000000000..ea70c1660 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/references/typescript.md @@ -0,0 +1,204 @@ +# TypeScript / JavaScript + +```bash +npm i @near-intents-agent-api/sdk # or pnpm add / yarn add / bun add +``` + +Node 24+ (or any runtime with `fetch` and Web Crypto). The SDK is a thin, typed client generated +from the server's endpoint registry: one method per endpoint, JSON:API errors, no retries, no +wallet signers, **server-side only** (it needs the API key). + +## Client + +```ts +import { AgentApi, AgentApiError, AgentApiRequestError, createGrantCredential, createIdempotencyKey } from "@near-intents-agent-api/sdk"; + +const api = new AgentApi({ + apiKey: process.env.NEAR_INTENTS_AGENT_API_KEY!, // naa_… from the partner dashboard + baseUrl: process.env.NEAR_INTENTS_AGENT_API_URL, // optional; defaults to https://api.agentsonintents.com +}); + +await api.getNetwork(); +await api.whoami(); +await api.getPartnerQuota(); +const tokens = await api.getTokens(); // TokenView[] +``` + +Options: `baseUrl`, `timeoutMs` (default 150 s for settlement writes, 65 s otherwise), +`maxResponseBytes`, `fetch`, `grantToken`. + +## Owner intent loop + +```ts +const generated = await api.generateIntent({ + type: "agent_create", + name: "Trading assistant", + external_user_id: user.id, + owner: { type: "near", account_id: "alice.near", public_key: "ed25519:…" }, + policy, +}); +// generated: { correlation_id, agent_id, intent: { standard, payload }, preview, expires_at, signer, idempotencyKey } + +// → send { correlation_id, intent, preview } to the browser; it returns signed_data +const signed_data = await browserSigns(generated.intent); + +await api.submitIntent({ type: generated.type, correlation_id: generated.correlation_id, signed_data }); +let status = await api.getStatus(generated.correlation_id, { waitMs: 30_000 }); +``` + +`generateIntent` returns `idempotencyKey`; to retry an interrupted generation pass the same one: +`api.generateIntent(req, { idempotencyKey })`. + +Typed narrowing: `GenerateIntentResponseOf<"policy_update">`, `StatusResponseOf<"swap">`. + +## Grants + +```ts +const { token, commitment } = createGrantCredential(); // store token encrypted first +const g = await api.generateIntent({ + type: "grant_issue", agent_id, label: "assistant-session-42", + credential: commitment, + expires_at: new Date(Date.now() + 7 * 86_400_000).toISOString(), +}); +// owner signs, submit, wait → status.details.grant.grant_id + +const agent = api.forGrant(token); // one client per grant +``` + +## Executions + +```ts +const quote = await agent.swap(agentId, { + origin_asset: "nep141:wrap.near", destination_asset: usdc, amount: "1000000000000000000000000", dry: true, +}); // QuoteResponse, no key + +const idempotencyKey = createIdempotencyKey(); // persist with the body first +const op = await agent.swap(agentId, { origin_asset: "nep141:wrap.near", destination_asset: usdc, amount }, { idempotencyKey }); +// op: StatusResponseOf<"swap"> & { idempotencyKey } + +const deposit = await api.deposit(agentId, { origin_asset: usdcOnBase }, { idempotencyKey: createIdempotencyKey() }); +// deposit.details: deposit_address, memo?, min_amount, expires_at, refund_to +``` + +Also: `withdraw`, `transfer`, `shield`, `unshield`, `recover(agentId, { correlation_id, request }, { idempotencyKey })`. + +## Reads + +`listAgents({ external_user_id, cursor })`, `getAgent`, `getWallet`, `getBalances(id, { source, asset })`, +`getPolicy`, `getPolicyHistory`, `listGrants`, `listScheduledExecutions`, `getContainment`, +`listApprovals`, `getApproval`, `getAddress(id, "near")`, `listProviderRecords(id, kind)`, +`getHistory(id, { cursor, limit, include_unpaid })`, `getStatus(cid, { waitMs, refresh })`, +`getOperationProof(id, cid)`. + +## Identity signing + +With a grant, and only for recipients in the policy's `sign.recipients` (never `intents.near` or +`intents.far`), the API signs a canonical identity challenge with the agent's NEAR key (NEP-413): + +```ts +const signature = await agent.sign(agentId, { message: canonicalChallengeJson, recipient: "login.example.near" }); +// signature: { near_account_id, public_key, signature (hex), nonce (base64), recipient } +``` + +The challenge format is in [policy.md](policy.md#signing-sign). The relying party verifies the +NEP-413 signature over `message`, `nonce` and `recipient`, and that `public_key` is a full-access +key of `near_account_id`; the examples' `support/identity.ts` does both. + +## Operation proofs + +```ts +import { NoteError, ProofError, verifyOperationProof } from "@near-intents-agent-api/sdk"; + +const proof = await api.getOperationProof(agentId, "op_…"); // executions only +const verified = verifyOperationProof(proof, "api.agentsonintents.com/log"); // pin the origin yourself +// verified.proven: [{ index, fields, checkpoint, cosignedAt, evidence }]; verified.pending, verified.unlogged +``` + +Offline and without trusting the API: each `PROVEN` event hashes to a leaf of a checkpoint signed by +the log key and cosigned by the notary, and names this execution. Its `evidence`, the result it +recorded with any settlement transaction hashes, must hash to the opening's `evidenceHash` and comes +back parsed (`null` when none was served). A proof that does not hold, or altered evidence, throws +`NoteError` or `ProofError`. `PENDING` events wait for the next checkpoint. To tie the keys to +attested code, check the notary's TDX birth quote (`proof.notary.birth`) and compare its +`report_data` with `verified.birthReportData`. A deployment without a log answers +`501 transparency_log_disabled`. + +## Errors + +```ts +try { + await agent.withdraw(agentId, req, { idempotencyKey }); +} catch (error) { + if (error instanceof AgentApiError) { + switch (error.code) { + case "policy_destination_denied": /* ask owner to allow the address */ break; + case "spend_budget_exceeded": /* show remaining budget */ break; + case "policy_schedule_denied": /* outside owner hours: same request at error.availableAt */ break; + case "policy_change_throttled": /* error.availableAt */ break; + default: throw error; + } + } else if (error instanceof AgentApiRequestError) { + // transport failure: retry with error.idempotencyKey and the same body + } +} +``` + +## Waiting helper + +```ts +async function settle(api: AgentApi, cid: string, deadlineMs = 300_000) { + const end = Date.now() + deadlineMs; + for (;;) { + const s = await api.getStatus(cid, { waitMs: 30_000 }); + if (!["PROCESSING", "QUEUED"].includes(s.status) || Date.now() > end) return s; + } +} +``` + +## Signing on the server (scripts, tests, company-held owner keys) + +NEAR (near-api-js v7) — from the examples' `support/sign-near-intent.ts`: + +```ts +import { createHash } from "node:crypto"; +import { actions, encodeDelegateAction, encodeSignedDelegate, JsonRpcProvider, KeyPair, Signature } from "near-api-js"; + +async function signNearIntent(generated, keyPair: KeyPair, rpcUrl: string) { + const { intent, signer } = generated; + if (intent.standard === "nep413") + return { ...intent, public_key: signer.public_key, + signature: await signNep413(keyPair, { ...intent.payload, nonce: Buffer.from(intent.payload.nonce, "base64") }) }; + const access = await new JsonRpcProvider({ url: rpcUrl }).viewAccessKey({ + accountId: signer.account_id, publicKey: signer.public_key, finalityQuery: { finality: "final" } }); + const delegateAction = { + senderId: signer.account_id, receiverId: intent.payload.receiverId, publicKey: keyPair.getPublicKey(), + nonce: access.nonce + 1n, maxBlockHeight: BigInt(access.block_height) + 120n, + actions: intent.payload.actions.map(({ params }) => + actions.functionCall(params.methodName, Buffer.from(JSON.stringify(params.args)), BigInt(params.gas), BigInt(params.deposit))), + }; + const digest = createHash("sha256").update(encodeDelegateAction(delegateAction)).digest(); + const signature = new Signature({ keyType: 0, data: keyPair.sign(digest).signature }); + return { ...intent, signed_delegate: Buffer.from(encodeSignedDelegate({ delegateAction, signature })).toString("base64") }; +} +``` + +(`encodeDelegateAction` already prepends the NEP-461 prefix. `signNep413` is in the examples' +`support/near-owner.ts`: sha256 of the NEP-413 borsh body, signed with the key pair, base64.) + +EVM (viem): + +```ts +import { privateKeyToAccount } from "viem/accounts"; +const account = privateKeyToAccount(pk); +const owner = { type: "evm", address: account.address.toLowerCase(), chain_id: 1, public_key: `0x${account.publicKey.slice(4)}` }; +const signed_data = { ...intent, signature: await account.signTypedData(intent.payload) }; +``` + +Browser signing for all four standards: [owner-intents.md](owner-intents.md#backend-for-frontend-bff-split). + +## Next.js / serverless notes + +- Keep the SDK in server code only (route handlers, server actions); never import it into a + client component with the key. +- Long-polls take up to 30 s and executions up to ~2 min: set the function timeout accordingly + or move polling to a background job / queue. diff --git a/.agents/skills/near-intents-agent-api/scripts/check_api.sh b/.agents/skills/near-intents-agent-api/scripts/check_api.sh new file mode 100755 index 000000000..f77aea5c9 --- /dev/null +++ b/.agents/skills/near-intents-agent-api/scripts/check_api.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Read-only smoke test for a NEAR Intents Agent API key. Moves no funds, changes nothing. +# +# NEAR_INTENTS_AGENT_API_KEY=naa_... ./check_api.sh +# NEAR_INTENTS_AGENT_API_URL=https://api.agentsonintents.com NEAR_INTENTS_AGENT_API_KEY=naa_... ./check_api.sh +# +# Needs curl; uses jq for pretty output when installed. +set -euo pipefail + +API_URL="${NEAR_INTENTS_AGENT_API_URL:-https://api.agentsonintents.com}" +API_URL="${API_URL%/}" + +pretty() { if command -v jq >/dev/null 2>&1; then jq "$@"; else cat; fi; } +count() { if command -v jq >/dev/null 2>&1; then jq -r '.data | length'; else grep -o "\"$1\"" | wc -l | tr -d ' '; fi; } + +call() { + local path="$1" auth="${2:-}" + local args=(-sS -w '\n%{http_code}' -H 'Accept: application/json') + [[ -n "$auth" ]] && args+=(-H "X-API-Key: $auth") + local out code + out="$(curl "${args[@]}" "$API_URL$path")" + code="${out##*$'\n'}" + BODY="${out%$'\n'*}" + STATUS="$code" +} + +echo "API: $API_URL" + +call /v1/network +if [[ "$STATUS" != 200 ]]; then + echo "✗ GET /v1/network → $STATUS (is the URL right?)"; echo "$BODY"; exit 1 +fi +echo "✓ network" +echo "$BODY" | pretty '{network, contract_id, supported_owner_types, overall: .status.overall, + components: [.status.components[] | {id, status, reason}]}' + +call /v1/tokens +echo "✓ tokens: $(echo "$BODY" | count asset_id) assets" + +if [[ -z "${NEAR_INTENTS_AGENT_API_KEY:-}" ]]; then + echo "! NEAR_INTENTS_AGENT_API_KEY not set: skipped authenticated checks (create a key in the partner dashboard)" + exit 0 +fi +if [[ ! "$NEAR_INTENTS_AGENT_API_KEY" =~ ^naa_[A-Za-z0-9_-]{43}$ ]]; then + echo "✗ NEAR_INTENTS_AGENT_API_KEY does not look like a naa_ key (check for quotes or whitespace)"; exit 1 +fi + +call /v1/whoami "$NEAR_INTENTS_AGENT_API_KEY" +if [[ "$STATUS" != 200 ]]; then + echo "✗ GET /v1/whoami → $STATUS"; echo "$BODY" | pretty '.errors[0] | {code, detail}'; exit 1 +fi +echo "✓ key valid"; echo "$BODY" | pretty . + +call /v1/quotas "$NEAR_INTENTS_AGENT_API_KEY" +echo "✓ quotas"; echo "$BODY" | pretty '{limits, usage}' + +call "/v1/agents" "$NEAR_INTENTS_AGENT_API_KEY" +echo "✓ agents on this tenant (first page): $(echo "$BODY" | count external_user_id)" diff --git a/skills-lock.json b/skills-lock.json index c797a46a5..9e8fc09b7 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -103,6 +103,12 @@ "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", "computedHash": "6397731ced114f3657aa88b55ed13d1344a56d77ca449c568e3200c21740fa99" }, + "near-intents-agent-api": { + "source": "NEAR-Intents-Agent-API/skills", + "sourceType": "github", + "skillPath": "near-intents-agent-api/SKILL.md", + "computedHash": "86d548e72ff84470b3875b17cc36887c0e24ce542ca1bf958689b105b84db750" + }, "pr": { "source": "mattpocock/skills", "sourceType": "github", From d6fb0cd1d47ffee841dd51590933a0c7e3ae33a2 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 13:48:15 -0500 Subject: [PATCH 16/20] =?UTF-8?q?docs(plans):=20target-state=20vision=20?= =?UTF-8?q?=E2=80=94=20custody-first=20default=20path=20and=20economy=20de?= =?UTF-8?q?sign=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/README.md | 1 + docs/plans/target-state/README.md | 232 ++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 docs/plans/target-state/README.md diff --git a/docs/plans/README.md b/docs/plans/README.md index ac9da590a..05d03c636 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -13,6 +13,7 @@ These are architecture and implementation plans, not issue tickets — sprint ti docs/plans/ ├── 001–048 (flat) # advisor audit queue (open work) ├── beta-v2/ # v2 platform architecture (5 docs) +├── target-state/ # platform target-state vision: primitives, default path, economy ├── deploy-agents-rebuild/ # sprint: deploy consolidation + agents rebuild (SPRINT.md + tickets) ├── extensions/ # plans that extend the beta-v2 architecture ├── infra/ # infrastructure & migration plans diff --git a/docs/plans/target-state/README.md b/docs/plans/target-state/README.md new file mode 100644 index 000000000..9f4c29d91 --- /dev/null +++ b/docs/plans/target-state/README.md @@ -0,0 +1,232 @@ +# The target state: an elegant runtime for apps and agents + +> **Category**: direction — this is a vision doc, not an executor plan. It +> records the target state of the platform and the decisions and open design +> space that follow from it. Decisions recorded here graduate into ADRs when +> they land; open items graduate into wayfinder tickets when they are ready +> to be decided. Nothing in this doc gates an executor. + +## Thesis + +Humans own wallets and sign rules. Agents act through scoped credentials. +Apps are published compositions. Machines are rented, pinned images. And +everything durable is on-chain. + +A developer writes one authored file (`bos.app.ts`); the runtime composes it. +A user signs in once and automatically holds everything they need — session, +platform access, publish identity, payment rail. An agent holds nothing and +acts through credentials scoped to one plane each. No principal ever holds +more key than its plane requires. + +"For other apps" is the point: any app extends the root runtime, composes +plugins, and inherits the entire surface — auth, API, MCP, the deploy train, +the agent money planes — without building any of it. Tenants compose; agents +operate; the platform pays for itself at the seams where value moves. + +## The primitives inventory + +The underlying primitives, each grounded in what exists today, with what +stays open. + +| Primitive | What it is | Grounded in | Open | +|---|---|---|---| +| **Identity** | Named NEAR account (vanity, subaccounts), implicit hex account (machine identity), custody wallet (`wk_`, TEE-held), agent account (on NEAR Intents) | ADR 0001 (namespace = signer), custody publish (see AGENTS.md "Custody publish") | Custody ↔ named-account bridge (decision: hex accepted for now — see "The default path") | +| **Credentials** | `api_` (platform access), `wk_` (custody: signing + compute payment), `naa_` (Agent API partner), `ngt_` (agent grant), session gas key (NEP-611), minted deploy keys | auth plugin (API keys, statements/roles), ADR 0017, `bos login --key`, OutLayer custody | Scoped publish delegate for CI (design below) | +| **Registry** | FastKV — self-authenticating namespaces (namespace = signer), action-indexed immutable writes, history as audit trail | ADR 0001 | Metering/fee hooks on writes (economy, below) | +| **Composition** | `bos.app.ts` authored descriptor; `extends` lineage; plugins; `bos import` byte-sourcing with SRI-verified source archives | ADR 0005, 0022, 0030; beta-v2 plan | — | +| **Distribution** | One universal image (digest-pinned), CDN bundles (SRI-pinned), the deploy train with post-publish smoke and rollback | ADR 0011, 0020, 0021 | — | +| **Tenancy & compute** | Tiers: shared-host tenants (`*.`), own-instance children (`.`), sandboxes (`*.*.` — platform image on provisioned machines, lease-scoped) | ADR 0020/0021, plan 032 (spike) | Hot-swap lifecycle, request drain (wayfinder ticket 10); lease pricing | +| **Money** | NEAR Intents: policy (what may happen), grant (who may act), atomic integer amounts, idempotency keys, UNCERTAIN/NEEDS_REVIEW semantics | Agent API (api.agentsonintents.com), `@near-intents-agent-api/sdk`, better-near-auth agent-connect | Platform-side policy templates for apps | +| **Gas** | Three gasless planes (see below) | ADR 0017 (gas keys primary, relayer fallback), custody prepaid balance | Sponsored first publishes (economy, below) | +| **Access** | Platform API keys minted self-serve, permission statements/roles, org delegation | auth plugin permission registry, plan 053 | Key distribution economy (economy, below) | +| **Agent education** | MCP tools from OpenAPI, `/llms.txt`, `/skill.md`, vendored agent skills | host MCP surface, `.agents/skills/near-intents-agent-api` | Refresh cadence from NEAR-Intents-Agent-API/skills upstream | + +### The five credentials, five planes + +| Credential | Plane | Held by | Grants | +|---|---|---|---| +| wallet (NEAR / EVM / passkey) | ownership | the human | signs owner intents: policies, publishes, grants | +| `api_…` | platform access | a person, org, or agent | MCP / REST / oRPC on a runtime | +| `wk_…` | machine identity + compute payment | TEE (OutLayer) | signs publishes; pays agent compute and sandbox leases | +| `naa_…` | Agent API partner | the app backend | calls the hosted agent API | +| `ngt_…` | agent money authority | the backend, per agent session | moves funds inside an owner-signed policy | + +### The three gasless planes + +One mechanism per principal type — they never compose, by protocol: + +| Principal | Mechanism | +|---|---| +| Browser user | Session gas key (NEP-611, sponsor-funded), NEP-366 relayer fallback | +| Machine / agent | Custody wallet prepaid balance via the OutLayer coordinator | +| Named-account owner | Their own gas (escape hatch) | + +A gas key cannot sign delegate actions and `WithdrawFromGasKey` is refused +inside a delegate; the browser plane and the machine plane stay separate. +The custody publish path never touches the NEP-366 relayer. + +## The target-state loop + +An app's whole lifecycle, with no human ever holding a raw private key: + +1. **Author** — `bos init` scaffolds; the developer (or an agent) writes + `bos.app.ts`, declaring local workspaces and `extends`. +2. **Log in** — `bos login` is the one login: SIWN session + `api_` key + + custody wallet registration (TEE-held `wk_`). The CLI prints the wallet's + hex account, network, and funding handoff URL — never the key itself. + When the `outlayer` CLI is present, the wallet links into it. +3. **Fund** — the custody wallet is funded once via the OutLayer handoff URL + (manual for now; see economy). Its prepaid balance pays every publish. +4. **Publish** — `bos deploy` runs the train: preflight → build → bundle + upload (SRI-verified, CDN) → FastKV publish signed by the custody wallet + through the coordinator (`/wallet/v1/call` → `__fastdata_kv`, prepaid gas + deducted, read-back confirmed) → universal image → post-publish smoke → + Railway pinned to the digest. A red smoke rolls the config back. +5. **Inhabit** — tenants extend the runtime (`*.` composition), users + SIWN in and write gaslessly, agents get agent accounts with owner-signed + policies and per-session `ngt_` grants. +6. **Operate** — every runtime is a full agent surface: MCP tools generated + from OpenAPI, REST, oRPC, plugin RPC — all behind `api_` keys or sessions. +7. **Compute** — sandbox tier: agents rent machines (plan 032), paid from the + same `wk_` balance, leases scoped and released like every Effect Layer. +8. **Audit** — every durable step is a FastKV write: config history, deploy + manifests, custody signatures. The on-chain record is the source of truth. + +## The default path + +Decisions (operator, 2026-10-16): + +- **Custody-first login.** `bos login` becomes the single login flow — + session + `api_` key + custody wallet registration in one step. The + `--custody` flag is dropped (it is the default); escape hatches remain as + flags for named-account publishing. +- **`publish.auth` auto-resolves**: custody wallet stored → custody publish; + else wallet session → session publish; else `NEAR_PRIVATE_KEY`. Explicit + authored config still wins over auto-resolution. +- **`NEAR_PRIVATE_KEY` is an escape hatch** — advanced / named-account / + break-glass use. `bos key generate` and the near-cli-rs steps move out of + the default self-deploy flow into escape-hatch docs. +- **Relayer explicit mode (`RelayerExplicitConfig`) is deleted** — already + vestigial; ephemeral mode is the only relayer configuration. +- **Funding is manual for now** — the handoff URL is documented in the login + output and in the deploy failure path (`wallet_underfunded` 402 carries the + exact cost). Sponsored first publishes and stake-to-start are open economy + items, not onboarding blockers. +- **Hex namespaces are accepted** — custody runtimes publish under + `apps//`; branding and routing ride on + `domain`/`gateway`. A named-account bridge (link or delegate) may be + designed later; it is not required. +- **Child CI signs with a scoped publish delegate** — the plan 042 "mint the + token to set in CI" pattern extended from bundle uploads to FastKV writes: + minted from the custody wallet, scoped to account/gateway, revocable, one + secret in CI, no raw NEAR key anywhere. + +### Graduation path (implementation backlog, not built yet) + +1. `bos login` default flow: custody registration folded in, `--custody` + flag retired. +2. `publish.auth` auto-resolution in `publish.ts` (custody → session → + `NEAR_PRIVATE_KEY`). +3. Scoped publish delegate mint (extends the deploy-key mint server route to + FastKV write scope). +4. Delete `RelayerExplicitConfig` + re-add-`NEAR_RELAYER_PRIVATE_KEY` docs. +5. Self-deploy doc rewrite around the 3-command default path. + +## The economy layer (open design space) + +Mapped, not decided. Each item graduates into a wayfinder ticket when it is +ready to be decided. + +### Staking — the two senses + +- **Staking for access** — a principal stakes NEAR to obtain or maintain + access tiers: API-key quotas, sandbox lease eligibility, publish rate + limits. The stake is sybil resistance and skin-in-the-game; it is + collateral, not payment. +- **Staking to run infra** — operators (city nodes, sandbox hosts, tenant + platforms) stake NEAR to participate in serving the network; the stake is + forfeitable for abuse or downtime. This is the economic security layer for + the compute tiers. + +The primitives already support both: the custody wallet is a NEAR account +that can hold staked position, and FastKV's action-indexed writes make +access grants and their revocations auditable. + +### Key issuance and distribution + +- Platform `api_` keys: self-serve minting in Settings → API Keys (exists); + org-scoped delegation and per-member/agent distribution (plan 053 + permissioning is the base); quotas as statements. +- Partner `naa_` keys: the hosted Agent API's partner dashboard — apps + integrate by holding one backend key. +- Minted deploy keys / publish delegates: scoped, revocable, per + account/gateway — the CI story above. + +Open: who can mint for whom; whether staking gates issuance tiers; expiry +and revocation conventions; how tenants re-brand/re-sell keys to their own +users (the two-level distribution problem). + +### Who pays whom + +| Cost | Payer today | Open surface | +|---|---|---| +| Publish gas | custody wallet prepaid balance | sponsored first publishes; publish fees | +| Browser user writes | Sponsor (capped) | sponsor budget tiers; stake-to-raise caps | +| Sandbox compute | `wk_` payments (planned, plan 032) | lease pricing; marketplace of hosts | +| Bundle storage / CDN | platform (R2) | borrowed-storage cold start (plan 042); storage rent | +| Agent executions | users move their own funds inside policies | platform fee via Intents solver spread | + +### Revenue surfaces the primitives already expose + +1. **Compute** — metered sandbox leases paid from `wk_` balances (the same + credential that signs publishes pays for the machines; nothing new to + collect). +2. **Sponsorship beyond caps** — sponsored publishes/writes above the free + tier. +3. **Agent execution flow** — the platform as a high-volume `naa_` partner: + fee or spread share on the money its apps' agents move. +4. **Staking float** — staked access collateral earns yield while locked + (stNEAR-style), aligning the platform's carry with users' security. +5. **Tenant services** — premium composition features (SSR gating is already + a per-tenant column; provisioned infra per plan 031). + +## Use cases this unlocks + +- **Zero-key onboarding** — `bos init` → `bos login` → `bos deploy`; no + near-cli-rs, no env secrets, no crypto knowledge required to run a + production app. +- **Agents as full-stack deployers** — an agent with a custody wallet + authors, publishes, rents compute, and operates an app end to end; a human + signed a policy once. +- **Supply-chain posture** — no plaintext private keys in CI secrets or + repos, ever; TEE-held keys and revocable scoped delegates replace them. +- **Apps with agent economies built in** — a tenant app embeds agent + accounts via better-near-auth's agent-connect (hosted API, `naa_`/`ngt_`), + so its users' agents hold and move funds inside owner-signed policies. +- **A marketplace of runtimes** — the spreadability tree + (everything.dev → citynode.app → chicago.citynode.app) extends downward + forever; each hop is a FastKV record, not a fork. + +## Agents domain engine posture + +Decision (operator, 2026-10-16): **hosted-first**. The hosted Agent API is +the only money plane for now. The vendored self-hosted control plane +(deploy-agents-rebuild sprint, Stream B) is paused; the +`better-near-auth` agent-connect plugin (SDK against +api.agentsonintents.com) is the integration surface. The wire contract is +the stable seam — the vendoring decision is reversible without touching +call sites, and tickets 14–15 (upstream proposals to +NEARFDE/near-intents-agent-api) remain the long-run convergence path. + +## Consulted parents + +- [beta-v2/](../beta-v2/) — composition vision (`app.ts`, tiers, tenants) +- [wayfinder/beta-v2-map.md](../wayfinder/beta-v2-map.md) — decision map; + the "Everything is a node" wayfinder (citynode.app#233–#252) re-derives + tenancy in node/zone vocabulary and consults this doc +- [deploy-agents-rebuild/](../deploy-agents-rebuild/) — deploy consolidation + (landed) + agents rebuild sprint (paused, hosted-first) +- ADRs 0001, 0011, 0017, 0020, 0021, 0022, 0030; plans 031, 032, 042, 053 +- Agent API skill: `.agents/skills/near-intents-agent-api/` (vendored from + NEAR-Intents-Agent-API/skills; upstream currently ships no LICENSE file — + flagged for an upstream MIT request) From e788a2e9f455cfce07f2c1fc3368f4a934f93a44 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 14:38:30 -0500 Subject: [PATCH 17/20] feat(agents): re-implement agent-connect as a dedicated plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the agent-account control plane out of better-near-auth/plugins/auth into plugins/agent-connect: oRPC contract (agent CRUD, prepare/submit/status intents, grants), Effect services over @near-intents-agent-api/sdk (the only protocol dependency — types re-exported, never re-declared), sealed grant vault, pre-send intent ledger, and an own plugin_agent_connect schema with a fresh migration chain (the never-published auth 0015 is dropped). better-near-auth keeps only the wallet side: signAgentIntent signs a generated intent.payload with the connected wallet (nep413/nep366/webauthn); the BFF split means the browser never sees the naa_ key or ngt_ tokens. Drift kit: SDK catalog-pinned, an env-guarded live drift-gate test against the hosted /openapi.json, and the vendored near-intents-agent-api skill as the rules source. UI (agent accounts, budget view, grants) is a planned follow-up. --- .changeset/agent-connect-client.md | 5 + .changeset/agent-connect-plugin.md | 4 +- bos.app.ts | 12 +- packages/better-near-auth/src/agent-api.ts | 38 - .../src/agent-connect-client.ts | 161 +- .../src/agent-connect.test.ts | 304 -- .../better-near-auth/src/agent-connect.ts | 711 ----- packages/better-near-auth/src/agent-schema.ts | 136 - packages/better-near-auth/src/client.ts | 2 +- packages/better-near-auth/src/index.ts | 31 - packages/everything-dev/src/ui/auth.ts | 7 +- plugins/agent-connect/CHANGELOG.md | 7 + plugins/agent-connect/README.md | 50 + plugins/agent-connect/drizzle.config.ts | 19 + plugins/agent-connect/package.json | 56 + plugins/agent-connect/plugin.dev.ts | 21 + plugins/agent-connect/src/contract.ts | 291 ++ plugins/agent-connect/src/db/index.ts | 26 + plugins/agent-connect/src/db/layer.ts | 87 + plugins/agent-connect/src/db/migrate.ts | 43 + .../src/db/migrations/0000_init.sql} | 4 +- .../src/db/migrations/meta/0000_snapshot.json | 364 +++ .../src/db/migrations/meta/_journal.json | 13 + plugins/agent-connect/src/db/schema.ts | 71 + plugins/agent-connect/src/global.d.ts | 11 + plugins/agent-connect/src/index.ts | 529 ++++ plugins/agent-connect/src/lib/context.ts | 13 + .../agent-connect/src/services/agent-api.ts | 145 + plugins/agent-connect/src/services/store.ts | 244 ++ plugins/agent-connect/src/services/vault.ts | 90 + .../tests/integration/agent-connect.test.ts | 265 ++ .../tests/integration/drift-gate.test.ts | 37 + plugins/agent-connect/tests/setup.ts | 133 + plugins/agent-connect/tests/types.d.ts | 8 + .../agent-connect/tests/unit/vault.test.ts | 40 + plugins/agent-connect/tsconfig.json | 37 + plugins/agent-connect/types/contract.d.ts | 234 ++ plugins/agent-connect/vitest.config.ts | 19 + plugins/auth/src/auth-config.ts | 5 - plugins/auth/src/auth-instance.ts | 18 +- plugins/auth/src/config-schemas.ts | 6 - plugins/auth/src/config.ts | 7 - .../src/db/migrations/meta/0015_snapshot.json | 2575 ----------------- .../auth/src/db/migrations/meta/_journal.json | 7 - plugins/auth/src/db/schema.ts | 67 - pnpm-lock.yaml | 76 + pnpm-workspace.yaml | 1 + 47 files changed, 2961 insertions(+), 4069 deletions(-) create mode 100644 .changeset/agent-connect-client.md delete mode 100644 packages/better-near-auth/src/agent-api.ts delete mode 100644 packages/better-near-auth/src/agent-connect.test.ts delete mode 100644 packages/better-near-auth/src/agent-connect.ts delete mode 100644 packages/better-near-auth/src/agent-schema.ts create mode 100644 plugins/agent-connect/CHANGELOG.md create mode 100644 plugins/agent-connect/README.md create mode 100644 plugins/agent-connect/drizzle.config.ts create mode 100644 plugins/agent-connect/package.json create mode 100644 plugins/agent-connect/plugin.dev.ts create mode 100644 plugins/agent-connect/src/contract.ts create mode 100644 plugins/agent-connect/src/db/index.ts create mode 100644 plugins/agent-connect/src/db/layer.ts create mode 100644 plugins/agent-connect/src/db/migrate.ts rename plugins/{auth/src/db/migrations/0015_agent-connect.sql => agent-connect/src/db/migrations/0000_init.sql} (76%) create mode 100644 plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json create mode 100644 plugins/agent-connect/src/db/migrations/meta/_journal.json create mode 100644 plugins/agent-connect/src/db/schema.ts create mode 100644 plugins/agent-connect/src/global.d.ts create mode 100644 plugins/agent-connect/src/index.ts create mode 100644 plugins/agent-connect/src/lib/context.ts create mode 100644 plugins/agent-connect/src/services/agent-api.ts create mode 100644 plugins/agent-connect/src/services/store.ts create mode 100644 plugins/agent-connect/src/services/vault.ts create mode 100644 plugins/agent-connect/tests/integration/agent-connect.test.ts create mode 100644 plugins/agent-connect/tests/integration/drift-gate.test.ts create mode 100644 plugins/agent-connect/tests/setup.ts create mode 100644 plugins/agent-connect/tests/types.d.ts create mode 100644 plugins/agent-connect/tests/unit/vault.test.ts create mode 100644 plugins/agent-connect/tsconfig.json create mode 100644 plugins/agent-connect/types/contract.d.ts create mode 100644 plugins/agent-connect/vitest.config.ts delete mode 100644 plugins/auth/src/db/migrations/meta/0015_snapshot.json diff --git a/.changeset/agent-connect-client.md b/.changeset/agent-connect-client.md new file mode 100644 index 000000000..1736c412c --- /dev/null +++ b/.changeset/agent-connect-client.md @@ -0,0 +1,5 @@ +--- +"better-near-auth": minor +--- + +Add `signAgentIntent` to the client surface (`better-near-auth/client`): signs a generated Agent Connect owner intent byte-for-byte with the connected wallet — `nep413` via signMessage, `nep366` via signDelegateActions, `webauthn` via the passkey authenticator — returning the wallet's output for the caller to submit. The agent-account control plane itself moved out of this package into the new `@everything-dev/agent-connect-plugin`. diff --git a/.changeset/agent-connect-plugin.md b/.changeset/agent-connect-plugin.md index c28129602..f0483b50e 100644 --- a/.changeset/agent-connect-plugin.md +++ b/.changeset/agent-connect-plugin.md @@ -1,5 +1,5 @@ --- -"better-near-auth": minor +"@everything-dev/agent-connect-plugin": minor --- -Add an `agentConnect` plugin for the NEAR Intents Agent API (https://api.agentsonintents.com): give every signed-in user an owner-controlled agent account on NEAR Intents — the owner signs the rules once with their wallet or passkey, and agents hold revocable `ngt_` grant tokens that only act inside the owner-signed policy. The server plugin (`agentConnect`) wraps `@near-intents-agent-api/sdk` behind a small endpoint surface — `agent/create`, `agent/intents/submit`, `agent/status`, `agent/list`, `agent/grants/issue|revoke|list` — enforcing session↔agent↔intent ownership on every route, persisting idempotency keys before sends, and storing grant tokens encrypted at rest (HKDF→AES-256-GCM, never sent to a browser). The client plugin (`agentConnectClient`, surface `client.agent.*`) signs generated intents byte-for-byte with the connected wallet — `nep413` via signMessage, `nep366` via signDelegateActions, `webauthn` via the passkey authenticator — and submits them in one `signAndSubmitIntent` call. Server types (`Policy`, `OwnerWallet`, `SignedData`, `StatusResponse`, `GrantView`, …) are re-exported from the SDK rather than re-declared. Register the plugin unconditionally; it stays dormant until a `naa_` partner API key is configured (secret `NEAR_INTENTS_AGENT_API_KEY`, optional `agentApi.baseUrl`/`timeoutMs` auth variables). +New plugin: the Agent Connect control plane — every signed-in user gets an owner-controlled agent account on the NEAR Intents Agent API (https://api.agentsonintents.com). The owner signs the rules once with their wallet; agents hold revocable `ngt_` grant tokens that only act inside the owner-signed policy. The plugin consumes `@near-intents-agent-api/sdk` (catalog-pinned, the only protocol dependency — types re-exported, never re-declared) and adds only platform concerns: session ownership via the auth context, the sealed grant-token vault (HKDF-SHA256 → AES-256-GCM, never sent to a browser), the pre-send intent ledger (persist before send, idempotent retry reuses the key), and the oRPC surface (`agent/create`, `agent/list`, `agent/{id}`, `agent/intents/prepare|submit|status`, `agent/{id}/grants`) which exposes the domain to REST and MCP through the host's OpenAPI mounts. Owner intents follow the BFF split — prepare returns `intent.payload` + preview, the browser signs via better-near-auth's `signAgentIntent`, submit carries the wallet's output. Dormant until `NEAR_INTENTS_AGENT_API_KEY` is configured; own `plugin_agent_connect` schema (`AGENT_CONNECT_DATABASE_URL`, pglite default); `AGENT_CONNECT_ENCRYPTION_KEY` isolates the vault key. diff --git a/bos.app.ts b/bos.app.ts index 6e0b5c20a..156b08c3c 100644 --- a/bos.app.ts +++ b/bos.app.ts @@ -34,7 +34,6 @@ export default App({ "GITHUB_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET", "FASTNEAR_API_KEY", - "NEAR_INTENTS_AGENT_API_KEY", "TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER", @@ -121,5 +120,16 @@ export default App({ secrets: ["AI_API_KEY"], ui: { name: "ai-ui", path: "plugins/ai/ui" }, }), + "agent-connect": Plugin("agent-connect").path("plugins/agent-connect", { + name: "@everything-dev/agent-connect-plugin", + variables: { + apiBaseUrl: "https://api.agentsonintents.com", + }, + secrets: [ + "NEAR_INTENTS_AGENT_API_KEY", + "AGENT_CONNECT_ENCRYPTION_KEY", + "AGENT_CONNECT_DATABASE_URL", + ], + }), }, }); diff --git a/packages/better-near-auth/src/agent-api.ts b/packages/better-near-auth/src/agent-api.ts deleted file mode 100644 index cb82877fc..000000000 --- a/packages/better-near-auth/src/agent-api.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentApiOptions } from "@near-intents-agent-api/sdk"; -import { AgentApi } from "@near-intents-agent-api/sdk"; - -export type { - AgentApiOptions, - AgentView, - BudgetView, - DeletionPreview, - ErrorDocument, - GenerateIntentResponse, - GenerateIntentResponseOf, - GrantCredential, - GrantView, - IdempotentOptions, - IdempotentResult, - Intent, - IntentPreview, - IntentType, - OwnerWallet, - Policy, - PolicyView, - SignedData, - StatusResponse, - StatusResponseOf, - StatusType, -} from "@near-intents-agent-api/sdk"; -export { - AgentApi, - AgentApiError, - AgentApiRequestError, - createGrantCredential, - createIdempotencyKey, - grantCommitment, -} from "@near-intents-agent-api/sdk"; - -export function createAgentApiClient(options: AgentApiOptions): AgentApi { - return new AgentApi(options); -} diff --git a/packages/better-near-auth/src/agent-connect-client.ts b/packages/better-near-auth/src/agent-connect-client.ts index 95aa0a9fa..babc17f2f 100644 --- a/packages/better-near-auth/src/agent-connect-client.ts +++ b/packages/better-near-auth/src/agent-connect-client.ts @@ -1,66 +1,15 @@ -import type { - BetterFetch, - BetterFetchOption, - BetterFetchResponse, - ClientStore, -} from "better-auth/client"; -import type { - AgentView, - GenerateIntentResponse, - GenerateIntentResponseOf, - GrantView, - SignedData, - StatusResponse, -} from "./agent-api.js"; -import type { - AgentStatusRequestT, - CreateAgentRequestT, - IssueAgentGrantRequestT, - ListAgentGrantsRequestT, - RevokeAgentGrantRequestT, - SubmitAgentIntentRequestT, -} from "./agent-connect.js"; -import type { AuthCallbacks } from "./auth-callbacks.js"; +import type { GenerateIntentResponse, SignedData } from "@near-intents-agent-api/sdk"; import { getWalletSigner } from "./wallet-signer.js"; -export interface AgentClientActions { - agent: { - create: ( - params: CreateAgentRequestT, - fetchOptions?: BetterFetchOption, - ) => Promise>>; - submitIntent: ( - params: SubmitAgentIntentRequestT, - fetchOptions?: BetterFetchOption, - ) => Promise>; - signIntent: (generated: GenerateIntentResponse) => Promise; - signAndSubmitIntent: ( - generated: GenerateIntentResponse, - callbacks?: AuthCallbacks, - ) => Promise>; - status: ( - params: AgentStatusRequestT, - fetchOptions?: BetterFetchOption, - ) => Promise>; - listAgents: ( - fetchOptions?: BetterFetchOption, - ) => Promise>; - issueGrant: ( - params: IssueAgentGrantRequestT, - fetchOptions?: BetterFetchOption, - ) => Promise>; - revokeGrant: ( - params: RevokeAgentGrantRequestT, - fetchOptions?: BetterFetchOption, - ) => Promise>; - listGrants: ( - params: ListAgentGrantsRequestT, - fetchOptions?: BetterFetchOption, - ) => Promise>; - }; -} +/** + * Client-side agent wallet signing — the wallet-plumbing half of the BFF + * split. The server half (intent preparation, submission, grant vault) lives + * in the `agent-connect` plugin; this surface only signs a generated + * `intent.payload` with the connected wallet and returns the wallet's output, + * byte-for-byte, for the plugin's submit route. + */ -async function signIntentWithWallet(generated: GenerateIntentResponse): Promise { +export async function signAgentIntent(generated: GenerateIntentResponse): Promise { const { standard, payload } = generated.intent; if (standard === "nep413") { @@ -106,95 +55,3 @@ async function signIntentWithWallet(generated: GenerateIntentResponse): Promise< throw new Error(`Unsupported intent signing standard: ${standard}`); } - -export const agentConnectClient = () => { - const plugin = { - id: "agentConnect" as const, - $InferServerPlugin: {}, - - getActions: ( - $fetch: BetterFetch, - $store: ClientStore, - _options: unknown, - ): AgentClientActions => { - void $store; - - return { - agent: { - create: async (params, fetchOptions?) => { - return await $fetch("/agent/create", { - method: "POST", - body: params, - ...fetchOptions, - }); - }, - submitIntent: async (params, fetchOptions?) => { - return await $fetch("/agent/intents/submit", { - method: "POST", - body: params, - ...fetchOptions, - }); - }, - signIntent: async (generated) => { - return await signIntentWithWallet(generated); - }, - signAndSubmitIntent: async (generated, callbacks?) => { - try { - const signedData = await signIntentWithWallet(generated); - const response = await $fetch("/agent/intents/submit", { - method: "POST", - body: { - type: generated.type, - correlationId: generated.correlation_id, - signedData, - }, - }); - if (response.error) { - throw new Error(response.error.message || "Failed to submit signed intent"); - } - callbacks?.onSuccess?.(); - return response; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - callbacks?.onError?.(err); - throw err; - } - }, - status: async (params, fetchOptions?) => { - return await $fetch("/agent/status", { - method: "POST", - body: params, - ...fetchOptions, - }); - }, - listAgents: async (fetchOptions?) => { - return await $fetch("/agent/list", { method: "GET", ...fetchOptions }); - }, - issueGrant: async (params, fetchOptions?) => { - return await $fetch("/agent/grants/issue", { - method: "POST", - body: params, - ...fetchOptions, - }); - }, - revokeGrant: async (params, fetchOptions?) => { - return await $fetch("/agent/grants/revoke", { - method: "POST", - body: params, - ...fetchOptions, - }); - }, - listGrants: async (params, fetchOptions?) => { - return await $fetch("/agent/grants", { - method: "POST", - body: params, - ...fetchOptions, - }); - }, - }, - }; - }, - }; - - return plugin; -}; diff --git a/packages/better-near-auth/src/agent-connect.test.ts b/packages/better-near-auth/src/agent-connect.test.ts deleted file mode 100644 index 42dfad70e..000000000 --- a/packages/better-near-auth/src/agent-connect.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { createHash } from "node:crypto"; -import { hex } from "@scure/base"; -import { getTestInstance } from "better-auth/test"; -import { describe, expect, it, vi } from "vitest"; -import { createGrantCredential, grantCommitment } from "./agent-api.js"; -import { agentConnect } from "./agent-connect.js"; -import { siwn } from "./index.js"; - -const MOCK_ACCOUNT_ID = "test.near"; -const MOCK_RECIPIENT = "example.near"; -const MOCK_PUBLIC_KEY = "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD"; -const AGENT_API_BASE = "http://localhost:9999"; - -vi.mock("near-kit", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Near: vi.fn(function NearMock() { - return {}; - }), - verifyNep413Signature: vi.fn(() => Promise.resolve(true)), - }; -}); - -function makeUniqueNonce(): Uint8Array { - const nonce = new Uint8Array(32); - crypto.getRandomValues(nonce); - return nonce; -} - -const mockSignedMessage = { - accountId: MOCK_ACCOUNT_ID, - publicKey: MOCK_PUBLIC_KEY, - signature: "mock-signature-base64", -}; - -function makeVerifyBody() { - const nonceBytes = makeUniqueNonce(); - const nonceHex = hex.encode(nonceBytes); - return { - signedMessage: mockSignedMessage, - message: `Sign in to ${MOCK_RECIPIENT}`, - recipient: MOCK_RECIPIENT, - nonce: nonceHex, - accountId: MOCK_ACCOUNT_ID, - }; -} - -const TEST_POLICY = { - frozen: false, - actions: ["swap", "transfer", "withdraw"], - confidential: false, - owner_approval: false, - assets: "any", - limits: {}, - max_actions_per_hour: null, - destinations: { mode: "only", list: [] }, - budget: { daily_usd: "10", weekly_usd: null, monthly_usd: null }, - timelock_ms: 0, -}; - -function makeAgentApiFetch() { - const calls: { url: string; init?: RequestInit }[] = []; - const handler = async ( - url: string | URL, - init?: RequestInit & { body?: string }, - ): Promise => { - calls.push({ url: String(url), init }); - const path = new URL(String(url)).pathname; - if (path.endsWith("/v1/generate-intent")) { - const body = JSON.parse(init?.body ?? "{}") as { - type: string; - agent_id?: string; - }; - const agentId = body.agent_id ?? "ag_test1"; - return Response.json({ - correlation_id: `intent_${calls.length}`, - type: body.type, - agent_id: agentId, - status: "PENDING_SIGNATURE", - expires_at: new Date(Date.now() + 600_000).toISOString(), - signer: { type: "near", account_id: MOCK_ACCOUNT_ID, public_key: MOCK_PUBLIC_KEY }, - intent: { - standard: "nep413", - payload: { - message: "create agent", - recipient: "api.agentsonintents.com", - nonce: "dGVzdA==", - }, - }, - preview: { summary: "Create your agent account" }, - }); - } - if (path.endsWith("/v1/submit-intent")) { - const body = JSON.parse(init?.body ?? "{}") as { type: string }; - const details = - body.type === "grant_issue" - ? { - grant: { - grant_id: "64hexgrantid", - agent_id: "ag_test1", - wallet_id: "w", - label: "l", - issued_at: "", - expires_at: "", - revoked_at: null, - revoked_reason: null, - owner_epoch: 0, - owner_message: {}, - }, - } - : {}; - return Response.json({ - correlation_id: "intent_1", - agent_id: "ag_test1", - status: "SUCCESS", - failure_code: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - dispatch_committed_at: null, - grant: null, - type: body.type, - details, - }); - } - if (path.endsWith("/v1/status")) { - return Response.json({ - correlation_id: "intent_1", - agent_id: "ag_test1", - status: "SUCCESS", - failure_code: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - dispatch_committed_at: null, - grant: null, - type: "agent_create", - details: { agent_id: "ag_test1", revision: 1, policy_hash: "hash", transaction_hash: null }, - }); - } - return Response.json( - { errors: [{ status: "404", code: "not_found", title: "Not found" }] }, - { status: 404 }, - ); - }; - const fetchFn = vi.fn(handler) as unknown as typeof fetch; - return { fetchFn, calls }; -} - -async function setup() { - const { fetchFn } = makeAgentApiFetch(); - return getTestInstance( - { - plugins: [ - siwn({ - recipient: MOCK_RECIPIENT, - validateLimitedAccessKey: async () => true, - }), - agentConnect({ - apiKey: `naa_${"a".repeat(43)}`, - baseUrl: AGENT_API_BASE, - fetch: fetchFn, - }), - ], - }, - { - clientOptions: { plugins: [] }, - disableTestUser: true, - }, - ); -} - -async function verifyWithCookie(customFetchImpl: (r: string, o?: unknown) => Promise) { - const res = await customFetchImpl("http://localhost/api/auth/near/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(makeVerifyBody()), - }); - expect(res.status).toBe(200); - const cookie = res.headers.get("set-cookie") || ""; - expect(cookie).not.toBe(""); - return cookie; -} - -describe("agent connect grant credentials", () => { - it("issues ngt_ tokens whose commitment is the sha256 of the token", () => { - const { token, commitment } = createGrantCredential(); - expect(token).toMatch(/^ngt_[A-Za-z0-9_-]{43}$/); - expect(commitment).toBe(createHash("sha256").update(token).digest("hex")); - expect(commitment).toBe(grantCommitment(token)); - }); - - it("stores grant tokens encrypted and recovers them server-side only", async () => { - const { encryptGrantToken, decryptGrantToken } = await import("./agent-connect.js"); - const secret = "test-auth-secret"; - const { token } = createGrantCredential(); - - const stored = await encryptGrantToken(token, secret); - expect(stored.encrypted).not.toContain(token); - expect(stored.iv).not.toContain(token); - await expect(decryptGrantToken(stored.encrypted, stored.iv, "wrong-secret")).rejects.toThrow(); - await expect(decryptGrantToken(stored.encrypted, stored.iv, secret)).resolves.toBe(token); - }); -}); - -describe("agent connect routes", () => { - vi.setConfig({ testTimeout: 30_000 }); - - it("requires a session", async () => { - const { customFetchImpl } = await setup(); - const res = await customFetchImpl("http://localhost/api/auth/agent/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), - }); - expect(res.status).toBe(401); - }); - - it("returns the generated intent without leaking the api key", async () => { - const { customFetchImpl } = await setup(); - const cookie = await verifyWithCookie(customFetchImpl); - - const res = await customFetchImpl("http://localhost/api/auth/agent/create", { - method: "POST", - headers: { "Content-Type": "application/json", cookie }, - body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), - }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.correlation_id).toMatch(/^intent_/); - expect(body.intent.standard).toBe("nep413"); - expect(JSON.stringify(body)).not.toContain("naa_"); - }); - - it("refuses submits for correlation ids not issued to the user", async () => { - const { customFetchImpl } = await setup(); - const cookie = await verifyWithCookie(customFetchImpl); - - const res = await customFetchImpl("http://localhost/api/auth/agent/intents/submit", { - method: "POST", - headers: { "Content-Type": "application/json", cookie }, - body: JSON.stringify({ - type: "agent_create", - correlationId: "intent_not_issued", - signedData: { - standard: "nep413", - payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, - public_key: MOCK_PUBLIC_KEY, - signature: "sig", - }, - }), - }); - expect(res.status).toBe(404); - }); - - it("rejects a submit whose type does not match the issued intent", async () => { - const { customFetchImpl } = await setup(); - const cookie = await verifyWithCookie(customFetchImpl); - - const createRes = await customFetchImpl("http://localhost/api/auth/agent/create", { - method: "POST", - headers: { "Content-Type": "application/json", cookie }, - body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), - }); - const created = (await createRes.json()) as { correlation_id: string }; - - const res = await customFetchImpl("http://localhost/api/auth/agent/intents/submit", { - method: "POST", - headers: { "Content-Type": "application/json", cookie }, - body: JSON.stringify({ - type: "grant_revoke", - correlationId: created.correlation_id, - signedData: { - standard: "nep413", - payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, - public_key: MOCK_PUBLIC_KEY, - signature: "sig", - }, - }), - }); - expect(res.status).toBe(400); - }); - - it("keeps grant tokens server-side when issuing a grant", async () => { - const { customFetchImpl } = await setup(); - const cookie = await verifyWithCookie(customFetchImpl); - - const createRes = await customFetchImpl("http://localhost/api/auth/agent/create", { - method: "POST", - headers: { "Content-Type": "application/json", cookie }, - body: JSON.stringify({ name: "my agent", policy: TEST_POLICY }), - }); - expect(createRes.status).toBe(200); - - const res = await customFetchImpl("http://localhost/api/auth/agent/grants/issue", { - method: "POST", - headers: { "Content-Type": "application/json", cookie }, - body: JSON.stringify({ agentId: "ag_test1", label: "assistant-session-42" }), - }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.type).toBe("grant_issue"); - expect(JSON.stringify(body)).not.toContain("ngt_"); - }); -}); diff --git a/packages/better-near-auth/src/agent-connect.ts b/packages/better-near-auth/src/agent-connect.ts deleted file mode 100644 index eb4e77c50..000000000 --- a/packages/better-near-auth/src/agent-connect.ts +++ /dev/null @@ -1,711 +0,0 @@ -import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api"; -import type { BetterAuthPlugin, DBAdapter } from "better-auth/types"; -import z from "zod"; -import type { - AgentApiOptions, - GenerateIntentResponse, - IdempotentResult, - OwnerWallet, - Policy, - SignedData, - StatusResponse, -} from "./agent-api.js"; -import { - AgentApiError, - AgentApiRequestError, - createAgentApiClient, - createGrantCredential, - createIdempotencyKey, -} from "./agent-api.js"; -import { agentSchema } from "./agent-schema.js"; -import type { NearAccount } from "./types.js"; -import { decryptPrivateKey, encryptPrivateKey } from "./utils.js"; - -const GRANT_TOKEN_SALT = "better-near-auth-agents"; -const MAX_GRANT_TTL_MS = 365 * 24 * 60 * 60 * 1000; -const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000; -const PRE_SEND_TTL_MS = 15 * 60 * 1000; - -const INTENT_TYPES = [ - "agent_create", - "policy_update", - "agent_freeze", - "agent_unfreeze", - "grant_issue", - "grant_revoke", - "execution_cancel", - "agent_archive", - "agent_restore", - "agent_delete", - "approval_vote", -] as const; - -const intentTypeSchema = z.enum(INTENT_TYPES); - -export const CreateAgentRequest = z.object({ - name: z.string().min(1).max(64), - policy: z.custom(() => true), -}); -export type CreateAgentRequestT = z.infer; - -export const SubmitAgentIntentRequest = z.object({ - type: intentTypeSchema, - correlationId: z.string().min(1), - signedData: z.custom(() => true), -}); -export type SubmitAgentIntentRequestT = z.infer; - -export const AgentStatusRequest = z.object({ - correlationId: z.string().min(1), - waitMs: z.number().int().min(0).max(30_000).optional(), -}); -export type AgentStatusRequestT = z.infer; - -export const IssueAgentGrantRequest = z.object({ - agentId: z.string().min(1), - label: z.string().min(1).max(100), - expiresAt: z.string().datetime().optional(), -}); -export type IssueAgentGrantRequestT = z.infer; - -export const RevokeAgentGrantRequest = z.object({ - agentId: z.string().min(1), - grantId: z.string().min(1), -}); -export type RevokeAgentGrantRequestT = z.infer; - -export const ListAgentGrantsRequest = z.object({ - agentId: z.string().min(1), -}); -export type ListAgentGrantsRequestT = z.infer; - -export interface AgentAccountRow { - id: string; - userId: string; - agentId: string; - name: string; - ownerType: string; - ownerAccountId?: string | null; - status: string; - createdAt: Date; -} - -export interface OwnerIntentRow { - id: string; - correlationId: string; - idempotencyKey: string; - intentType: string; - agentId?: string | null; - grantRowId?: string | null; - userId: string; - status: string; - expiresAt: Date; - createdAt: Date; -} - -export interface AgentGrantRow { - id: string; - userId: string; - agentId: string; - grantId?: string | null; - correlationId?: string | null; - label: string; - tokenCiphertext: string; - tokenIv: string; - commitment: string; - expiresAt: Date; - revokedAt?: Date | null; - createdAt: Date; -} - -export interface AgentConnectPasskeyOwner { - rpId: string; - origin: string; -} - -export type AgentConnectOptions = Omit & { - apiKey?: string; - passkeyOwner?: AgentConnectPasskeyOwner; -}; - -type APIErrorStatus = - | "BAD_REQUEST" - | "UNAUTHORIZED" - | "FORBIDDEN" - | "NOT_FOUND" - | "CONFLICT" - | "TOO_MANY_REQUESTS" - | "BAD_GATEWAY" - | "SERVICE_UNAVAILABLE" - | "INTERNAL_SERVER_ERROR"; - -const AGENT_API_ERROR_STATUS: Record = { - 400: "BAD_REQUEST", - 401: "UNAUTHORIZED", - 403: "FORBIDDEN", - 404: "NOT_FOUND", - 409: "CONFLICT", - 410: "NOT_FOUND", - 429: "TOO_MANY_REQUESTS", - 502: "BAD_GATEWAY", - 503: "SERVICE_UNAVAILABLE", -}; - -function mapAgentApiError(error: unknown): never { - if (error instanceof AgentApiRequestError) { - throw new APIError("SERVICE_UNAVAILABLE", { - message: "Agent API is unreachable — retry with the same request", - status: 503, - }); - } - if (error instanceof AgentApiError) { - throw new APIError(AGENT_API_ERROR_STATUS[error.status] ?? "INTERNAL_SERVER_ERROR", { - message: error.detail || error.title, - code: error.code, - retryable: error.retryable, - availableAt: error.availableAt, - status: error.status, - }); - } - throw error; -} - -async function encryptGrantToken( - token: string, - secret: string, -): Promise<{ encrypted: string; iv: string }> { - return encryptPrivateKey(new TextEncoder().encode(token), secret, GRANT_TOKEN_SALT); -} - -async function decryptGrantToken(encrypted: string, iv: string, secret: string): Promise { - const bytes = await decryptPrivateKey(encrypted, iv, secret, GRANT_TOKEN_SALT); - return new TextDecoder().decode(bytes); -} - -export const agentConnect = (options: AgentConnectOptions): BetterAuthPlugin => { - if (!options.apiKey) { - return { - id: "agentConnect", - schema: agentSchema, - endpoints: {}, - }; - } - - const api = createAgentApiClient({ - baseUrl: options.baseUrl, - timeoutMs: options.timeoutMs, - maxResponseBytes: options.maxResponseBytes, - fetch: options.fetch, - apiKey: options.apiKey, - }); - - const requireUser = (session: { user: { id: string } } | null): string => { - if (!session) { - throw new APIError("UNAUTHORIZED", { message: "Authentication required", status: 401 }); - } - return session.user.id; - }; - - const requireOwnedAgent = async ( - adapter: DBAdapter, - userId: string, - agentId: string, - ): Promise => { - const row = (await adapter.findOne({ - model: "agentAccount", - where: [ - { field: "agentId", operator: "eq", value: agentId }, - { field: "userId", operator: "eq", value: userId }, - ], - })) as AgentAccountRow | null; - if (!row) { - throw new APIError("NOT_FOUND", { - message: "Agent not found for this user", - status: 404, - }); - } - return row; - }; - - const requireOwnedIntent = async ( - adapter: DBAdapter, - userId: string, - correlationId: string, - ): Promise => { - const row = (await adapter.findOne({ - model: "ownerIntent", - where: [{ field: "correlationId", operator: "eq", value: correlationId }], - })) as OwnerIntentRow | null; - if (!row || row.userId !== userId) { - throw new APIError("NOT_FOUND", { message: "Unknown correlation id", status: 404 }); - } - return row; - }; - - const deriveOwner = async (adapter: DBAdapter, userId: string): Promise => { - const nearAccount = (await adapter.findOne({ - model: "nearAccount", - where: [ - { field: "userId", operator: "eq", value: userId }, - { field: "isPrimary", operator: "eq", value: true }, - ], - })) as NearAccount | null; - if (nearAccount) { - return { - type: "near", - account_id: nearAccount.accountId, - public_key: nearAccount.publicKey, - }; - } - if (options.passkeyOwner) { - const authenticators = (await adapter.findMany({ - model: "passkey", - where: [{ field: "userId", operator: "eq", value: userId }], - })) as Array<{ credentialID: string; publicKey: string; createdAt?: Date | string }> | null; - const latest = authenticators - ?.slice() - .sort( - (a, b) => - (a.createdAt ? new Date(a.createdAt).getTime() : 0) - - (b.createdAt ? new Date(b.createdAt).getTime() : 0), - ) - .at(-1); - if (latest) { - return { - type: "passkey", - credential_id: latest.credentialID, - public_key: latest.publicKey, - rp_id: options.passkeyOwner.rpId, - origin: options.passkeyOwner.origin, - }; - } - } - throw new APIError("BAD_REQUEST", { - message: "No owner wallet available — link a NEAR account or register a passkey first", - status: 400, - }); - }; - - const runIntent = async ( - adapter: DBAdapter, - args: { - idempotencyKey: string; - intentType: string; - agentId?: string | null; - grantRowId?: string | null; - userId: string; - }, - send: () => Promise>, - ): Promise> => { - await adapter.create({ - model: "ownerIntent", - data: { - correlationId: `pending-${args.idempotencyKey}`, - idempotencyKey: args.idempotencyKey, - intentType: args.intentType, - agentId: args.agentId ?? null, - grantRowId: args.grantRowId ?? null, - userId: args.userId, - status: "PENDING_SIGNATURE", - expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), - createdAt: new Date(), - }, - }); - - const generated = await send(); - - try { - await adapter.update({ - model: "ownerIntent", - where: [{ field: "idempotencyKey", operator: "eq", value: args.idempotencyKey }], - update: { - correlationId: generated.correlation_id, - status: generated.status, - expiresAt: new Date(generated.expires_at), - }, - }); - } catch (error) { - console.error("[agentConnect] failed to link the generated intent to the ledger:", error); - throw new APIError("INTERNAL_SERVER_ERROR", { - message: "Agent intent was created but could not be recorded — retry to reuse it", - status: 500, - }); - } - - return generated; - }; - - const updateIntentStatus = async ( - adapter: DBAdapter, - correlationId: string, - status: string, - ): Promise => { - try { - await adapter.update({ - model: "ownerIntent", - where: [{ field: "correlationId", operator: "eq", value: correlationId }], - update: { status }, - }); - } catch (error) { - console.error("[agentConnect] failed to update owner intent status:", error); - } - }; - - return { - id: "agentConnect", - schema: agentSchema, - endpoints: { - createAgent: createAuthEndpoint( - "/agent/create", - { - method: "POST", - body: CreateAgentRequest, - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - const { name, policy } = ctx.body; - - const owner = await deriveOwner(ctx.context.adapter, userId); - const idempotencyKey = createIdempotencyKey(); - - let generated: IdempotentResult; - try { - generated = await runIntent( - ctx.context.adapter, - { - idempotencyKey, - intentType: "agent_create", - userId, - }, - () => - api.generateIntent( - { - type: "agent_create", - name, - external_user_id: userId, - owner, - policy, - }, - { idempotencyKey }, - ), - ); - } catch (error) { - mapAgentApiError(error); - } - - try { - await ctx.context.adapter.create({ - model: "agentAccount", - data: { - userId, - agentId: generated.agent_id, - name, - ownerType: owner.type, - ownerAccountId: owner.type === "near" ? owner.account_id : null, - status: "PENDING", - createdAt: new Date(), - }, - }); - } catch (error) { - console.error("[agentConnect] failed to persist agent_create state:", error); - } - - return ctx.json(generated); - }, - ), - - submitAgentIntent: createAuthEndpoint( - "/agent/intents/submit", - { - method: "POST", - body: SubmitAgentIntentRequest, - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - const { type, correlationId, signedData } = ctx.body; - - const intentRow = await requireOwnedIntent(ctx.context.adapter, userId, correlationId); - if (intentRow.intentType !== type) { - throw new APIError("BAD_REQUEST", { - message: "Intent type does not match the issued correlation id", - status: 400, - }); - } - - let status: StatusResponse; - try { - status = await api.submitIntent({ - type, - correlation_id: correlationId, - signed_data: signedData, - }); - } catch (error) { - mapAgentApiError(error); - } - - await updateIntentStatus(ctx.context.adapter, correlationId, status.status); - - if (type === "grant_issue" && status.status === "SUCCESS" && intentRow.grantRowId) { - const details = status.details as { grant?: { grant_id: string } | null } | undefined; - const issuedGrantId = details?.grant?.grant_id; - if (issuedGrantId) { - try { - await ctx.context.adapter.update({ - model: "agentGrant", - where: [{ field: "id", operator: "eq", value: intentRow.grantRowId }], - update: { grantId: issuedGrantId }, - }); - } catch (error) { - console.error("[agentConnect] failed to link grant row:", error); - } - } - } - - if (type === "grant_revoke" && status.status === "SUCCESS" && intentRow.grantRowId) { - try { - await ctx.context.adapter.update({ - model: "agentGrant", - where: [{ field: "id", operator: "eq", value: intentRow.grantRowId }], - update: { revokedAt: new Date() }, - }); - } catch (error) { - console.error("[agentConnect] failed to mark grant revoked:", error); - } - } - - if (type === "agent_create" && status.status === "SUCCESS" && intentRow.agentId) { - try { - await ctx.context.adapter.update({ - model: "agentAccount", - where: [ - { field: "agentId", operator: "eq", value: intentRow.agentId }, - { field: "userId", operator: "eq", value: userId }, - ], - update: { status: "ACTIVE" }, - }); - } catch (error) { - console.error("[agentConnect] failed to activate agent row:", error); - } - } - - return ctx.json(status); - }, - ), - - getAgentStatus: createAuthEndpoint( - "/agent/status", - { - method: "POST", - body: AgentStatusRequest, - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - const { correlationId, waitMs } = ctx.body; - - await requireOwnedIntent(ctx.context.adapter, userId, correlationId); - - let status: StatusResponse; - try { - status = await api.getStatus(correlationId, { waitMs }); - } catch (error) { - mapAgentApiError(error); - } - - await updateIntentStatus(ctx.context.adapter, correlationId, status.status); - - return ctx.json(status); - }, - ), - - listAgents: createAuthEndpoint( - "/agent/list", - { - method: "GET", - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - - try { - const page = await api.listAgents({ external_user_id: userId }); - return ctx.json({ - data: page.data, - nextCursor: page.next_cursor, - }); - } catch (error) { - mapAgentApiError(error); - } - }, - ), - - issueAgentGrant: createAuthEndpoint( - "/agent/grants/issue", - { - method: "POST", - body: IssueAgentGrantRequest, - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - const { agentId, label, expiresAt } = ctx.body; - - await requireOwnedAgent(ctx.context.adapter, userId, agentId); - - const requestedExpiry = expiresAt ? new Date(expiresAt).getTime() : Number.NaN; - if (expiresAt && Number.isNaN(requestedExpiry)) { - throw new APIError("BAD_REQUEST", { - message: "expiresAt must be an ISO datetime", - status: 400, - }); - } - const maxExpiry = Date.now() + MAX_GRANT_TTL_MS; - const resolvedExpiryMs = Number.isNaN(requestedExpiry) - ? Date.now() + DEFAULT_GRANT_TTL_MS - : Math.min(requestedExpiry, maxExpiry); - - const { token, commitment } = createGrantCredential(); - const encrypted = await encryptGrantToken(token, ctx.context.secret); - - const grantRow = (await ctx.context.adapter.create({ - model: "agentGrant", - data: { - userId, - agentId, - grantId: null, - correlationId: null, - label, - tokenCiphertext: encrypted.encrypted, - tokenIv: encrypted.iv, - commitment, - expiresAt: new Date(resolvedExpiryMs), - revokedAt: null, - createdAt: new Date(), - }, - })) as AgentGrantRow | null; - - const idempotencyKey = createIdempotencyKey(); - let generated: IdempotentResult; - try { - generated = await runIntent( - ctx.context.adapter, - { - idempotencyKey, - intentType: "grant_issue", - agentId, - grantRowId: grantRow?.id ?? null, - userId, - }, - () => - api.generateIntent( - { - type: "grant_issue", - agent_id: agentId, - label, - credential: commitment, - expires_at: new Date(resolvedExpiryMs).toISOString(), - }, - { idempotencyKey }, - ), - ); - } catch (error) { - mapAgentApiError(error); - } - - return ctx.json(generated); - }, - ), - - revokeAgentGrant: createAuthEndpoint( - "/agent/grants/revoke", - { - method: "POST", - body: RevokeAgentGrantRequest, - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - const { agentId, grantId } = ctx.body; - - await requireOwnedAgent(ctx.context.adapter, userId, agentId); - - const grantRow = (await ctx.context.adapter.findOne({ - model: "agentGrant", - where: [ - { field: "agentId", operator: "eq", value: agentId }, - { field: "grantId", operator: "eq", value: grantId }, - { field: "userId", operator: "eq", value: userId }, - ], - })) as AgentGrantRow | null; - if (!grantRow) { - throw new APIError("NOT_FOUND", { - message: "Grant not found for this user", - status: 404, - }); - } - - const idempotencyKey = createIdempotencyKey(); - let generated: IdempotentResult; - try { - generated = await runIntent( - ctx.context.adapter, - { - idempotencyKey, - intentType: "grant_revoke", - agentId, - grantRowId: grantRow.id, - userId, - }, - () => - api.generateIntent( - { - type: "grant_revoke", - agent_id: agentId, - grant_id: grantId, - }, - { idempotencyKey }, - ), - ); - } catch (error) { - mapAgentApiError(error); - } - - return ctx.json(generated); - }, - ), - - listAgentGrants: createAuthEndpoint( - "/agent/grants", - { - method: "POST", - body: ListAgentGrantsRequest, - use: [sessionMiddleware], - }, - async (ctx) => { - const session = ctx.context.session; - const userId = requireUser(session); - const { agentId } = ctx.body; - - await requireOwnedAgent(ctx.context.adapter, userId, agentId); - - try { - const grants = await api.listGrants(agentId); - return ctx.json({ data: grants }); - } catch (error) { - mapAgentApiError(error); - } - }, - ), - }, - } satisfies BetterAuthPlugin; -}; - -export { agentSchema, decryptGrantToken, encryptGrantToken }; diff --git a/packages/better-near-auth/src/agent-schema.ts b/packages/better-near-auth/src/agent-schema.ts deleted file mode 100644 index a66ae285c..000000000 --- a/packages/better-near-auth/src/agent-schema.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { BetterAuthPluginDBSchema } from "better-auth/db"; - -export const agentSchema = { - agentAccount: { - fields: { - userId: { - type: "string", - references: { - model: "user", - field: "id", - }, - required: true, - }, - agentId: { - type: "string", - required: true, - }, - name: { - type: "string", - required: true, - }, - ownerType: { - type: "string", - required: true, - }, - ownerAccountId: { - type: "string", - required: false, - }, - status: { - type: "string", - required: true, - }, - createdAt: { - type: "date", - required: true, - }, - }, - }, - ownerIntent: { - fields: { - correlationId: { - type: "string", - required: true, - }, - idempotencyKey: { - type: "string", - required: true, - }, - intentType: { - type: "string", - required: true, - }, - agentId: { - type: "string", - required: false, - }, - grantRowId: { - type: "string", - required: false, - }, - userId: { - type: "string", - references: { - model: "user", - field: "id", - }, - required: true, - }, - status: { - type: "string", - required: true, - }, - expiresAt: { - type: "date", - required: true, - }, - createdAt: { - type: "date", - required: true, - }, - }, - }, - agentGrant: { - fields: { - userId: { - type: "string", - references: { - model: "user", - field: "id", - }, - required: true, - }, - agentId: { - type: "string", - required: true, - }, - grantId: { - type: "string", - required: false, - }, - correlationId: { - type: "string", - required: false, - }, - label: { - type: "string", - required: true, - }, - tokenCiphertext: { - type: "string", - required: true, - }, - tokenIv: { - type: "string", - required: true, - }, - commitment: { - type: "string", - required: true, - }, - expiresAt: { - type: "date", - required: true, - }, - revokedAt: { - type: "date", - required: false, - }, - createdAt: { - type: "date", - required: true, - }, - }, - }, -} satisfies BetterAuthPluginDBSchema; diff --git a/packages/better-near-auth/src/client.ts b/packages/better-near-auth/src/client.ts index 735c4a662..e03591d4d 100644 --- a/packages/better-near-auth/src/client.ts +++ b/packages/better-near-auth/src/client.ts @@ -164,7 +164,7 @@ export interface SIWNClientActions { }; } -export { type AgentClientActions, agentConnectClient } from "./agent-connect-client.js"; +export { signAgentIntent } from "./agent-connect-client.js"; export { DEFAULT_DEVICE_LINK_CLIENT_ID } from "./constants.js"; export { isPasskeyWalletAvailable, type PasskeyWalletNetwork } from "./passkey.js"; diff --git a/packages/better-near-auth/src/index.ts b/packages/better-near-auth/src/index.ts index 465a43cfb..a88dfe6c2 100644 --- a/packages/better-near-auth/src/index.ts +++ b/packages/better-near-auth/src/index.ts @@ -85,37 +85,6 @@ import { ViewContractResponse, } from "./types.js"; -export type { - AgentApiOptions, - AgentView, - GenerateIntentResponse, - GenerateIntentResponseOf, - GrantView, - Intent, - IntentPreview, - IntentType, - OwnerWallet, - Policy, - PolicyView, - SignedData, - StatusResponse, - StatusResponseOf, -} from "./agent-api.js"; -export { - type AgentAccountRow, - type AgentConnectOptions, - type AgentConnectPasskeyOwner, - type AgentGrantRow, - type AgentStatusRequestT, - agentConnect, - agentSchema, - type CreateAgentRequestT, - type IssueAgentGrantRequestT, - type ListAgentGrantsRequestT, - type OwnerIntentRow, - type RevokeAgentGrantRequestT, - type SubmitAgentIntentRequestT, -} from "./agent-connect.js"; export { getPasskeyWalletFactory, isDeterministicAccountId, diff --git a/packages/everything-dev/src/ui/auth.ts b/packages/everything-dev/src/ui/auth.ts index 808d07bb8..2a3e0910f 100644 --- a/packages/everything-dev/src/ui/auth.ts +++ b/packages/everything-dev/src/ui/auth.ts @@ -12,11 +12,7 @@ import { } from "better-auth/client/plugins"; import { createAuthClient as createBetterAuthClient } from "better-auth/react"; import type { PasskeyWalletLink, RelayedTransactionT } from "better-near-auth"; -import { - agentConnectClient, - DEFAULT_DEVICE_LINK_CLIENT_ID, - siwnClient, -} from "better-near-auth/client"; +import { DEFAULT_DEVICE_LINK_CLIENT_ID, siwnClient } from "better-near-auth/client"; import type { ClientRuntimeConfig } from "../types"; import { getRuntimeConfig } from "./runtime"; @@ -123,7 +119,6 @@ export function createAuthClient(options: CreateAuthClientOptions = {}) { }, }), siwnClient(nearAuthConfig), - agentConnectClient(), adminClient(), anonymousClient(), phoneNumberClient(), diff --git a/plugins/agent-connect/CHANGELOG.md b/plugins/agent-connect/CHANGELOG.md new file mode 100644 index 000000000..60b1dc240 --- /dev/null +++ b/plugins/agent-connect/CHANGELOG.md @@ -0,0 +1,7 @@ +# @everything-dev/agent-connect-plugin + +## 0.1.0-rc.0 + +### Minor Changes + +- Initial release: agent-account control plane over the hosted NEAR Intents Agent API — session-scoped agent accounts, BFF-split owner intents (prepare → wallet-sign → submit), sealed grant-token vault, own plugin schema. diff --git a/plugins/agent-connect/README.md b/plugins/agent-connect/README.md new file mode 100644 index 000000000..ee7e40011 --- /dev/null +++ b/plugins/agent-connect/README.md @@ -0,0 +1,50 @@ +# Agent Connect + +Control plane giving every signed-in user an owner-controlled agent account on the +[NEAR Intents Agent API](https://api.agentsonintents.com) — the owner signs the rules +once with their wallet, and agents hold revocable `ngt_` grant tokens that only act +inside the owner-signed policy. + +## Layering + +- **`@near-intents-agent-api/sdk`** (catalog-pinned) is the only protocol dependency. + Every domain type is re-exported from it, never re-declared. The hosted API is the + source of truth for all shapes (`/openapi.json`). +- **This plugin** adds only platform concerns: session ownership (auth context), + the sealed grant-token vault, the pre-send intent ledger, and the oRPC surface + (REST + MCP via the host's OpenAPI mounts). +- **`better-near-auth`** owns the wallet side: `signAgentIntent` (client surface) + signs a generated `intent.payload` with the connected wallet — nep413, nep366, + or webauthn — and the browser submits the wallet's output back here. + +Flow (the BFF split): `agentCreate` / `prepareIntent` return `intent.payload` + +`preview` only → the browser signs → `intentSubmit` submits the wallet's output. +The `naa_` partner key and `ngt_` grant tokens never reach a browser. + +## Invariants (enforced in code and tests) + +1. Every route requires a session; every agent/grant/correlation id is checked + against the calling user's rows before any host call. +2. Grant tokens are sealed at rest (HKDF-SHA256 → AES-256-GCM) and never appear in + a response — only their sha256 commitment enters the owner-signed intent. +3. Intents are persisted **before** the send (status `PENDING_SIGNATURE`, 15-minute + pre-send TTL) and linked to the real correlation id after; a failed link asks the + caller to retry, which reuses the same idempotency key. +4. Status updates from submit/poll are best-effort; ledger link failures are fatal. + +## Configuration + +- Secret `NEAR_INTENTS_AGENT_API_KEY` (`naa_…`, partner dashboard) — the plugin is + dormant without it (routes fail with a clear "not configured" error). +- Secret `AGENT_CONNECT_ENCRYPTION_KEY` (32+ bytes) — grant vault key; falls back to + a key derived from the API key when absent (set a dedicated key in production). +- Secret `AGENT_CONNECT_DATABASE_URL` — own `plugin_agent_connect` schema; `pglite:` + default for local dev. +- Variable `apiBaseUrl` — hosted API origin (non-mainnet deployments point elsewhere). + +## Upstream-first + +Needs that require protocol changes go upstream (NEAR-Intents-Agent-API: `sdk-typescript`, +`skills`; private: `api`, `agent-connect`) — never forked locally. The vendored skill +(`.agents/skills/near-intents-agent-api`) is the rules source and drift-tracked via +`skills-lock.json`. diff --git a/plugins/agent-connect/drizzle.config.ts b/plugins/agent-connect/drizzle.config.ts new file mode 100644 index 000000000..7c13ac9d5 --- /dev/null +++ b/plugins/agent-connect/drizzle.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "drizzle-kit"; +import { resolveDatabaseUrl, workspaceIdentityFromModuleUrl } from "everything-dev/db"; + +const identity = workspaceIdentityFromModuleUrl(import.meta.url); + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./src/db/migrations", + dialect: "postgresql", + dbCredentials: { + url: resolveDatabaseUrl(identity), + }, + migrations: { + schema: identity.journal.schema, + table: identity.journal.table, + }, + verbose: true, + strict: true, +}); diff --git a/plugins/agent-connect/package.json b/plugins/agent-connect/package.json new file mode 100644 index 000000000..1f263a6e7 --- /dev/null +++ b/plugins/agent-connect/package.json @@ -0,0 +1,56 @@ +{ + "name": "@everything-dev/agent-connect-plugin", + "version": "0.1.0-rc.0", + "description": "Agent Connect control plane — agent accounts on the NEAR Intents Agent API for every signed-in user", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./contract": { + "types": "./src/contract.ts", + "default": "./src/contract.ts" + } + }, + "peerDependencies": { + "typescript": "catalog:" + }, + "dependencies": { + "@electric-sql/pglite": "catalog:", + "@near-intents-agent-api/sdk": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi": "catalog:", + "@orpc/server": "catalog:", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "pg": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@module-federation/node": "catalog:", + "@module-federation/runtime-tools": "catalog:", + "@rspack/cli": "catalog:", + "@rspack/core": "catalog:", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "@proj-airi/unplugin-drizzle-orm-migrations": "^0.1.4", + "dotenv": "^17.2.3", + "drizzle-kit": "catalog:", + "everything-dev": "workspace:^", + "every-plugin": "workspace:^", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "catalog:" + }, + "scripts": { + "build": "every-plugin build", + "dev": "every-plugin dev", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "typecheck": "tsc --noEmit" + } +} diff --git a/plugins/agent-connect/plugin.dev.ts b/plugins/agent-connect/plugin.dev.ts new file mode 100644 index 000000000..de03f50f3 --- /dev/null +++ b/plugins/agent-connect/plugin.dev.ts @@ -0,0 +1,21 @@ +import "dotenv/config"; +import type { PluginConfigInput } from "every-plugin"; +import type Plugin from "./src/index"; + +export default { + port: Number(process.env.PORT) || 3016, + config: { + variables: { + apiBaseUrl: process.env.AGENT_CONNECT_API_BASE_URL || undefined, + apiTimeoutMs: process.env.AGENT_CONNECT_API_TIMEOUT_MS + ? Number(process.env.AGENT_CONNECT_API_TIMEOUT_MS) + : undefined, + }, + secrets: { + NEAR_INTENTS_AGENT_API_KEY: process.env.NEAR_INTENTS_AGENT_API_KEY || `naa_${"a".repeat(43)}`, + AGENT_CONNECT_ENCRYPTION_KEY: process.env.AGENT_CONNECT_ENCRYPTION_KEY, + AGENT_CONNECT_DATABASE_URL: + process.env.AGENT_CONNECT_DATABASE_URL || "pglite:.bos/agent-connect/:memory:", + }, + } satisfies PluginConfigInput, +}; diff --git a/plugins/agent-connect/src/contract.ts b/plugins/agent-connect/src/contract.ts new file mode 100644 index 000000000..547c36c48 --- /dev/null +++ b/plugins/agent-connect/src/contract.ts @@ -0,0 +1,291 @@ +import "@orpc/openapi/extensions/route"; +import { oc } from "@orpc/contract"; +import { z } from "zod"; + +/** + * Agent Connect control-plane contract. + * + * Output envelopes are deliberately loose (`z.looseObject`): the hosted Agent + * API (via `@near-intents-agent-api/sdk`) is the source of truth for every + * domain shape — see `.agents/skills/near-intents-agent-api` and the hosted + * `/openapi.json`. Re-declaring deep shapes here would drift. + */ + +const Errors = { + UNAUTHORIZED: { + message: "Authentication required", + }, + FORBIDDEN: { + message: "Operation not permitted", + }, + NOT_FOUND: { + message: "Agent not found for this user", + }, + CONFLICT: { + message: "Conflict with the Agent API state", + }, + BAD_REQUEST: { + message: "Bad request", + }, + TOO_MANY_REQUESTS: { + message: "Agent API rate limit reached", + }, + BAD_GATEWAY: { + message: "Agent API returned an upstream failure", + }, + SERVICE_UNAVAILABLE: { + message: "Agent API is unreachable — retry with the same request", + }, +}; + +const PolicySchema = z + .record(z.string(), z.unknown()) + .describe("Owner-signed spending policy — the hosted Agent API validates its shape"); + +const SignedDataSchema = z + .record(z.string(), z.unknown()) + .describe("The wallet's signature output over intent.payload, submitted unchanged"); + +/** + * The browser-visible intent envelope: `intent.payload` + `preview` are all a + * browser ever sees — never the `naa_` partner key or an `ngt_` grant token. + */ +const GeneratedIntentSchema = z.looseObject({ + correlation_id: z.string().describe("Tracking id of the generated owner intent"), + type: z.string().describe("Intent type, e.g. agent_create, grant_issue, grant_revoke"), + status: z.string().describe("Intent status, e.g. PENDING_SIGNATURE"), + expires_at: z.string().describe("ISO datetime after which the intent can no longer be signed"), + intent: z.looseObject({ + standard: z.string().describe("Signing standard: nep413 | nep366 | eip712 | webauthn"), + payload: z.record(z.string(), z.unknown()).describe("The exact bytes the owner's wallet signs"), + }), + preview: z + .unknown() + .optional() + .describe("Human-readable summary shown to the owner before signing"), +}); + +const StatusSchema = z.looseObject({ + correlation_id: z.string().describe("Tracking id of the intent"), + status: z + .string() + .describe("SUCCESS | FAILED | PENDING_SIGNATURE | UNCERTAIN | NEEDS_REVIEW | …"), + type: z.string().optional().describe("Intent type"), + details: z.unknown().optional().describe("Intent-type-specific result details"), +}); + +const AgentPageSchema = z.looseObject({ + data: z.array(z.unknown()).describe("Agent views"), + next_cursor: z.string().nullable().optional().describe("Cursor for the next page"), +}); + +const AgentViewSchema = z.looseObject({ + id: z.string().describe("Agent account id"), +}); + +const GrantsSchema = z.object({ + data: z.array(z.unknown()).describe("Grant views for the agent account"), +}); + +const PrepareIntentInputSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("agent_create"), + name: z.string().min(1).max(64).describe("Display name for the new agent account"), + policy: PolicySchema, + }), + z.object({ + type: z.literal("grant_issue"), + agentId: z.string().min(1).describe("Agent account to issue the grant on"), + label: z.string().min(1).max(100).describe("Label for the grant, e.g. the session it serves"), + expiresAt: z + .string() + .datetime() + .optional() + .describe("Grant expiry; default 7 days, max 365 days"), + }), + z.object({ + type: z.literal("grant_revoke"), + agentId: z.string().min(1).describe("Agent account holding the grant"), + grantId: z.string().min(1).describe("Grant to revoke"), + }), +]); + +export const contract = oc.router({ + agentCreate: oc + .route({ + method: "POST", + path: "/agents", + summary: "Prepare agent account creation", + description: + "Generates the owner-signed agent_create intent. The browser signs intent.payload and submits it via intentSubmit. Requires authentication.", + tags: ["Agents"], + }) + .input( + z.object({ + name: z.string().min(1).max(64).describe("Display name for the new agent account"), + policy: PolicySchema, + }), + ) + .output(GeneratedIntentSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + agentList: oc + .route({ + method: "GET", + path: "/agents", + summary: "List the user's agent accounts", + description: "Lists agent accounts registered on the hosted Agent API for this user.", + tags: ["Agents"], + }) + .output(AgentPageSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + agentGet: oc + .route({ + method: "GET", + path: "/agents/{agentId}", + summary: "Get an agent account view", + description: "Fetches one agent account; refused unless the agent belongs to the caller.", + tags: ["Agents"], + }) + .input( + z.object({ + agentId: z.string().min(1).describe("Agent account id"), + }), + ) + .output(AgentViewSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + NOT_FOUND: Errors.NOT_FOUND, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + prepareIntent: oc + .route({ + method: "POST", + path: "/agents/intents/prepare", + summary: "Prepare an owner intent for signing", + description: + "Generates a grant_issue or grant_revoke owner intent for the caller to sign. Grant tokens are created server-side, sealed at rest, and never appear in the response — only their commitment enters the intent.", + tags: ["Intents"], + }) + .input(PrepareIntentInputSchema) + .output(GeneratedIntentSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + NOT_FOUND: Errors.NOT_FOUND, + BAD_REQUEST: Errors.BAD_REQUEST, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + intentSubmit: oc + .route({ + method: "POST", + path: "/agents/intents/submit", + summary: "Submit a signed owner intent", + description: + "Submits the wallet's signature output for a previously prepared intent. The correlation id must have been issued to the calling user, and the intent type must match.", + tags: ["Intents"], + }) + .input( + z.object({ + type: z + .enum(["agent_create", "grant_issue", "grant_revoke"]) + .describe("Must match the type of the issued intent"), + correlationId: z.string().min(1).describe("The correlation id returned at prepare time"), + signedData: SignedDataSchema, + }), + ) + .output(StatusSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + NOT_FOUND: Errors.NOT_FOUND, + BAD_REQUEST: Errors.BAD_REQUEST, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + intentStatus: oc + .route({ + method: "POST", + path: "/agents/intents/status", + summary: "Read the status of an issued intent", + description: + "Polls the hosted Agent API for an intent's status; the correlation id must have been issued to the calling user.", + tags: ["Intents"], + }) + .input( + z.object({ + correlationId: z.string().min(1).describe("The correlation id returned at prepare time"), + waitMs: z + .number() + .int() + .min(0) + .max(30_000) + .optional() + .describe("Long-poll up to this many milliseconds for a terminal status"), + }), + ) + .output(StatusSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + NOT_FOUND: Errors.NOT_FOUND, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), + + grantList: oc + .route({ + method: "GET", + path: "/agents/{agentId}/grants", + summary: "List an agent account's grants", + description: + "Lists grants from the hosted Agent API; refused unless the agent belongs to the caller.", + tags: ["Grants"], + }) + .input( + z.object({ + agentId: z.string().min(1).describe("Agent account id"), + }), + ) + .output(GrantsSchema) + .errors({ + UNAUTHORIZED: Errors.UNAUTHORIZED, + BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, + TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, + NOT_FOUND: Errors.NOT_FOUND, + SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, + BAD_GATEWAY: Errors.BAD_GATEWAY, + INTERNAL_SERVER_ERROR: { message: "Agent API call failed" }, + }), +}); + +export type ContractType = typeof contract; diff --git a/plugins/agent-connect/src/db/index.ts b/plugins/agent-connect/src/db/index.ts new file mode 100644 index 000000000..8e9cc360c --- /dev/null +++ b/plugins/agent-connect/src/db/index.ts @@ -0,0 +1,26 @@ +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { createDatabaseDriver as createSharedDriver } from "everything-dev/db"; +import * as schema from "./schema"; + +export type Database = PgDatabase; + +export { DatabaseError, unwrapDatabaseError } from "everything-dev/db"; + +export interface DatabaseDriver { + readonly db: Database; + close(): Promise; +} + +/** + * Thin adapter over the shared `createDatabaseDriver` (`everything-dev/db`). + * + * Engine selection (`pglite:` / `:memory:` → PGlite, else postgres), + * protocol-level `search_path`, one-time schema creation, env-driven pool + * config, and the idempotent close handler all live in the shared driver. + */ +export async function createDatabaseDriver( + url: string, + schemaName?: string, +): Promise { + return createSharedDriver(url, schema, schemaName); +} diff --git a/plugins/agent-connect/src/db/layer.ts b/plugins/agent-connect/src/db/layer.ts new file mode 100644 index 000000000..90feddee3 --- /dev/null +++ b/plugins/agent-connect/src/db/layer.ts @@ -0,0 +1,87 @@ +import { Context, Effect, Layer } from "effect"; +import { PluginIdTag } from "every-plugin"; +import { getMigrationStorage, pluginMigrationSlug } from "everything-dev/db"; +import { createDatabaseDriver, type Database, DatabaseError } from "./index"; +import { detectDrift, loadMigrations, migrate } from "./migrate"; + +export class DatabaseTag extends Context.Service()("Database") {} + +export const DatabaseLive = (url: string) => + Layer.effect( + DatabaseTag, + Effect.gen(function* () { + const pluginId = yield* PluginIdTag; + const slug = pluginMigrationSlug(pluginId); + const schemaName = `plugin_${slug}`; + + const driver = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => createDatabaseDriver(url, schemaName), + catch: (cause) => new DatabaseError({ stage: "driver", cause }), + }), + (driver) => + Effect.tryPromise({ + try: () => driver.close(), + catch: (cause) => new DatabaseError({ stage: "close", cause }), + }).pipe(Effect.ignore), + ); + + const storage = getMigrationStorage(slug); + const { migrations, source } = yield* loadMigrations; + + if (migrations.length === 0) { + yield* Effect.logWarning( + `[Database] No migrations found (source: ${source}) — schema may be missing`, + ); + } else { + const applied = yield* migrate(driver.db, migrations, storage, schemaName); + + if (applied === 0) { + yield* Effect.logInfo( + `[Database] Schema up to date (0 migrations needed, source: ${source})`, + ); + } else { + yield* Effect.logInfo( + `[Database] Applied ${applied}/${migrations.length} migration(s) (source: ${source}, journal: ${storage.schema}.${storage.table}, schema: ${schemaName})`, + ); + } + + const drift = yield* detectDrift(driver.db, migrations, storage, schemaName); + if (drift.status === "healthy" || drift.status === "untracked-existing-schema") { + yield* Effect.logInfo(`[Database] Ready`); + } else if (drift.status === "drift-safe-repair") { + yield* Effect.logWarning( + `[Database] ⚠️ Migration drift detected: ${drift.missingTables.length} expected table(s) missing: ${drift.missingTables.join(", ")}`, + ); + yield* Effect.logWarning( + `[Database] Run \`bos db doctor ${storage.slug}\` to diagnose and \`bos db repair ${storage.slug}\` to fix.`, + ); + return yield* new DatabaseError({ + stage: "migration", + migrationTag: "drift-safe-repair", + cause: new Error( + `Migration journal has ${drift.appliedHashes} applied hashes but all ${drift.expectedTables.length} expected table(s) are missing. ` + + `Run \`bos db repair ${storage.slug}\` to reset the migration history and reapply migrations. ` + + `Missing tables: ${drift.missingTables.join(", ")}`, + ), + }); + } + if (drift.status === "drift-manual") { + yield* Effect.logWarning( + `[Database] ⚠️ Partial migration drift detected: ${drift.missingTables.length}/${drift.expectedTables.length} expected table(s) missing.`, + ); + return yield* new DatabaseError({ + stage: "migration", + migrationTag: "drift-manual", + cause: new Error( + `Partial schema drift — ${drift.missingTables.length}/${drift.expectedTables.length} expected table(s) are missing. ` + + `Run \`bos db doctor ${storage.slug}\` for details. Manual intervention required. ` + + `Missing tables: ${drift.missingTables.join(", ")}`, + ), + }); + } + } + + return driver.db; + }), + ); diff --git a/plugins/agent-connect/src/db/migrate.ts b/plugins/agent-connect/src/db/migrate.ts new file mode 100644 index 000000000..48a5a7355 --- /dev/null +++ b/plugins/agent-connect/src/db/migrate.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import { + type DatabaseError, + type LoadedMigrations, + loadMigrations as loadSharedMigrations, + type Migration, + type MigrationDatabase, + type MigrationReport, + type MigrationStorage, + runMigrations, +} from "everything-dev/db"; + +export type { DriftReport, LoadedMigrations } from "everything-dev/db"; +export { detectDrift } from "everything-dev/db"; + +export const loadMigrations: Effect.Effect = loadSharedMigrations({ + fromDir: import.meta.dirname, + virtual: () => import("virtual:drizzle-migrations.sql"), +}); + +/** + * Thin adapter over the shared `runMigrations` runner (`everything-dev/db`). + * + * This file is sync-owned: `bos sync` copies it verbatim into every plugin's + * `src/db/migrate.ts`, so it must stay workspace-agnostic — the drizzle + * migrations arrive via the bundler's `virtual:drizzle-migrations.sql` module + * (declared in each workspace's `src/global.d.ts`) with a disk fallback. + * Duplicate-DDL tolerance, savepoint handling, and the retryable-SQLSTATE + * journal-init backoff all live in the shared runner now. + */ +export function migrate( + db: MigrationDatabase, + migrations: Migration[], + storage?: MigrationStorage, + schemaName?: string, +): Effect.Effect { + return Effect.map( + runMigrations(db, migrations, { journal: storage, schemaName }), + (report) => report.applied, + ); +} + +export type { Migration, MigrationReport }; diff --git a/plugins/auth/src/db/migrations/0015_agent-connect.sql b/plugins/agent-connect/src/db/migrations/0000_init.sql similarity index 76% rename from plugins/auth/src/db/migrations/0015_agent-connect.sql rename to plugins/agent-connect/src/db/migrations/0000_init.sql index 27878ed6e..2812ae01d 100644 --- a/plugins/auth/src/db/migrations/0015_agent-connect.sql +++ b/plugins/agent-connect/src/db/migrations/0000_init.sql @@ -37,13 +37,11 @@ CREATE TABLE "owner_intent" ( "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -ALTER TABLE "agent_account" ADD CONSTRAINT "agent_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "agent_grant" ADD CONSTRAINT "agent_grant_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "owner_intent" ADD CONSTRAINT "owner_intent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "agentAccount_agentId_uidx" ON "agent_account" USING btree ("agent_id");--> statement-breakpoint CREATE INDEX "agentAccount_userId_idx" ON "agent_account" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "agentGrant_agentId_idx" ON "agent_grant" USING btree ("agent_id");--> statement-breakpoint CREATE INDEX "agentGrant_userId_idx" ON "agent_grant" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "agentGrant_commitment_idx" ON "agent_grant" USING btree ("commitment");--> statement-breakpoint CREATE UNIQUE INDEX "ownerIntent_correlationId_uidx" ON "owner_intent" USING btree ("correlation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "ownerIntent_idempotencyKey_uidx" ON "owner_intent" USING btree ("idempotency_key");--> statement-breakpoint CREATE INDEX "ownerIntent_userId_idx" ON "owner_intent" USING btree ("user_id"); \ No newline at end of file diff --git a/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..897a960dc --- /dev/null +++ b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,364 @@ +{ + "id": "a8f935cf-004e-43a6-829a-8becabf9b766", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_account": { + "name": "agent_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentAccount_agentId_uidx": { + "name": "agentAccount_agentId_uidx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentAccount_userId_idx": { + "name": "agentAccount_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_grant": { + "name": "agent_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commitment": { + "name": "commitment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentGrant_agentId_idx": { + "name": "agentGrant_agentId_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_userId_idx": { + "name": "agentGrant_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentGrant_commitment_idx": { + "name": "agentGrant_commitment_idx", + "columns": [ + { + "expression": "commitment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_intent": { + "name": "owner_intent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent_type": { + "name": "intent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_row_id": { + "name": "grant_row_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ownerIntent_correlationId_uidx": { + "name": "ownerIntent_correlationId_uidx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownerIntent_idempotencyKey_uidx": { + "name": "ownerIntent_idempotencyKey_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownerIntent_userId_idx": { + "name": "ownerIntent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/plugins/agent-connect/src/db/migrations/meta/_journal.json b/plugins/agent-connect/src/db/migrations/meta/_journal.json new file mode 100644 index 000000000..57574d967 --- /dev/null +++ b/plugins/agent-connect/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1792178485013, + "tag": "0000_init", + "breakpoints": true + } + ] +} diff --git a/plugins/agent-connect/src/db/schema.ts b/plugins/agent-connect/src/db/schema.ts new file mode 100644 index 000000000..924691e8a --- /dev/null +++ b/plugins/agent-connect/src/db/schema.ts @@ -0,0 +1,71 @@ +import { index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; + +/** + * Agent Connect control-plane tables (own plugin schema, `plugin_agent_connect`). + * + * `user_id` is the Better Auth user id resolved from the request context + * server-side — never trusted from client input; no FK (the user table lives + * in the auth plugin's database). + */ + +export const agentAccount = pgTable( + "agent_account", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + agentId: text("agent_id").notNull(), + name: text("name").notNull(), + ownerType: text("owner_type").notNull(), + ownerAccountId: text("owner_account_id"), + status: text("status").notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("agentAccount_agentId_uidx").on(table.agentId), + index("agentAccount_userId_idx").on(table.userId), + ], +); + +export const ownerIntent = pgTable( + "owner_intent", + { + id: text("id").primaryKey(), + correlationId: text("correlation_id").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + intentType: text("intent_type").notNull(), + agentId: text("agent_id"), + grantRowId: text("grant_row_id"), + userId: text("user_id").notNull(), + status: text("status").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("ownerIntent_correlationId_uidx").on(table.correlationId), + uniqueIndex("ownerIntent_idempotencyKey_uidx").on(table.idempotencyKey), + index("ownerIntent_userId_idx").on(table.userId), + ], +); + +export const agentGrant = pgTable( + "agent_grant", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + agentId: text("agent_id").notNull(), + grantId: text("grant_id"), + correlationId: text("correlation_id"), + label: text("label").notNull(), + tokenCiphertext: text("token_ciphertext").notNull(), + tokenIv: text("token_iv").notNull(), + commitment: text("commitment").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { mode: "date", withTimezone: true }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("agentGrant_agentId_idx").on(table.agentId), + index("agentGrant_userId_idx").on(table.userId), + index("agentGrant_commitment_idx").on(table.commitment), + ], +); diff --git a/plugins/agent-connect/src/global.d.ts b/plugins/agent-connect/src/global.d.ts new file mode 100644 index 000000000..aa4d71ce6 --- /dev/null +++ b/plugins/agent-connect/src/global.d.ts @@ -0,0 +1,11 @@ +declare module "virtual:drizzle-migrations.sql" { + export interface Migration { + idx: number; + when: number; + tag: string; + hash: string; + sql: string[]; + } + const migrations: Migration[]; + export default migrations; +} diff --git a/plugins/agent-connect/src/index.ts b/plugins/agent-connect/src/index.ts new file mode 100644 index 000000000..420e6a31f --- /dev/null +++ b/plugins/agent-connect/src/index.ts @@ -0,0 +1,529 @@ +import { + createGrantCredential, + createIdempotencyKey, + DEFAULT_BASE_URL, +} from "@near-intents-agent-api/sdk"; +import { ORPCError } from "@orpc/server"; +import { Effect, Layer } from "effect"; +import { createPlugin } from "every-plugin"; +import { z } from "zod"; +import { contract } from "./contract"; +import { DatabaseLive } from "./db/layer"; +import { ContextSchema } from "./lib/context"; +import { + AgentApiClient, + type AgentApiServiceError, + agentApiClientLayer, +} from "./services/agent-api"; +import { AgentStore, agentStoreLayer } from "./services/store"; +import { GrantVault, grantVaultLayer } from "./services/vault"; + +/** + * Agent Connect — the control plane giving every signed-in user an + * owner-controlled agent account on the NEAR Intents Agent API. + * + * Layering: `@near-intents-agent-api/sdk` is the only protocol dependency; + * this plugin adds only platform concerns — session ownership, the sealed + * grant-token vault, the pre-send intent ledger, and the oRPC surface + * (REST + MCP via the host's OpenAPI mounts). + * + * Owner intents follow the BFF split: prepare (this plugin, returns + * `intent.payload` + preview) → the browser signs with the connected wallet + * (better-near-auth's `agentConnectClient`) → submit (this plugin). The + * `naa_` partner key and `ngt_` grant tokens never reach a browser. + * + * Owner derivation reads the request context (`context.near`) — the primary + * linked NEAR account. Passkey-only owners are a tracked follow-up. + * + * The plugin is dormant until `NEAR_INTENTS_AGENT_API_KEY` is configured: + * routes exist but fail with a clear "not configured" error. + */ + +const PRE_SEND_TTL_MS = 15 * 60 * 1000; +const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_GRANT_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +const NOT_CONFIGURED: AgentApiServiceError = new ORPCError("SERVICE_UNAVAILABLE", { + message: "Agent Connect is not configured — set NEAR_INTENTS_AGENT_API_KEY", + data: { message: "Agent Connect is not configured — set NEAR_INTENTS_AGENT_API_KEY" }, +}); + +const dormantClientLayer = Layer.succeed(AgentApiClient, { + generateIntent: () => Effect.fail(NOT_CONFIGURED), + submitIntent: () => Effect.fail(NOT_CONFIGURED), + getStatus: () => Effect.fail(NOT_CONFIGURED), + listAgents: () => Effect.fail(NOT_CONFIGURED), + getAgent: () => Effect.fail(NOT_CONFIGURED), + listGrants: () => Effect.fail(NOT_CONFIGURED), +}); + +const newId = (): string => crypto.randomUUID(); + +export default createPlugin({ + variables: z.object({ + apiBaseUrl: z.string().default(DEFAULT_BASE_URL).describe("Hosted Agent API origin"), + apiTimeoutMs: z.number().min(1000).max(60_000).optional().describe("Agent API request timeout"), + }), + + secrets: z.object({ + NEAR_INTENTS_AGENT_API_KEY: z + .string() + .optional() + .describe( + "Partner API key (naa_…) from the partner dashboard; the plugin stays dormant without it", + ), + AGENT_CONNECT_ENCRYPTION_KEY: z + .string() + .min(32) + .optional() + .describe( + "Grant-token vault key (32+ bytes). Falls back to a key derived from NEAR_INTENTS_AGENT_API_KEY when absent — set a dedicated key in production.", + ), + AGENT_CONNECT_DATABASE_URL: z + .string() + .default("pglite:.bos/agent-connect/:memory:") + .describe("Database connection string. Use pglite: for local, postgres:// for production."), + }), + + context: ContextSchema, + + contract, + + initialize: (config) => + Effect.gen(function* () { + const apiKey = config.secrets.NEAR_INTENTS_AGENT_API_KEY; + + const vaultSecret = + config.secrets.AGENT_CONNECT_ENCRYPTION_KEY ?? + (apiKey + ? yield* Effect.logWarning( + "[agent-connect] AGENT_CONNECT_ENCRYPTION_KEY not set — deriving the grant vault key from NEAR_INTENTS_AGENT_API_KEY; set a dedicated key in production", + ).pipe(Effect.as(apiKey)) + : "agent-connect-dormant"); + + const clientLayer = apiKey + ? agentApiClientLayer({ + apiKey, + baseUrl: config.variables.apiBaseUrl, + timeoutMs: config.variables.apiTimeoutMs, + }) + : dormantClientLayer; + + return Layer.mergeAll( + clientLayer, + grantVaultLayer(vaultSecret), + agentStoreLayer.pipe( + Layer.provide(DatabaseLive(config.secrets.AGENT_CONNECT_DATABASE_URL)), + ), + ); + }), + + createRouter: (builder) => { + const requireUser = (userId: string | null | undefined): string => { + if (!userId) { + throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" }); + } + return userId; + }; + + const deriveOwner = ( + near: + | { + primaryAccountId: string | null; + linkedAccounts: Array<{ accountId: string; publicKey: string }>; + } + | null + | undefined, + ): { type: "near"; account_id: string; public_key: string } => { + const primary = near?.primaryAccountId + ? near.linkedAccounts.find((account) => account.accountId === near.primaryAccountId) + : undefined; + if (!primary) { + throw new ORPCError("BAD_REQUEST", { + message: "No owner wallet available — link a NEAR account first", + }); + } + return { type: "near", account_id: primary.accountId, public_key: primary.publicKey }; + }; + + return { + agentCreate: builder.agentCreate.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const owner = deriveOwner(context.near); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const idempotencyKey = createIdempotencyKey(); + yield* store.insertPendingIntent({ + id: newId(), + correlationId: `pending-${idempotencyKey}`, + idempotencyKey, + intentType: "agent_create", + agentId: null, + grantRowId: null, + userId, + expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), + }); + + const generated = yield* client.generateIntent( + { + type: "agent_create", + name: input.name, + external_user_id: userId, + owner, + policy: input.policy as never, + }, + { idempotencyKey }, + ); + + yield* store + .linkIntent({ + idempotencyKey, + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + }) + .pipe( + Effect.catch(() => + Effect.fail( + errors.INTERNAL_SERVER_ERROR({ + message: "Agent intent was created but could not be recorded — retry to reuse it", + }), + ), + ), + ); + + yield* store + .createAgentAccount({ + id: newId(), + userId, + agentId: generated.agent_id, + name: input.name, + ownerType: owner.type, + ownerAccountId: owner.account_id, + status: "PENDING", + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to persist agent state:", error), + ), + ); + + return generated; + }), + + agentList: builder.agentList.effect(function* ({ context }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + return yield* client.listAgents(userId); + }), + + agentGet: builder.agentGet.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Agent not found for this user" })); + } + return yield* client.getAgent(input.agentId); + }), + prepareIntent: builder.prepareIntent.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + const vault = yield* GrantVault; + + if (input.type === "agent_create") { + const owner = deriveOwner(context.near); + const idempotencyKey = createIdempotencyKey(); + yield* store.insertPendingIntent({ + id: newId(), + correlationId: `pending-${idempotencyKey}`, + idempotencyKey, + intentType: "agent_create", + agentId: null, + grantRowId: null, + userId, + expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), + }); + + const generated = yield* client.generateIntent( + { + type: "agent_create", + name: input.name, + external_user_id: userId, + owner, + policy: input.policy as never, + }, + { idempotencyKey }, + ); + + yield* store + .linkIntent({ + idempotencyKey, + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + }) + .pipe( + Effect.catch(() => + Effect.fail( + errors.INTERNAL_SERVER_ERROR({ + message: + "Agent intent was created but could not be recorded — retry to reuse it", + }), + ), + ), + ); + + yield* store + .createAgentAccount({ + id: newId(), + userId, + agentId: generated.agent_id, + name: input.name, + ownerType: owner.type, + ownerAccountId: owner.account_id, + status: "PENDING", + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to persist agent state:", error), + ), + ); + + return generated; + } + + if (input.type === "grant_issue") { + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail( + errors.NOT_FOUND({ message: "Agent not found for this user" }), + ); + } + + const requestedExpiry = input.expiresAt ? Date.parse(input.expiresAt) : Number.NaN; + if (input.expiresAt && Number.isNaN(requestedExpiry)) { + return yield* Effect.fail( + errors.BAD_REQUEST({ message: "expiresAt must be an ISO datetime" }), + ); + } + const resolvedExpiryMs = Number.isNaN(requestedExpiry) + ? Date.now() + DEFAULT_GRANT_TTL_MS + : Math.min(requestedExpiry, Date.now() + MAX_GRANT_TTL_MS); + + const { token, commitment } = createGrantCredential(); + const sealed = yield* vault.seal(token); + const grantRow = yield* store.insertGrantRow({ + id: newId(), + userId, + agentId: input.agentId, + label: input.label, + tokenCiphertext: sealed.ciphertext, + tokenIv: sealed.iv, + commitment, + expiresAt: new Date(resolvedExpiryMs), + }); + + const idempotencyKey = createIdempotencyKey(); + yield* store.insertPendingIntent({ + id: newId(), + correlationId: `pending-${idempotencyKey}`, + idempotencyKey, + intentType: "grant_issue", + agentId: input.agentId, + grantRowId: grantRow.id, + userId, + expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), + }); + + const generated = yield* client.generateIntent( + { + type: "grant_issue", + agent_id: input.agentId, + label: input.label, + credential: commitment, + expires_at: new Date(resolvedExpiryMs).toISOString(), + }, + { idempotencyKey }, + ); + + yield* store + .linkIntent({ + idempotencyKey, + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + }) + .pipe( + Effect.catch(() => + Effect.fail( + errors.INTERNAL_SERVER_ERROR({ + message: + "Agent intent was created but could not be recorded — retry to reuse it", + }), + ), + ), + ); + + return generated; + } + + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Agent not found for this user" })); + } + const grantRow = yield* store.findGrantByUser(userId, input.agentId, input.grantId); + if (!grantRow) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Grant not found for this user" })); + } + + const idempotencyKey = createIdempotencyKey(); + yield* store.insertPendingIntent({ + id: newId(), + correlationId: `pending-${idempotencyKey}`, + idempotencyKey, + intentType: "grant_revoke", + agentId: input.agentId, + grantRowId: grantRow.id, + userId, + expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), + }); + + const generated = yield* client.generateIntent( + { + type: "grant_revoke", + agent_id: input.agentId, + grant_id: input.grantId, + }, + { idempotencyKey }, + ); + + yield* store + .linkIntent({ + idempotencyKey, + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + }) + .pipe( + Effect.catch(() => + Effect.fail( + errors.INTERNAL_SERVER_ERROR({ + message: "Agent intent was created but could not be recorded — retry to reuse it", + }), + ), + ), + ); + + return generated; + }), + + intentSubmit: builder.intentSubmit.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const intentRow = yield* store.findIntentByUser(userId, input.correlationId); + if (!intentRow) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Unknown correlation id" })); + } + if (intentRow.intentType !== input.type) { + return yield* Effect.fail( + errors.BAD_REQUEST({ message: "Intent type does not match the issued correlation id" }), + ); + } + + const status = yield* client.submitIntent({ + type: input.type, + correlation_id: input.correlationId, + signed_data: input.signedData as never, + }); + + yield* store + .updateIntentStatus(input.correlationId, status.status) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to update intent status:", error), + ), + ); + + if (status.status === "SUCCESS" && intentRow.grantRowId) { + if (input.type === "grant_issue") { + const details = status.details as { grant?: { grant_id?: string } | null } | undefined; + const issuedGrantId = details?.grant?.grant_id; + if (issuedGrantId) { + yield* store + .linkGrantId(intentRow.grantRowId, issuedGrantId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to link grant row:", error), + ), + ); + } + } + if (input.type === "grant_revoke") { + yield* store + .markGrantRevoked(intentRow.grantRowId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to mark grant revoked:", error), + ), + ); + } + } + + if (status.status === "SUCCESS" && input.type === "agent_create" && intentRow.agentId) { + yield* store + .activateAgent(userId, intentRow.agentId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to activate agent row:", error), + ), + ); + } + + return status; + }), + + intentStatus: builder.intentStatus.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const intentRow = yield* store.findIntentByUser(userId, input.correlationId); + if (!intentRow) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Unknown correlation id" })); + } + + const status = yield* client.getStatus(input.correlationId, input.waitMs); + + yield* store + .updateIntentStatus(input.correlationId, status.status) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to update intent status:", error), + ), + ); + + return status; + }), + + grantList: builder.grantList.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const owned = yield* store.findAgentByUser(userId, input.agentId); + if (!owned) { + return yield* Effect.fail(errors.NOT_FOUND({ message: "Agent not found for this user" })); + } + const grants = yield* client.listGrants(input.agentId); + return { data: grants }; + }), + }; + }, +}); diff --git a/plugins/agent-connect/src/lib/context.ts b/plugins/agent-connect/src/lib/context.ts new file mode 100644 index 000000000..38f603e00 --- /dev/null +++ b/plugins/agent-connect/src/lib/context.ts @@ -0,0 +1,13 @@ +/** + * Request context schema — the auth context the host injects per request. + * + * BE CAREFUL MODIFYING THIS FILE — changes will be overwritten by `bos sync` / `bos upgrade`. + * Prefer upstream changes at https://github.com/nearbuilders/everything-dev + */ + +import { z } from "zod"; +import type { AuthPluginContext as AuthContext } from "./auth-types.gen"; + +export const ContextSchema = z.custom(); + +export type Context = AuthContext; diff --git a/plugins/agent-connect/src/services/agent-api.ts b/plugins/agent-connect/src/services/agent-api.ts new file mode 100644 index 000000000..28828db4a --- /dev/null +++ b/plugins/agent-connect/src/services/agent-api.ts @@ -0,0 +1,145 @@ +import type { + AgentApiOptions, + AgentPage, + AgentView, + GenerateIntentRequest, + GenerateIntentResponse, + GrantView, + IdempotentResult, + StatusResponse, + SubmitIntentRequest, +} from "@near-intents-agent-api/sdk"; +import { + AgentApi, + AgentApiError, + AgentApiRequestError, + DEFAULT_BASE_URL, +} from "@near-intents-agent-api/sdk"; +import { ORPCError } from "@orpc/server"; +import { Context, Effect, Layer } from "effect"; + +/** + * Typed wrapper over the hosted Agent API client (`@near-intents-agent-api/sdk`). + * + * The SDK is the only protocol dependency — no endpoint shapes, amount math, + * or retry semantics live here. This layer maps SDK failures onto the + * contract's error surface and nothing else. + */ + +export type AgentApiServiceError = ORPCError< + | "BAD_REQUEST" + | "NOT_FOUND" + | "CONFLICT" + | "TOO_MANY_REQUESTS" + | "BAD_GATEWAY" + | "SERVICE_UNAVAILABLE" + | "INTERNAL_SERVER_ERROR", + unknown +>; + +const STATUS_MAP: Record< + number, + | "BAD_REQUEST" + | "NOT_FOUND" + | "CONFLICT" + | "TOO_MANY_REQUESTS" + | "BAD_GATEWAY" + | "SERVICE_UNAVAILABLE" +> = { + 400: "BAD_REQUEST", + 401: "BAD_REQUEST", + 403: "BAD_REQUEST", + 404: "NOT_FOUND", + 409: "CONFLICT", + 410: "NOT_FOUND", + 429: "TOO_MANY_REQUESTS", + 502: "BAD_GATEWAY", + 503: "SERVICE_UNAVAILABLE", +}; + +function mapDownstreamError(error: unknown): AgentApiServiceError { + if (error instanceof AgentApiRequestError) { + return new ORPCError("SERVICE_UNAVAILABLE", { + message: "Agent API is unreachable — retry with the same request", + data: { retryable: true }, + }); + } + if (error instanceof AgentApiError) { + const code = STATUS_MAP[error.status] ?? "INTERNAL_SERVER_ERROR"; + return new ORPCError(code, { + message: error.detail || error.title, + data: { + code: error.code, + retryable: error.retryable, + ...(error.availableAt ? { availableAt: error.availableAt } : {}), + }, + }); + } + return new ORPCError("INTERNAL_SERVER_ERROR", { + message: error instanceof Error ? error.message : "Agent API call failed", + }); +} + +export interface AgentApiClientShape { + generateIntent( + request: GenerateIntentRequest, + options?: { idempotencyKey?: string }, + ): Effect.Effect, AgentApiServiceError>; + submitIntent(request: SubmitIntentRequest): Effect.Effect; + getStatus( + correlationId: string, + waitMs?: number, + ): Effect.Effect; + listAgents(externalUserId: string): Effect.Effect; + getAgent(agentId: string): Effect.Effect; + listGrants(agentId: string): Effect.Effect; +} + +export class AgentApiClient extends Context.Service()( + "agent-connect/AgentApiClient", +) {} + +export const agentApiClientLayer = (options: { + apiKey: string; + baseUrl?: string; + timeoutMs?: number; +}): Layer.Layer => { + const api = new AgentApi({ + apiKey: options.apiKey, + baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + } satisfies AgentApiOptions); + + return Layer.succeed(AgentApiClient, { + generateIntent: (request, opts) => + Effect.tryPromise({ + try: () => api.generateIntent(request as never, opts as never), + catch: mapDownstreamError, + }), + submitIntent: (request) => + Effect.tryPromise({ + try: () => api.submitIntent(request), + catch: mapDownstreamError, + }), + getStatus: (correlationId, waitMs) => + Effect.tryPromise({ + try: () => api.getStatus(correlationId, waitMs !== undefined ? { waitMs } : undefined), + catch: mapDownstreamError, + }), + listAgents: (externalUserId) => + Effect.tryPromise({ + try: () => api.listAgents({ external_user_id: externalUserId }), + catch: mapDownstreamError, + }), + getAgent: (agentId) => + Effect.tryPromise({ + try: () => api.getAgent(agentId), + catch: mapDownstreamError, + }), + listGrants: (agentId) => + Effect.tryPromise({ + try: () => api.listGrants(agentId), + catch: mapDownstreamError, + }), + }); +}; diff --git a/plugins/agent-connect/src/services/store.ts b/plugins/agent-connect/src/services/store.ts new file mode 100644 index 000000000..e2deb96be --- /dev/null +++ b/plugins/agent-connect/src/services/store.ts @@ -0,0 +1,244 @@ +import { ORPCError } from "@orpc/server"; +import { and, eq } from "drizzle-orm"; +import { Context, Effect, Layer } from "effect"; +import { DatabaseTag } from "../db/layer"; +import { agentAccount, agentGrant, ownerIntent } from "../db/schema"; + +/** + * Control-plane store over the plugin's own schema. Every query that reads a + * user-owned row scopes by `userId` — the ownership invariant lives here, in + * the query, not in the caller. + */ + +export type StoreError = ORPCError< + "NOT_FOUND" | "CONFLICT" | "INTERNAL_SERVER_ERROR", + { message: string } +>; + +export interface AgentAccountRow { + id: string; + userId: string; + agentId: string; + name: string; + ownerType: string; + ownerAccountId: string | null; + status: string; + createdAt: Date; +} + +export interface OwnerIntentRow { + id: string; + correlationId: string; + idempotencyKey: string; + intentType: string; + agentId: string | null; + grantRowId: string | null; + userId: string; + status: string; + expiresAt: Date; + createdAt: Date; +} + +export interface AgentGrantRow { + id: string; + userId: string; + agentId: string; + grantId: string | null; + correlationId: string | null; + label: string; + tokenCiphertext: string; + tokenIv: string; + commitment: string; + expiresAt: Date; + revokedAt: Date | null; + createdAt: Date; +} + +const storeError = (message: string, cause: unknown): StoreError => + new ORPCError("INTERNAL_SERVER_ERROR", { message, data: { message } }); + +export class AgentStore extends Context.Service()( + "agent-connect/AgentStore", +) {} + +export interface AgentStoreShape { + createAgentAccount(input: { + id: string; + userId: string; + agentId: string; + name: string; + ownerType: string; + ownerAccountId: string | null; + status: string; + }): Effect.Effect; + findAgentByUser( + userId: string, + agentId: string, + ): Effect.Effect; + activateAgent(userId: string, agentId: string): Effect.Effect; + insertPendingIntent(input: { + id: string; + correlationId: string; + idempotencyKey: string; + intentType: string; + agentId: string | null; + grantRowId: string | null; + userId: string; + expiresAt: Date; + }): Effect.Effect; + linkIntent(input: { + idempotencyKey: string; + correlationId: string; + status: string; + expiresAt: Date; + }): Effect.Effect; + findIntentByUser( + userId: string, + correlationId: string, + ): Effect.Effect; + updateIntentStatus(correlationId: string, status: string): Effect.Effect; + insertGrantRow(input: { + id: string; + userId: string; + agentId: string; + label: string; + tokenCiphertext: string; + tokenIv: string; + commitment: string; + expiresAt: Date; + }): Effect.Effect; + linkGrantId(rowId: string, grantId: string): Effect.Effect; + markGrantRevoked(rowId: string): Effect.Effect; + findGrantByUser( + userId: string, + agentId: string, + grantId: string, + ): Effect.Effect; +} + +export const agentStoreLayer = Layer.effect( + AgentStore, + Effect.gen(function* () { + const db = yield* DatabaseTag; + + const store: AgentStoreShape = { + createAgentAccount: (input) => + Effect.tryPromise({ + try: async () => { + const [row] = await db.insert(agentAccount).values(input).returning(); + return row!; + }, + catch: (cause) => storeError("Failed to persist agent account", cause), + }), + findAgentByUser: (userId, agentId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(agentAccount) + .where(and(eq(agentAccount.agentId, agentId), eq(agentAccount.userId, userId))) + .limit(1) + .then((rows) => rows[0] ?? null)) as AgentAccountRow | null, + catch: (cause) => storeError("Failed to load agent account", cause), + }), + activateAgent: (userId, agentId) => + Effect.tryPromise({ + try: async () => { + await db + .update(agentAccount) + .set({ status: "ACTIVE" }) + .where(and(eq(agentAccount.agentId, agentId), eq(agentAccount.userId, userId))); + }, + catch: (cause) => storeError("Failed to activate agent account", cause), + }), + insertPendingIntent: (input) => + Effect.tryPromise({ + try: async () => { + const [row] = await db + .insert(ownerIntent) + .values({ ...input, status: "PENDING_SIGNATURE" }) + .returning(); + return row!; + }, + catch: (cause) => storeError("Failed to record pending intent", cause), + }), + linkIntent: ({ idempotencyKey, correlationId, status, expiresAt }) => + Effect.tryPromise({ + try: async () => { + await db + .update(ownerIntent) + .set({ correlationId, status, expiresAt }) + .where(eq(ownerIntent.idempotencyKey, idempotencyKey)); + }, + catch: (cause) => storeError("Failed to link the generated intent to the ledger", cause), + }), + findIntentByUser: (userId, correlationId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(ownerIntent) + .where( + and(eq(ownerIntent.correlationId, correlationId), eq(ownerIntent.userId, userId)), + ) + .limit(1) + .then((rows) => rows[0] ?? null)) as OwnerIntentRow | null, + catch: (cause) => storeError("Failed to load intent", cause), + }), + updateIntentStatus: (correlationId, status) => + Effect.tryPromise({ + try: async () => { + await db + .update(ownerIntent) + .set({ status }) + .where(eq(ownerIntent.correlationId, correlationId)); + }, + catch: (cause) => storeError("Failed to update intent status", cause), + }), + insertGrantRow: (input) => + Effect.tryPromise({ + try: async () => { + const [row] = await db.insert(agentGrant).values(input).returning(); + return row!; + }, + catch: (cause) => storeError("Failed to persist grant", cause), + }), + linkGrantId: (rowId, grantId) => + Effect.tryPromise({ + try: async () => { + await db.update(agentGrant).set({ grantId }).where(eq(agentGrant.id, rowId)); + }, + catch: (cause) => storeError("Failed to link grant", cause), + }), + markGrantRevoked: (rowId) => + Effect.tryPromise({ + try: async () => { + await db + .update(agentGrant) + .set({ revokedAt: new Date() }) + .where(eq(agentGrant.id, rowId)); + }, + catch: (cause) => storeError("Failed to mark grant revoked", cause), + }), + findGrantByUser: (userId, agentId, grantId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(agentGrant) + .where( + and( + eq(agentGrant.agentId, agentId), + eq(agentGrant.grantId, grantId), + eq(agentGrant.userId, userId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null)) as AgentGrantRow | null, + catch: (cause) => storeError("Failed to load grant", cause), + }), + }; + + return store; + }), +); diff --git a/plugins/agent-connect/src/services/vault.ts b/plugins/agent-connect/src/services/vault.ts new file mode 100644 index 000000000..1587db636 --- /dev/null +++ b/plugins/agent-connect/src/services/vault.ts @@ -0,0 +1,90 @@ +import { Context, Effect, Layer } from "effect"; + +/** + * Grant-token vault — `ngt_` tokens are encrypted at rest (HKDF-SHA256 → + * AES-256-GCM, plugin-specific salt) and never leave the server. The + * commitment (sha256 of the token) is the only thing that reaches the + * owner-signed intent. + */ + +const VAULT_SALT = "agent-connect-grant-vault"; + +export interface SealedToken { + ciphertext: string; + iv: string; +} + +export class GrantVault extends Context.Service()( + "agent-connect/GrantVault", +) {} + +export interface GrantVaultShape { + seal(token: string): Effect.Effect; + open(sealed: SealedToken): Effect.Effect; +} + +async function deriveAesKey(secret: string): Promise { + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { + name: "HKDF", + }, + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new TextEncoder().encode(VAULT_SALT), + info: new Uint8Array(0), + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function bytesToBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + +function base64ToBytes(base64: string): Uint8Array { + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); +} + +export const grantVaultLayer = (secret: string): Layer.Layer => { + const keyPromise = deriveAesKey(secret); + + const vault: GrantVaultShape = { + seal: (token) => + Effect.tryPromise({ + try: async (): Promise => { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + await keyPromise, + new TextEncoder().encode(token), + ); + return { ciphertext: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) }; + }, + catch: (cause) => new Error("Failed to seal grant token", { cause }), + }), + open: ({ ciphertext, iv }) => + Effect.tryPromise({ + try: async (): Promise => { + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(iv) as Uint8Array }, + await keyPromise, + base64ToBytes(ciphertext) as Uint8Array, + ); + return new TextDecoder().decode(decrypted); + }, + catch: (cause) => new Error("Failed to open grant token", { cause }), + }), + }; + + return Layer.succeed(GrantVault, vault); +}; diff --git a/plugins/agent-connect/tests/integration/agent-connect.test.ts b/plugins/agent-connect/tests/integration/agent-connect.test.ts new file mode 100644 index 000000000..f9f6b7b80 --- /dev/null +++ b/plugins/agent-connect/tests/integration/agent-connect.test.ts @@ -0,0 +1,265 @@ +import type { ContractRouterClient } from "@orpc/contract"; +import { describe, expect, it, vi } from "vitest"; +import type { contract } from "@/contract"; + +const MOCK_ACCOUNT_ID = "test.near"; +const MOCK_PUBLIC_KEY = "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD"; + +const TEST_POLICY = { + frozen: false, + actions: ["swap", "transfer", "withdraw"], + confidential: false, + owner_approval: false, + assets: "any", + limits: {}, + max_actions_per_hour: null, + destinations: { mode: "only", list: [] }, + budget: { daily_usd: "10", weekly_usd: null, monthly_usd: null }, + timelock_ms: 0, +}; + +/** + * In-memory stand-in for the hosted Agent API — the SDK module is mocked so + * the plugin's control-plane invariants are tested against the same + * generate/submit/status surface without network. + */ +function makeAgentApiStub() { + const intents: Array<{ type: string; agent_id?: string }> = []; + return class AgentApiMock { + constructor(options?: { apiKey?: string }) { + if (!options?.apiKey) throw new Error("apiKey required"); + } + generateIntent( + request: { type: string; agent_id?: string }, + options?: { idempotencyKey?: string }, + ) { + intents.push(request); + const agentId = "agent_id" in request && request.agent_id ? request.agent_id : "ag_test1"; + return Promise.resolve({ + correlation_id: `intent_${intents.length}`, + type: request.type, + agent_id: agentId, + status: "PENDING_SIGNATURE", + expires_at: new Date(Date.now() + 600_000).toISOString(), + signer: { type: "near", account_id: MOCK_ACCOUNT_ID, public_key: MOCK_PUBLIC_KEY }, + intent: { + standard: "nep413", + payload: { + message: "owner intent", + recipient: "api.agentsonintents.com", + nonce: "dGVzdA==", + }, + }, + preview: { summary: "Sign to continue" }, + }); + } + submitIntent(request: { type: string }) { + const details = request.type === "grant_issue" ? { grant: { grant_id: "64hexgrantid" } } : {}; + return Promise.resolve({ + correlation_id: "intent_1", + agent_id: "ag_test1", + status: "SUCCESS", + failure_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + dispatch_committed_at: null, + grant: null, + type: request.type, + details, + }); + } + getStatus(correlationId: string) { + return Promise.resolve({ + correlation_id: correlationId, + agent_id: "ag_test1", + status: "SUCCESS", + failure_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + dispatch_committed_at: null, + grant: null, + type: "agent_create", + details: { agent_id: "ag_test1", revision: 1, policy_hash: "hash", transaction_hash: null }, + }); + } + listAgents() { + return Promise.resolve({ data: [{ id: "ag_test1" }], next_cursor: null }); + } + getAgent(agentId: string) { + return Promise.resolve({ id: agentId }); + } + listGrants() { + return Promise.resolve([]); + } + }; +} + +vi.mock("@near-intents-agent-api/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AgentApi: makeAgentApiStub(), + }; +}); + +const { getPluginClient, runtime } = await import("../setup"); +const { createPluginRuntime } = await import("every-plugin"); +const Plugin = (await import("@/index")).default; + +describe("agent connect routes", () => { + it("requires a session", async () => { + const client = await getPluginClient(); + await expect(client.agentCreate({ name: "my agent", policy: TEST_POLICY })).rejects.toThrow( + /Authentication required/, + ); + }); + + it("returns the generated intent without leaking the api key", async () => { + const client = await getPluginClient({ userId: "user1" }); + const generated = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + expect(generated.correlation_id).toMatch(/^intent_/); + expect(generated.intent.standard).toBe("nep413"); + expect(JSON.stringify(generated)).not.toContain("naa_"); + }); + + it("derives the owner from the primary linked NEAR account or refuses", async () => { + const client = await getPluginClient({ + userId: "user1", + near: { primaryAccountId: null, linkedAccounts: [], hasNearAccount: false }, + }); + await expect(client.agentCreate({ name: "no wallet", policy: TEST_POLICY })).rejects.toThrow( + /No owner wallet available/, + ); + }); + + it("refuses submits for correlation ids not issued to the user", async () => { + const client = await getPluginClient({ userId: "user1" }); + await expect( + client.intentSubmit({ + type: "agent_create", + correlationId: "intent_not_issued", + signedData: { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", + }, + }), + ).rejects.toThrow(/Unknown correlation id/); + }); + + it("rejects a submit whose type does not match the issued intent", async () => { + const client = await getPluginClient({ userId: "user1" }); + const created = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + await expect( + client.intentSubmit({ + type: "grant_revoke", + correlationId: created.correlation_id, + signedData: { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", + }, + }), + ).rejects.toThrow(/does not match the issued correlation id/); + }); + + it("keeps grant tokens server-side when issuing a grant", async () => { + const client = await getPluginClient({ userId: "user1" }); + await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + const generated = await client.prepareIntent({ + type: "grant_issue", + agentId: "ag_test1", + label: "assistant-session-42", + }); + expect(generated.type).toBe("grant_issue"); + expect(JSON.stringify(generated)).not.toContain("ngt_"); + }); + + it("refuses grant operations on agents the user does not own", async () => { + const client = await getPluginClient({ userId: "someone-else" }); + await expect( + client.prepareIntent({ type: "grant_issue", agentId: "ag_test1", label: "hijack" }), + ).rejects.toThrow(/Agent not found for this user/); + await expect(client.grantList({ agentId: "ag_test1" })).rejects.toThrow( + /Agent not found for this user/, + ); + }); + + it("completes the create → submit → owned-agent flow", async () => { + const client = await getPluginClient({ userId: "user1" }); + const created = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + const status = await client.intentSubmit({ + type: "agent_create", + correlationId: created.correlation_id, + signedData: { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", + }, + }); + expect(status.status).toBe("SUCCESS"); + + const view = await client.agentGet({ agentId: "ag_test1" }); + expect(view.id).toBe("ag_test1"); + }); +}); + +describe("dormant plugin", () => { + it("fails with a not-configured error when no api key is set", async () => { + const { createServer } = await import("node:http"); + const dormantRuntime = createPluginRuntime({ + registry: { + "@everything-dev/agent-connect-plugin": { module: Plugin, description: "dormant" }, + }, + secrets: {}, + }); + const { router, initialized } = await dormantRuntime.usePlugin( + "@everything-dev/agent-connect-plugin", + { + variables: {}, + secrets: { AGENT_CONNECT_DATABASE_URL: "pglite:.bos/agent-connect-test/:memory:" }, + } as never, + ); + + const { RPCHandler } = await import("@orpc/server/node"); + const { createORPCClient } = await import("@orpc/client"); + const { RPCLink } = await import("@orpc/client/fetch"); + + const rpcHandler = new RPCHandler(router); + const server = createServer(async (req, res) => { + const url = new URL(req.url!, "http://localhost:0"); + if (url.pathname.startsWith("/rpc")) { + const result = await rpcHandler.handle(req, res, { + prefix: "/rpc", + context: { userId: "user1", "effect/context": initialized.effectContext }, + }); + if (result.matched) return; + } + res.statusCode = 404; + res.end("Route not found"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + const link = new RPCLink({ + origin: `http://127.0.0.1:${port}`, + url: "/rpc", + fetch: globalThis.fetch, + }); + const client: ContractRouterClient = createORPCClient(link); + + await expect(client.agentList()).rejects.toThrow(/not configured/); + + await new Promise((resolve) => server.close(() => resolve())); + await dormantRuntime.shutdown(); + await runtime.shutdown(); + }); +}); diff --git a/plugins/agent-connect/tests/integration/drift-gate.test.ts b/plugins/agent-connect/tests/integration/drift-gate.test.ts new file mode 100644 index 000000000..089e2925b --- /dev/null +++ b/plugins/agent-connect/tests/integration/drift-gate.test.ts @@ -0,0 +1,37 @@ +import { DEFAULT_BASE_URL } from "@near-intents-agent-api/sdk"; +import { describe, expect, it } from "vitest"; + +/** + * Drift gate — opt-in live check that the hosted Agent API still exposes the + * endpoints this plugin proxies. The SDK is generated from the API's + * OpenAPI spec, so drift between the SDK and the API is upstream's to fix; + * this test catches the case where the *deployment* the plugin points at + * (`AGENT_CONNECT_LIVE_BASE_URL`, default: the SDK's default) no longer + * matches the surface the plugin assumes. Run with: + * + * AGENT_CONNECT_LIVE_CHECK=1 pnpm test + */ +describe.skipIf(!process.env.AGENT_CONNECT_LIVE_CHECK)("live drift gate", () => { + const baseUrl = process.env.AGENT_CONNECT_LIVE_BASE_URL ?? DEFAULT_BASE_URL; + + it("exposes the paths the plugin proxies", async () => { + const response = await fetch(new URL("/openapi.json", baseUrl), { + headers: { accept: "application/json" }, + }); + expect(response.ok).toBe(true); + + const spec = (await response.json()) as { paths?: Record }; + const paths = Object.keys(spec.paths ?? {}); + + for (const expected of [ + "/v1/agents", + "/v1/agents/{agent_id}", + "/v1/generate-intent", + "/v1/submit-intent", + "/v1/status", + "/v1/agents/{agent_id}/grants", + ]) { + expect(paths, `missing ${expected}`).toContain(expected); + } + }); +}); diff --git a/plugins/agent-connect/tests/setup.ts b/plugins/agent-connect/tests/setup.ts new file mode 100644 index 000000000..adefc9d06 --- /dev/null +++ b/plugins/agent-connect/tests/setup.ts @@ -0,0 +1,133 @@ +import { createServer } from "node:http"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import type { ContractRouterClient } from "@orpc/contract"; +import { RPCHandler } from "@orpc/server/node"; +import { createPluginRuntime } from "every-plugin"; +import type { contract } from "@/contract"; +import Plugin from "@/index"; +import packageJson from "../package.json" with { type: "json" }; +import pluginDevConfig from "../plugin.dev"; + +const TEST_PLUGIN_ID = packageJson.name; +const TEST_CONFIG = pluginDevConfig.config; + +const TEST_REGISTRY = { + [TEST_PLUGIN_ID]: { + module: Plugin, + description: "Integration test runtime", + }, +} as const; + +export const runtime = createPluginRuntime({ + registry: TEST_REGISTRY, + secrets: {}, +}); + +let server: ReturnType | null = null; +let baseUrl = ""; +let port = 0; + +export async function getPluginClient(context?: { + userId?: string; + sessionId?: string; + near?: unknown; +}) { + if (!server) { + const { router, initialized } = await runtime.usePlugin(TEST_PLUGIN_ID, TEST_CONFIG); + const rpcHandler = new RPCHandler(router); + const effectContext = initialized.effectContext; + + // Find an available port + const testPort = 3000 + Math.floor(Math.random() * 1000); + port = testPort; + baseUrl = `http://localhost:${port}`; + + server = createServer(async (req, res) => { + const url = new URL(req.url!, baseUrl); + + if (url.pathname.startsWith("/rpc")) { + // Initialize empty context for each request to prevent closure capture + let requestContext = {}; + + // Allow overriding context via headers for flexibility + if (req.headers["x-test-user"]) { + requestContext = { + ...requestContext, + userId: req.headers["x-test-user"] as string, + }; + } + if (req.headers["x-test-session"]) { + requestContext = { + ...requestContext, + sessionId: req.headers["x-test-session"] as string, + }; + } + if (req.headers["x-test-near"]) { + requestContext = { + ...requestContext, + near: JSON.parse(req.headers["x-test-near"] as string), + }; + } else { + requestContext = { + ...requestContext, + near: { + primaryAccountId: "test.near", + linkedAccounts: [ + { + accountId: "test.near", + network: "mainnet", + publicKey: "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD", + isPrimary: true, + }, + ], + hasNearAccount: true, + }, + }; + } + + const result = await rpcHandler.handle(req, res, { + prefix: "/rpc", + context: { ...requestContext, "effect/context": effectContext }, + }); + if (result.matched) return; + } + + res.statusCode = 404; + res.end("Route not found"); + }); + + await new Promise((resolve, reject) => { + server?.listen(port, "127.0.0.1", () => resolve()); + server?.on("error", reject); + }); + } + + const link = new RPCLink({ + origin: baseUrl, + url: "/rpc", + fetch: globalThis.fetch, + headers: context + ? { + ...(context.userId && { "x-test-user": context.userId }), + ...(context.sessionId && { "x-test-session": context.sessionId }), + ...(context.near !== undefined && { + "x-test-near": JSON.stringify(context.near), + }), + } + : {}, + }); + + const client: ContractRouterClient = createORPCClient(link); + return client; +} + +export async function teardown() { + if (server) { + await new Promise((resolve) => { + server?.close(() => resolve()); + }); + server = null; + } + await runtime.shutdown(); +} diff --git a/plugins/agent-connect/tests/types.d.ts b/plugins/agent-connect/tests/types.d.ts new file mode 100644 index 000000000..ce96ca4a1 --- /dev/null +++ b/plugins/agent-connect/tests/types.d.ts @@ -0,0 +1,8 @@ +import Plugin from "@/index"; +import packageJson from "../package.json" with { type: "json" }; + +declare module "every-plugin" { + interface RegisteredPlugins { + [packageJson.name]: typeof Plugin; + } +} diff --git a/plugins/agent-connect/tests/unit/vault.test.ts b/plugins/agent-connect/tests/unit/vault.test.ts new file mode 100644 index 000000000..2a6e015e5 --- /dev/null +++ b/plugins/agent-connect/tests/unit/vault.test.ts @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; +import { createGrantCredential, grantCommitment } from "@near-intents-agent-api/sdk"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { GrantVault, grantVaultLayer } from "@/services/vault"; + +const withVault = (secret: string) => + Effect.gen(function* () { + return yield* GrantVault; + }).pipe(Effect.provide([grantVaultLayer(secret)])); + +describe("grant token vault", () => { + it("issues ngt_ tokens whose commitment is the sha256 of the token", () => { + const { token, commitment } = createGrantCredential(); + expect(token).toMatch(/^ngt_[A-Za-z0-9_-]{43}$/); + expect(commitment).toBe(createHash("sha256").update(token).digest("hex")); + expect(commitment).toBe(grantCommitment(token)); + }); + + it("seals tokens so neither ciphertext nor iv contains the token", async () => { + const vault = await Effect.runPromise(withVault("test-vault-secret-32-bytes-long!!")); + const { token } = createGrantCredential(); + + const sealed = await Effect.runPromise(vault.seal(token)); + expect(sealed.ciphertext).not.toContain(token); + expect(sealed.iv).not.toContain(token); + }); + + it("opens sealed tokens only under the same vault secret", async () => { + const vault = await Effect.runPromise(withVault("test-vault-secret-32-bytes-long!!")); + const { token } = createGrantCredential(); + const sealed = await Effect.runPromise(vault.seal(token)); + + const opened = await Effect.runPromise(vault.open(sealed)); + expect(opened).toBe(token); + + const otherVault = await Effect.runPromise(withVault("a-completely-different-secret-value")); + await expect(Effect.runPromise(otherVault.open(sealed))).rejects.toThrow(); + }); +}); diff --git a/plugins/agent-connect/tsconfig.json b/plugins/agent-connect/tsconfig.json new file mode 100644 index 000000000..a29780adc --- /dev/null +++ b/plugins/agent-connect/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "types": ["node"], + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "customConditions": ["development"], + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "declaration": false, + "declarationMap": false, + "plugins": [ + { + "name": "@effect/language-service", + "diagnostics": false + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "files": ["tests/types.d.ts"], + "include": ["src/**/*", "tests/**/*", "plugin.dev.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/agent-connect/types/contract.d.ts b/plugins/agent-connect/types/contract.d.ts new file mode 100644 index 000000000..faf8183dd --- /dev/null +++ b/plugins/agent-connect/types/contract.d.ts @@ -0,0 +1,234 @@ +import "@orpc/openapi/extensions/route"; +import { z } from "zod"; +export declare const contract: { + agentCreate: import("@orpc/contract").ProcedureContract; + }, z.core.$strip>, z.ZodObject<{ + correlation_id: z.ZodString; + type: z.ZodString; + status: z.ZodString; + expires_at: z.ZodString; + intent: z.ZodObject<{ + standard: z.ZodString; + payload: z.ZodRecord; + }, z.core.$loose>; + preview: z.ZodOptional; + }, z.core.$loose>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + agentList: import("@orpc/contract").ProcedureContract; + next_cursor: z.ZodOptional>; + }, z.core.$loose>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + agentGet: import("@orpc/contract").ProcedureContract, z.ZodObject<{ + id: z.ZodString; + }, z.core.$loose>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + prepareIntent: import("@orpc/contract").ProcedureContract; + name: z.ZodString; + policy: z.ZodRecord; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"grant_issue">; + agentId: z.ZodString; + label: z.ZodString; + expiresAt: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"grant_revoke">; + agentId: z.ZodString; + grantId: z.ZodString; + }, z.core.$strip>], "type">, z.ZodObject<{ + correlation_id: z.ZodString; + type: z.ZodString; + status: z.ZodString; + expires_at: z.ZodString; + intent: z.ZodObject<{ + standard: z.ZodString; + payload: z.ZodRecord; + }, z.core.$loose>; + preview: z.ZodOptional; + }, z.core.$loose>, { + UNAUTHORIZED: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + intentSubmit: import("@orpc/contract").ProcedureContract; + correlationId: z.ZodString; + signedData: z.ZodRecord; + }, z.core.$strip>, z.ZodObject<{ + correlation_id: z.ZodString; + status: z.ZodString; + type: z.ZodOptional; + details: z.ZodOptional; + }, z.core.$loose>, { + UNAUTHORIZED: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + intentStatus: import("@orpc/contract").ProcedureContract; + }, z.core.$strip>, z.ZodObject<{ + correlation_id: z.ZodString; + status: z.ZodString; + type: z.ZodOptional; + details: z.ZodOptional; + }, z.core.$loose>, { + UNAUTHORIZED: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; + grantList: import("@orpc/contract").ProcedureContract, z.ZodObject<{ + data: z.ZodArray; + }, z.core.$strip>, { + UNAUTHORIZED: { + message: string; + }; + BAD_REQUEST: { + message: string; + }; + CONFLICT: { + message: string; + }; + TOO_MANY_REQUESTS: { + message: string; + }; + NOT_FOUND: { + message: string; + }; + SERVICE_UNAVAILABLE: { + message: string; + }; + BAD_GATEWAY: { + message: string; + }; + INTERNAL_SERVER_ERROR: { + message: string; + }; + }>; +}; +export type ContractType = typeof contract; diff --git a/plugins/agent-connect/vitest.config.ts b/plugins/agent-connect/vitest.config.ts new file mode 100644 index 000000000..f252254cf --- /dev/null +++ b/plugins/agent-connect/vitest.config.ts @@ -0,0 +1,19 @@ +import DrizzleORMMigrations from "@proj-airi/unplugin-drizzle-orm-migrations/vite"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + exclude: ["node_modules/**", "dist/**"], + testTimeout: 30000, + }, + plugins: [ + tsconfigPaths({ + projects: ["./tsconfig.json"], + }), + DrizzleORMMigrations(), + ], +}); diff --git a/plugins/auth/src/auth-config.ts b/plugins/auth/src/auth-config.ts index d00723e3e..1176bee59 100644 --- a/plugins/auth/src/auth-config.ts +++ b/plugins/auth/src/auth-config.ts @@ -72,11 +72,6 @@ export interface AuthConfig { }; }; siwn: AuthSiwnConfig; - agentApi?: { - apiKey?: string; - baseUrl?: string; - timeoutMs?: number; - }; email?: { from: string; }; diff --git a/plugins/auth/src/auth-instance.ts b/plugins/auth/src/auth-instance.ts index 34e11dbb7..45303dc57 100644 --- a/plugins/auth/src/auth-instance.ts +++ b/plugins/auth/src/auth-instance.ts @@ -19,13 +19,7 @@ import { memberAc, ownerAc, } from "better-auth/plugins/organization/access"; -import { - type AgentConnectOptions, - agentConnect, - DEFAULT_DEVICE_LINK_CLIENT_ID, - type SIWNPluginOptions, - siwn, -} from "better-near-auth"; +import { DEFAULT_DEVICE_LINK_CLIENT_ID, type SIWNPluginOptions, siwn } from "better-near-auth"; import { eq, gt } from "drizzle-orm"; import type { AuthConfig, AuthPermissionStatements } from "./auth-config"; import { custody } from "./custody"; @@ -292,15 +286,6 @@ export function createAuthInstance( const mainnetRecipient = isRecipientsConfig(siwnOptions) ? siwnOptions.recipients.mainnet : siwnOptions.recipient; - const agentApiOptions: AgentConnectOptions = { - apiKey: config.agentApi?.apiKey, - baseUrl: config.agentApi?.baseUrl, - timeoutMs: config.agentApi?.timeoutMs, - passkeyOwner: { - rpId: passkeyOptions.rpID, - origin: passkeyOptions.origin[0] ?? config.baseUrl, - }, - }; return betterAuth({ database: drizzleAdapter(db, { @@ -325,7 +310,6 @@ export function createAuthInstance( socialProviders, plugins: [ siwn(siwnOptions), - agentConnect(agentApiOptions), admin({ defaultRole: "user", adminRoles: ["admin"] }), anonymous({ emailDomainName: mainnetRecipient }), haveIBeenPwned({ enabled: process.env.NODE_ENV !== "test" }), diff --git a/plugins/auth/src/config-schemas.ts b/plugins/auth/src/config-schemas.ts index 34b41dc11..3f407d965 100644 --- a/plugins/auth/src/config-schemas.ts +++ b/plugins/auth/src/config-schemas.ts @@ -121,12 +121,6 @@ export const authVariablesSchema = z.object({ }) .optional(), siwn: z.union([authSiwnRecipientSchema, authSiwnRecipientsSchema]), - agentApi: z - .object({ - baseUrl: z.string().optional(), - timeoutMs: z.number().optional(), - }) - .optional(), deviceLink: z .object({ clientId: z.string().min(1).default(DEFAULT_DEVICE_LINK_CLIENT_ID), diff --git a/plugins/auth/src/config.ts b/plugins/auth/src/config.ts index e3703ffe5..20dca9f2d 100644 --- a/plugins/auth/src/config.ts +++ b/plugins/auth/src/config.ts @@ -226,13 +226,6 @@ export function normalizeAuthConfig( ...siwn, apiKey: secrets.FASTNEAR_API_KEY, }, - agentApi: secrets.NEAR_INTENTS_AGENT_API_KEY - ? { - apiKey: secrets.NEAR_INTENTS_AGENT_API_KEY, - baseUrl: variables.agentApi?.baseUrl, - timeoutMs: variables.agentApi?.timeoutMs, - } - : undefined, email: variables.email?.from ? { from: variables.email.from, diff --git a/plugins/auth/src/db/migrations/meta/0015_snapshot.json b/plugins/auth/src/db/migrations/meta/0015_snapshot.json deleted file mode 100644 index 0da78afb0..000000000 --- a/plugins/auth/src/db/migrations/meta/0015_snapshot.json +++ /dev/null @@ -1,2575 +0,0 @@ -{ - "id": "2fe7ceae-3b77-4310-a43a-bed5387db422", - "prevId": "293bc059-79e1-4ac2-a90d-63b2dd52a5fb", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.apikey": { - "name": "apikey", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "config_id": { - "name": "config_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'default'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "start": { - "name": "start", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "reference_id": { - "name": "reference_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refill_interval": { - "name": "refill_interval", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "refill_amount": { - "name": "refill_amount", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_refill_at": { - "name": "last_refill_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "rate_limit_enabled": { - "name": "rate_limit_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "rate_limit_time_window": { - "name": "rate_limit_time_window", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 86400000 - }, - "rate_limit_max": { - "name": "rate_limit_max", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 10 - }, - "request_count": { - "name": "request_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "remaining": { - "name": "remaining", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_request": { - "name": "last_request", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "permissions": { - "name": "permissions", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "apikey_configId_idx": { - "name": "apikey_configId_idx", - "columns": [ - { - "expression": "config_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "apikey_referenceId_idx": { - "name": "apikey_referenceId_idx", - "columns": [ - { - "expression": "reference_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "apikey_key_idx": { - "name": "apikey_key_idx", - "columns": [ - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.custody_wallet": { - "name": "custody_wallet", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "wallet_id": { - "name": "wallet_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "near_account_id": { - "name": "near_account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encrypted_api_key": { - "name": "encrypted_api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "handoff_url": { - "name": "handoff_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "custodyWallet_userId_network_uidx": { - "name": "custodyWallet_userId_network_uidx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "network", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "custody_wallet_user_id_user_id_fk": { - "name": "custody_wallet_user_id_user_id_fk", - "tableFrom": "custody_wallet", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_code": { - "name": "device_code", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "device_code": { - "name": "device_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_code": { - "name": "user_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_polled_at": { - "name": "last_polled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "polling_interval": { - "name": "polling_interval", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "client_id": { - "name": "client_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deviceCode_deviceCode_uidx": { - "name": "deviceCode_deviceCode_uidx", - "columns": [ - { - "expression": "device_code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deviceCode_userCode_uidx": { - "name": "deviceCode_userCode_uidx", - "columns": [ - { - "expression": "user_code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "device_code_user_id_user_id_fk": { - "name": "device_code_user_id_user_id_fk", - "tableFrom": "device_code", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_link_claim": { - "name": "device_link_claim", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "token_hash": { - "name": "token_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "client_id": { - "name": "client_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "consumed_at": { - "name": "consumed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deviceLinkClaim_tokenHash_uidx": { - "name": "deviceLinkClaim_tokenHash_uidx", - "columns": [ - { - "expression": "token_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.funded_gas_key": { - "name": "funded_gas_key", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "fundedGasKey_userId_idx": { - "name": "fundedGasKey_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "fundedGasKey_accountId_idx": { - "name": "fundedGasKey_accountId_idx", - "columns": [ - { - "expression": "account_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "funded_gas_key_user_id_user_id_fk": { - "name": "funded_gas_key_user_id_user_id_fk", - "tableFrom": "funded_gas_key", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.invitation": { - "name": "invitation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "inviter_id": { - "name": "inviter_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "near_account_id": { - "name": "near_account_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "near_network": { - "name": "near_network", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "invitation_organizationId_idx": { - "name": "invitation_organizationId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_email_idx": { - "name": "invitation_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_teamId_idx": { - "name": "invitation_teamId_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_nearAccountId_idx": { - "name": "invitation_nearAccountId_idx", - "columns": [ - { - "expression": "near_account_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_nearNetwork_idx": { - "name": "invitation_nearNetwork_idx", - "columns": [ - { - "expression": "near_network", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "invitation_organization_id_organization_id_fk": { - "name": "invitation_organization_id_organization_id_fk", - "tableFrom": "invitation", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "invitation_inviter_id_user_id_fk": { - "name": "invitation_inviter_id_user_id_fk", - "tableFrom": "invitation", - "tableTo": "user", - "columnsFrom": ["inviter_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.member": { - "name": "member", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "member_organizationId_idx": { - "name": "member_organizationId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "member_userId_idx": { - "name": "member_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "member_organization_id_organization_id_fk": { - "name": "member_organization_id_organization_id_fk", - "tableFrom": "member", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "member_user_id_user_id_fk": { - "name": "member_user_id_user_id_fk", - "tableFrom": "member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.near_account": { - "name": "near_account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "near_account_user_id_user_id_fk": { - "name": "near_account_user_id_user_id_fk", - "tableFrom": "near_account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.onboarding_code": { - "name": "onboarding_code", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "code_hash": { - "name": "code_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "event_id": { - "name": "event_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "event_name": { - "name": "event_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encrypted_code": { - "name": "encrypted_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "max_uses": { - "name": "max_uses", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 50 - }, - "used_count": { - "name": "used_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "onboardingCode_codeHash_uidx": { - "name": "onboardingCode_codeHash_uidx", - "columns": [ - { - "expression": "code_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "onboardingCode_organizationId_eventId_idx": { - "name": "onboardingCode_organizationId_eventId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "event_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "onboarding_code_organization_id_organization_id_fk": { - "name": "onboarding_code_organization_id_organization_id_fk", - "tableFrom": "onboarding_code", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "onboarding_code_team_id_team_id_fk": { - "name": "onboarding_code_team_id_team_id_fk", - "tableFrom": "onboarding_code", - "tableTo": "team", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "onboarding_code_created_by_user_id_fk": { - "name": "onboarding_code_created_by_user_id_fk", - "tableFrom": "onboarding_code", - "tableTo": "user", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.onboarding_redemption": { - "name": "onboarding_redemption", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "code_id": { - "name": "code_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "onboardingRedemption_codeId_userId_uidx": { - "name": "onboardingRedemption_codeId_userId_uidx", - "columns": [ - { - "expression": "code_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "onboardingRedemption_codeId_idx": { - "name": "onboardingRedemption_codeId_idx", - "columns": [ - { - "expression": "code_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "onboarding_redemption_code_id_onboarding_code_id_fk": { - "name": "onboarding_redemption_code_id_onboarding_code_id_fk", - "tableFrom": "onboarding_redemption", - "tableTo": "onboarding_code", - "columnsFrom": ["code_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "onboarding_redemption_user_id_user_id_fk": { - "name": "onboarding_redemption_user_id_user_id_fk", - "tableFrom": "onboarding_redemption", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organization": { - "name": "organization", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "logo": { - "name": "logo", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "requested_by": { - "name": "requested_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "rejection_reason": { - "name": "rejection_reason", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "organization_slug_uidx": { - "name": "organization_slug_uidx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "organization_requested_by_user_id_fk": { - "name": "organization_requested_by_user_id_fk", - "tableFrom": "organization", - "tableTo": "user", - "columnsFrom": ["requested_by"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "organization_slug_unique": { - "name": "organization_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.passkey": { - "name": "passkey", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "credential_id": { - "name": "credential_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "counter": { - "name": "counter", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "device_type": { - "name": "device_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "backed_up": { - "name": "backed_up", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "transports": { - "name": "transports", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "aaguid": { - "name": "aaguid", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "passkey_userId_idx": { - "name": "passkey_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "passkey_credentialID_idx": { - "name": "passkey_credentialID_idx", - "columns": [ - { - "expression": "credential_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "passkey_user_id_user_id_fk": { - "name": "passkey_user_id_user_id_fk", - "tableFrom": "passkey", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.relayed_transaction": { - "name": "relayed_transaction", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "tx_hash": { - "name": "tx_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sender_id": { - "name": "sender_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "receiver_id": { - "name": "receiver_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "gas_used": { - "name": "gas_used", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "relayedTransaction_txHash_idx": { - "name": "relayedTransaction_txHash_idx", - "columns": [ - { - "expression": "tx_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "relayedTransaction_userId_idx": { - "name": "relayedTransaction_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "relayed_transaction_user_id_user_id_fk": { - "name": "relayed_transaction_user_id_user_id_fk", - "tableFrom": "relayed_transaction", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.relayer_key": { - "name": "relayer_key", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "encrypted_private_key": { - "name": "encrypted_private_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "iv": { - "name": "iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "network": { - "name": "network", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "impersonated_by": { - "name": "impersonated_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "active_organization_id": { - "name": "active_organization_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "active_team_id": { - "name": "active_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team": { - "name": "team", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "member_count": { - "name": "member_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "team_organizationId_idx": { - "name": "team_organizationId_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "team_organization_id_organization_id_fk": { - "name": "team_organization_id_organization_id_fk", - "tableFrom": "team", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.team_member": { - "name": "team_member", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "membership_key": { - "name": "membership_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "teamMember_teamId_idx": { - "name": "teamMember_teamId_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "teamMember_userId_idx": { - "name": "teamMember_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "team_member_team_id_team_id_fk": { - "name": "team_member_team_id_team_id_fk", - "tableFrom": "team_member", - "tableTo": "team", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "team_member_user_id_user_id_fk": { - "name": "team_member_user_id_user_id_fk", - "tableFrom": "team_member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "team_member_membership_key_unique": { - "name": "team_member_membership_key_unique", - "nullsNotDistinct": false, - "columns": ["membership_key"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "banned": { - "name": "banned", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "ban_reason": { - "name": "ban_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ban_expires": { - "name": "ban_expires", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "phone_number": { - "name": "phone_number", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "phone_number_verified": { - "name": "phone_number_verified", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "locale": { - "name": "locale", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - }, - "user_phone_number_unique": { - "name": "user_phone_number_unique", - "nullsNotDistinct": false, - "columns": ["phone_number"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.agent_account": { - "name": "agent_account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_type": { - "name": "owner_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_account_id": { - "name": "owner_account_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "agentAccount_agentId_uidx": { - "name": "agentAccount_agentId_uidx", - "columns": [ - { - "expression": "agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "agentAccount_userId_idx": { - "name": "agentAccount_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "agent_account_user_id_user_id_fk": { - "name": "agent_account_user_id_user_id_fk", - "tableFrom": "agent_account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.agent_grant": { - "name": "agent_grant", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "grant_id": { - "name": "grant_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "correlation_id": { - "name": "correlation_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_ciphertext": { - "name": "token_ciphertext", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_iv": { - "name": "token_iv", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "commitment": { - "name": "commitment", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "agentGrant_agentId_idx": { - "name": "agentGrant_agentId_idx", - "columns": [ - { - "expression": "agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "agentGrant_userId_idx": { - "name": "agentGrant_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "agentGrant_commitment_idx": { - "name": "agentGrant_commitment_idx", - "columns": [ - { - "expression": "commitment", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "agent_grant_user_id_user_id_fk": { - "name": "agent_grant_user_id_user_id_fk", - "tableFrom": "agent_grant", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.owner_intent": { - "name": "owner_intent", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "correlation_id": { - "name": "correlation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "intent_type": { - "name": "intent_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "grant_row_id": { - "name": "grant_row_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "ownerIntent_correlationId_uidx": { - "name": "ownerIntent_correlationId_uidx", - "columns": [ - { - "expression": "correlation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "ownerIntent_userId_idx": { - "name": "ownerIntent_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "owner_intent_user_id_user_id_fk": { - "name": "owner_intent_user_id_user_id_fk", - "tableFrom": "owner_intent", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/plugins/auth/src/db/migrations/meta/_journal.json b/plugins/auth/src/db/migrations/meta/_journal.json index c74d3eda8..dfde00a28 100644 --- a/plugins/auth/src/db/migrations/meta/_journal.json +++ b/plugins/auth/src/db/migrations/meta/_journal.json @@ -99,13 +99,6 @@ "when": 1792074422000, "tag": "0014_deep_toad", "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1792174050940, - "tag": "0015_agent-connect", - "breakpoints": true } ] } diff --git a/plugins/auth/src/db/schema.ts b/plugins/auth/src/db/schema.ts index 868861add..62af5755d 100644 --- a/plugins/auth/src/db/schema.ts +++ b/plugins/auth/src/db/schema.ts @@ -420,73 +420,6 @@ export const custodyWallet = pgTable( (table) => [uniqueIndex("custodyWallet_userId_network_uidx").on(table.userId, table.network)], ); -export const agentAccount = pgTable( - "agent_account", - { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - agentId: text("agent_id").notNull(), - name: text("name").notNull(), - ownerType: text("owner_type").notNull(), - ownerAccountId: text("owner_account_id"), - status: text("status").notNull(), - createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - uniqueIndex("agentAccount_agentId_uidx").on(table.agentId), - index("agentAccount_userId_idx").on(table.userId), - ], -); - -export const ownerIntent = pgTable( - "owner_intent", - { - id: text("id").primaryKey(), - correlationId: text("correlation_id").notNull(), - idempotencyKey: text("idempotency_key").notNull(), - intentType: text("intent_type").notNull(), - agentId: text("agent_id"), - grantRowId: text("grant_row_id"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - status: text("status").notNull(), - expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - uniqueIndex("ownerIntent_correlationId_uidx").on(table.correlationId), - index("ownerIntent_userId_idx").on(table.userId), - ], -); - -export const agentGrant = pgTable( - "agent_grant", - { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - agentId: text("agent_id").notNull(), - grantId: text("grant_id"), - correlationId: text("correlation_id"), - label: text("label").notNull(), - tokenCiphertext: text("token_ciphertext").notNull(), - tokenIv: text("token_iv").notNull(), - commitment: text("commitment").notNull(), - expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), - revokedAt: timestamp("revoked_at", { mode: "date", withTimezone: true }), - createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index("agentGrant_agentId_idx").on(table.agentId), - index("agentGrant_userId_idx").on(table.userId), - index("agentGrant_commitment_idx").on(table.commitment), - ], -); - export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f58c0e477..437ce44bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ catalogs: '@module-federation/runtime-tools': specifier: ^2.9.0 version: 2.9.2 + '@near-intents-agent-api/sdk': + specifier: ^0.4.1 + version: 0.4.1 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 @@ -1067,6 +1070,79 @@ importers: specifier: 'catalog:' version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(@vitest/ui@4.1.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + plugins/agent-connect: + dependencies: + '@electric-sql/pglite': + specifier: 'catalog:' + version: 0.4.6 + '@near-intents-agent-api/sdk': + specifier: 'catalog:' + version: 0.4.1 + '@orpc/contract': + specifier: 'catalog:' + version: 2.0.0-beta.35 + '@orpc/openapi': + specifier: 'catalog:' + version: 2.0.0-beta.35 + '@orpc/server': + specifier: 'catalog:' + version: 2.0.0-beta.35 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.3(@electric-sql/pglite@0.4.6)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(bun-types@1.4.3)(kysely@0.29.6)(pg@8.23.1) + effect: + specifier: 'catalog:' + version: 4.0.0-rc.117 + pg: + specifier: 'catalog:' + version: 8.23.1 + typescript: + specifier: 'catalog:' + version: 7.0.2 + zod: + specifier: 'catalog:' + version: 4.6.5 + devDependencies: + '@module-federation/node': + specifier: 'catalog:' + version: 2.7.52(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23))(typescript@7.0.2) + '@module-federation/runtime-tools': + specifier: 'catalog:' + version: 2.9.2 + '@proj-airi/unplugin-drizzle-orm-migrations': + specifier: ^0.1.4 + version: 0.1.6(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + '@rspack/cli': + specifier: 'catalog:' + version: 2.2.6(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23)) + '@rspack/core': + specifier: 2.2.8 + version: 2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23) + '@types/node': + specifier: 'catalog:' + version: 25.9.9 + '@types/pg': + specifier: 'catalog:' + version: 8.23.1 + dotenv: + specifier: ^17.2.3 + version: 17.4.2 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.11 + every-plugin: + specifier: workspace:^ + version: link:../../packages/every-plugin + everything-dev: + specifier: workspace:^ + version: link:../../packages/everything-dev + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@7.0.2)(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.9)(@vitest/ui@4.1.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.2(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + plugins/ai: dependencies: '@base-ui/react': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 480cb820d..83bbb2cde 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,7 @@ catalog: "@orpc/tanstack-query": 2.0.0-beta.35 "@orpc/zod": 2.0.0-beta.35 "@outlayer/sdk": 0.1.0-alpha.4 + "@near-intents-agent-api/sdk": ^0.4.1 "@rsbuild/core": 2.2.8 "@rsbuild/plugin-react": 2.1.0 "@rspack/cli": 2.2.6 From 222ad14c1fbc2b1e626feb99a1b7d12dc979b3bd Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 14:54:12 -0500 Subject: [PATCH 18/20] =?UTF-8?q?fix(agent-connect):=20cleanup=20pass=20?= =?UTF-8?q?=E2=80=94=20shared=20reconciliation,=20ledger=20agent=5Fid,=20h?= =?UTF-8?q?onest=20idempotency,=20typed=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings addressed: - submit and status polling now share one reconciliation path: a SUCCESS observed later applies the same local effects as an immediate one (grant id linked, revoke marked, agent row activated) - the intent ledger records the reserved agent_id at link time, so agent_create rows can activate on later success - idempotency made real: prepare routes accept an optional client idempotency key (8-128 chars); the stored response replays on reuse, an interrupted prepare resends the exact stored request body under the same key, and key reuse across intent types conflicts - contract outputs typed where the UI/helper need it (agent_id, signer, idempotencyKey, grant views) with passthrough elsewhere - the SDK stays the only shape authority No migration changes needed: the new plugin's unpublished 0000 chain was regenerated in place; the auth chain is untouched vs main. --- plugins/agent-connect/src/contract.ts | 44 +- .../src/db/migrations/0000_init.sql | 2 + .../src/db/migrations/meta/0000_snapshot.json | 16 +- .../src/db/migrations/meta/_journal.json | 4 +- plugins/agent-connect/src/db/schema.ts | 4 +- plugins/agent-connect/src/index.ts | 501 +++++++++--------- plugins/agent-connect/src/services/store.ts | 35 +- 7 files changed, 331 insertions(+), 275 deletions(-) diff --git a/plugins/agent-connect/src/contract.ts b/plugins/agent-connect/src/contract.ts index 547c36c48..432b110f8 100644 --- a/plugins/agent-connect/src/contract.ts +++ b/plugins/agent-connect/src/contract.ts @@ -46,19 +46,38 @@ const SignedDataSchema = z .record(z.string(), z.unknown()) .describe("The wallet's signature output over intent.payload, submitted unchanged"); +const IdempotencyKeySchema = z + .string() + .regex(/^[A-Za-z0-9._:-]{8,128}$/, "8–128 chars of [A-Za-z0-9._:-]") + .describe( + "Optional retry key: the same key with the same request returns the same intent (safe retry); the same key with a different request is a conflict", + ); + /** * The browser-visible intent envelope: `intent.payload` + `preview` are all a * browser ever sees — never the `naa_` partner key or an `ngt_` grant token. + * Fields beyond the typed subset pass through unwrapped. */ const GeneratedIntentSchema = z.looseObject({ correlation_id: z.string().describe("Tracking id of the generated owner intent"), + agent_id: z + .string() + .nullable() + .optional() + .describe("The agent account id — agent_create reserves it and returns it immediately"), type: z.string().describe("Intent type, e.g. agent_create, grant_issue, grant_revoke"), status: z.string().describe("Intent status, e.g. PENDING_SIGNATURE"), expires_at: z.string().describe("ISO datetime after which the intent can no longer be signed"), + idempotencyKey: z.string().optional().describe("The idempotency key used for this intent"), intent: z.looseObject({ standard: z.string().describe("Signing standard: nep413 | nep366 | eip712 | webauthn"), payload: z.record(z.string(), z.unknown()).describe("The exact bytes the owner's wallet signs"), }), + signer: z + .looseObject({ type: z.string() }) + .nullable() + .optional() + .describe("The owner wallet that must sign this intent"), preview: z .unknown() .optional() @@ -67,6 +86,7 @@ const GeneratedIntentSchema = z.looseObject({ const StatusSchema = z.looseObject({ correlation_id: z.string().describe("Tracking id of the intent"), + agent_id: z.string().nullable().optional().describe("The agent account the intent acts on"), status: z .string() .describe("SUCCESS | FAILED | PENDING_SIGNATURE | UNCERTAIN | NEEDS_REVIEW | …"), @@ -74,17 +94,26 @@ const StatusSchema = z.looseObject({ details: z.unknown().optional().describe("Intent-type-specific result details"), }); +const AgentViewSchema = z.looseObject({ + id: z.string().describe("Agent account id"), + name: z.string().optional().describe("Display name"), + status: z.string().optional().describe("Agent status, e.g. PENDING | ACTIVE | FROZEN"), +}); + const AgentPageSchema = z.looseObject({ - data: z.array(z.unknown()).describe("Agent views"), + data: z.array(AgentViewSchema).describe("Agent views"), next_cursor: z.string().nullable().optional().describe("Cursor for the next page"), }); -const AgentViewSchema = z.looseObject({ - id: z.string().describe("Agent account id"), +const GrantViewSchema = z.looseObject({ + grant_id: z.string().describe("Grant id (64 hex)"), + label: z.string().optional().describe("Grant label"), + expires_at: z.string().optional().describe("Grant expiry"), + revoked_at: z.string().nullable().optional().describe("When the grant was revoked, if it was"), }); const GrantsSchema = z.object({ - data: z.array(z.unknown()).describe("Grant views for the agent account"), + data: z.array(GrantViewSchema).describe("Grant views for the agent account"), }); const PrepareIntentInputSchema = z.discriminatedUnion("type", [ @@ -92,6 +121,7 @@ const PrepareIntentInputSchema = z.discriminatedUnion("type", [ type: z.literal("agent_create"), name: z.string().min(1).max(64).describe("Display name for the new agent account"), policy: PolicySchema, + idempotencyKey: IdempotencyKeySchema.optional(), }), z.object({ type: z.literal("grant_issue"), @@ -102,11 +132,13 @@ const PrepareIntentInputSchema = z.discriminatedUnion("type", [ .datetime() .optional() .describe("Grant expiry; default 7 days, max 365 days"), + idempotencyKey: IdempotencyKeySchema.optional(), }), z.object({ type: z.literal("grant_revoke"), agentId: z.string().min(1).describe("Agent account holding the grant"), grantId: z.string().min(1).describe("Grant to revoke"), + idempotencyKey: IdempotencyKeySchema.optional(), }), ]); @@ -124,12 +156,14 @@ export const contract = oc.router({ z.object({ name: z.string().min(1).max(64).describe("Display name for the new agent account"), policy: PolicySchema, + idempotencyKey: IdempotencyKeySchema.optional(), }), ) .output(GeneratedIntentSchema) .errors({ UNAUTHORIZED: Errors.UNAUTHORIZED, BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, BAD_GATEWAY: Errors.BAD_GATEWAY, @@ -195,6 +229,7 @@ export const contract = oc.router({ UNAUTHORIZED: Errors.UNAUTHORIZED, NOT_FOUND: Errors.NOT_FOUND, BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, BAD_GATEWAY: Errors.BAD_GATEWAY, @@ -224,6 +259,7 @@ export const contract = oc.router({ UNAUTHORIZED: Errors.UNAUTHORIZED, NOT_FOUND: Errors.NOT_FOUND, BAD_REQUEST: Errors.BAD_REQUEST, + CONFLICT: Errors.CONFLICT, SERVICE_UNAVAILABLE: Errors.SERVICE_UNAVAILABLE, TOO_MANY_REQUESTS: Errors.TOO_MANY_REQUESTS, BAD_GATEWAY: Errors.BAD_GATEWAY, diff --git a/plugins/agent-connect/src/db/migrations/0000_init.sql b/plugins/agent-connect/src/db/migrations/0000_init.sql index 2812ae01d..be7b58425 100644 --- a/plugins/agent-connect/src/db/migrations/0000_init.sql +++ b/plugins/agent-connect/src/db/migrations/0000_init.sql @@ -34,6 +34,8 @@ CREATE TABLE "owner_intent" ( "user_id" text NOT NULL, "status" text NOT NULL, "expires_at" timestamp with time zone NOT NULL, + "request" jsonb, + "response" jsonb, "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint diff --git a/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json index 897a960dc..cb35451b6 100644 --- a/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json +++ b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a8f935cf-004e-43a6-829a-8becabf9b766", + "id": "dcc38b20-e016-4567-b55c-73b3630c78e9", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -287,6 +287,18 @@ "primaryKey": false, "notNull": true }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -361,4 +373,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/plugins/agent-connect/src/db/migrations/meta/_journal.json b/plugins/agent-connect/src/db/migrations/meta/_journal.json index 57574d967..582098d75 100644 --- a/plugins/agent-connect/src/db/migrations/meta/_journal.json +++ b/plugins/agent-connect/src/db/migrations/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "7", - "when": 1792178485013, + "when": 1792180403527, "tag": "0000_init", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plugins/agent-connect/src/db/schema.ts b/plugins/agent-connect/src/db/schema.ts index 924691e8a..827468359 100644 --- a/plugins/agent-connect/src/db/schema.ts +++ b/plugins/agent-connect/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; /** * Agent Connect control-plane tables (own plugin schema, `plugin_agent_connect`). @@ -38,6 +38,8 @@ export const ownerIntent = pgTable( userId: text("user_id").notNull(), status: text("status").notNull(), expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }).notNull(), + request: jsonb("request").$type(), + response: jsonb("response").$type(), createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), }, (table) => [ diff --git a/plugins/agent-connect/src/index.ts b/plugins/agent-connect/src/index.ts index 420e6a31f..98aa63f67 100644 --- a/plugins/agent-connect/src/index.ts +++ b/plugins/agent-connect/src/index.ts @@ -2,6 +2,8 @@ import { createGrantCredential, createIdempotencyKey, DEFAULT_BASE_URL, + type GenerateIntentResponse, + type StatusResponse, } from "@near-intents-agent-api/sdk"; import { ORPCError } from "@orpc/server"; import { Effect, Layer } from "effect"; @@ -10,13 +12,9 @@ import { z } from "zod"; import { contract } from "./contract"; import { DatabaseLive } from "./db/layer"; import { ContextSchema } from "./lib/context"; -import { - AgentApiClient, - type AgentApiServiceError, - agentApiClientLayer, -} from "./services/agent-api"; -import { AgentStore, agentStoreLayer } from "./services/store"; -import { GrantVault, grantVaultLayer } from "./services/vault"; +import { AgentApiClient, type AgentApiClientShape, type AgentApiServiceError, agentApiClientLayer } from "./services/agent-api"; +import { agentStoreLayer, AgentStore, type AgentStoreShape, type OwnerIntentRow } from "./services/store"; +import { grantVaultLayer, GrantVault } from "./services/vault"; /** * Agent Connect — the control plane giving every signed-in user an @@ -29,8 +27,17 @@ import { GrantVault, grantVaultLayer } from "./services/vault"; * * Owner intents follow the BFF split: prepare (this plugin, returns * `intent.payload` + preview) → the browser signs with the connected wallet - * (better-near-auth's `agentConnectClient`) → submit (this plugin). The - * `naa_` partner key and `ngt_` grant tokens never reach a browser. + * (better-near-auth's `signAgentIntent`) → submit (this plugin). The `naa_` + * partner key and `ngt_` grant tokens never reach a browser. + * + * Idempotency: prepare routes accept an optional client idempotency key — + * the same key with the same request returns the same intent (safe retry: + * a stored response replays, otherwise the exact stored request body is + * resent under the same key), and the same key with a different request + * type conflicts. Upstream enforces body equality (`409 idempotency_conflict`). + * + * Submit and status polling share one reconciliation path, so a success + * observed later applies the same local effects as an immediate one. * * Owner derivation reads the request context (`context.near`) — the primary * linked NEAR account. Passkey-only owners are a tracked follow-up. @@ -42,6 +49,8 @@ import { GrantVault, grantVaultLayer } from "./services/vault"; const PRE_SEND_TTL_MS = 15 * 60 * 1000; const DEFAULT_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MAX_GRANT_TTL_MS = 365 * 24 * 60 * 60 * 1000; +const LINK_FAILURE_MESSAGE = + "Agent intent was created but could not be recorded — retry with the same idempotency key to reuse it"; const NOT_CONFIGURED: AgentApiServiceError = new ORPCError("SERVICE_UNAVAILABLE", { message: "Agent Connect is not configured — set NEAR_INTENTS_AGENT_API_KEY", @@ -59,6 +68,18 @@ const dormantClientLayer = Layer.succeed(AgentApiClient, { const newId = (): string => crypto.randomUUID(); +/** The pieces of the generated intent the ledger stores and the UI needs. */ +interface GeneratedIntent { + correlation_id: string; + agent_id?: string | null; + status: string; + expires_at: string; +} + +type LedgerError = ORPCError<"INTERNAL_SERVER_ERROR", unknown>; + +const CONFLICT_ERROR = (message: string) => new ORPCError("CONFLICT", { message }); + export default createPlugin({ variables: z.object({ apiBaseUrl: z.string().default(DEFAULT_BASE_URL).describe("Hosted Agent API origin"), @@ -146,52 +167,174 @@ export default createPlugin({ return { type: "near", account_id: primary.accountId, public_key: primary.publicKey }; }; - return { - agentCreate: builder.agentCreate.effect(function* ({ input, context, errors }) { - const userId = requireUser(context.userId); - const owner = deriveOwner(context.near); - const client = yield* AgentApiClient; - const store = yield* AgentStore; + /** + * Ledger link — fatal on failure: the caller retries with the same key + * and lands on the reuse path. + */ + const linkGenerated = ( + store: AgentStoreShape, + idempotencyKey: string, + generated: GenerateIntentResponse, + ): Effect.Effect => + store + .linkIntent({ + idempotencyKey, + correlationId: generated.correlation_id, + status: generated.status, + expiresAt: new Date(generated.expires_at), + agentId: generated.agent_id ?? null, + response: generated, + }) + .pipe( + Effect.catch(() => + Effect.fail(new ORPCError("INTERNAL_SERVER_ERROR", { message: LINK_FAILURE_MESSAGE })), + ), + Effect.as(generated), + ); + + /** + * The intent ledger, uniformly: reuse (stored response replays; otherwise + * the exact stored request body is resent under the same key) or fresh + * (record pending with the request → send → link). + */ + const prepareWithLedger = (args: { + store: AgentStoreShape; + client: AgentApiClientShape; + userId: string; + intentType: "agent_create" | "grant_issue" | "grant_revoke"; + idempotencyKeyInput: string | undefined; + buildFreshRequest: () => Effect.Effect< + { request: unknown; grantRowId?: string }, + AgentApiServiceError | LedgerError | Error + >; + }): Effect.Effect => + Effect.gen(function* () { + const { store, client, userId } = args; + const idempotencyKey = args.idempotencyKeyInput ?? createIdempotencyKey(); + + const existing = yield* store.findIntentByKey(userId, idempotencyKey); + if (existing) { + if (existing.intentType !== args.intentType) { + return yield* Effect.fail( + CONFLICT_ERROR("Idempotency key was already used for a different request"), + ); + } + if (existing.response != null) { + return existing.response as GenerateIntentResponse; + } + const regenerated = yield* client.generateIntent(existing.request as never, { + idempotencyKey, + }); + return yield* linkGenerated(store, idempotencyKey, regenerated); + } - const idempotencyKey = createIdempotencyKey(); + const fresh = yield* args.buildFreshRequest(); yield* store.insertPendingIntent({ id: newId(), correlationId: `pending-${idempotencyKey}`, idempotencyKey, - intentType: "agent_create", + intentType: args.intentType, agentId: null, - grantRowId: null, + grantRowId: fresh.grantRowId ?? null, userId, + request: fresh.request, expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), }); - const generated = yield* client.generateIntent( - { - type: "agent_create", - name: input.name, - external_user_id: userId, - owner, - policy: input.policy as never, - }, - { idempotencyKey }, + const generated = yield* client.generateIntent(fresh.request as never, { + idempotencyKey, + }); + return yield* linkGenerated(store, idempotencyKey, generated); + }); + + /** + * One reconciliation path for a settled intent — shared by submit and + * status polling, so a success observed later applies the same local + * effects as an immediate one. Ledger writes are best-effort. + */ + const reconcileIntent = ( + store: AgentStoreShape, + userId: string, + intentRow: OwnerIntentRow, + status: StatusResponse, + ): Effect.Effect => { + const update = store + .updateIntentStatus(intentRow.correlationId, status.status) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to update intent status:", error).pipe( + Effect.asVoid, + ), + ), ); - yield* store - .linkIntent({ - idempotencyKey, - correlationId: generated.correlation_id, - status: generated.status, - expiresAt: new Date(generated.expires_at), - }) - .pipe( - Effect.catch(() => - Effect.fail( - errors.INTERNAL_SERVER_ERROR({ - message: "Agent intent was created but could not be recorded — retry to reuse it", - }), + if (status.status !== "SUCCESS") { + return update; + } + + const settled = Effect.gen(function* () { + const details = status.details as { grant?: { grant_id?: string } | null } | undefined; + if (intentRow.intentType === "grant_issue" && intentRow.grantRowId) { + const issuedGrantId = details?.grant?.grant_id; + if (issuedGrantId) { + yield* store + .linkGrantId(intentRow.grantRowId, issuedGrantId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to link grant row:", error).pipe( + Effect.asVoid, + ), + ), + ); + } + } + if (intentRow.intentType === "grant_revoke" && intentRow.grantRowId) { + yield* store.markGrantRevoked(intentRow.grantRowId).pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to mark grant revoked:", error).pipe( + Effect.asVoid, + ), + ), + ); + } + if (intentRow.intentType === "agent_create" && intentRow.agentId) { + yield* store.activateAgent(userId, intentRow.agentId).pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to activate agent row:", error).pipe( + Effect.asVoid, ), ), ); + } + }); + + return Effect.asVoid(Effect.flatMap(update, () => settled)); + }; + + return { + agentCreate: builder.agentCreate.effect(function* ({ input, context, errors }) { + const userId = requireUser(context.userId); + const owner = deriveOwner(context.near); + const client = yield* AgentApiClient; + const store = yield* AgentStore; + + const generated = yield* prepareWithLedger({ + store, + client, + userId, + intentType: "agent_create", + idempotencyKeyInput: input.idempotencyKey, + buildFreshRequest: () => + Effect.succeed({ + request: { + type: "agent_create", + name: input.name, + external_user_id: userId, + owner, + policy: input.policy, + }, + }), + }); yield* store .createAgentAccount({ @@ -229,197 +372,74 @@ export default createPlugin({ } return yield* client.getAgent(input.agentId); }), + prepareIntent: builder.prepareIntent.effect(function* ({ input, context, errors }) { const userId = requireUser(context.userId); const client = yield* AgentApiClient; const store = yield* AgentStore; const vault = yield* GrantVault; - if (input.type === "agent_create") { - const owner = deriveOwner(context.near); - const idempotencyKey = createIdempotencyKey(); - yield* store.insertPendingIntent({ - id: newId(), - correlationId: `pending-${idempotencyKey}`, - idempotencyKey, - intentType: "agent_create", - agentId: null, - grantRowId: null, - userId, - expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), - }); - - const generated = yield* client.generateIntent( - { - type: "agent_create", - name: input.name, - external_user_id: userId, - owner, - policy: input.policy as never, - }, - { idempotencyKey }, - ); - - yield* store - .linkIntent({ - idempotencyKey, - correlationId: generated.correlation_id, - status: generated.status, - expiresAt: new Date(generated.expires_at), - }) - .pipe( - Effect.catch(() => - Effect.fail( - errors.INTERNAL_SERVER_ERROR({ - message: - "Agent intent was created but could not be recorded — retry to reuse it", - }), - ), - ), - ); - - yield* store - .createAgentAccount({ - id: newId(), - userId, - agentId: generated.agent_id, - name: input.name, - ownerType: owner.type, - ownerAccountId: owner.account_id, - status: "PENDING", - }) - .pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to persist agent state:", error), - ), - ); - - return generated; - } - - if (input.type === "grant_issue") { + if (input.type === "grant_issue" || input.type === "grant_revoke") { const owned = yield* store.findAgentByUser(userId, input.agentId); if (!owned) { return yield* Effect.fail( errors.NOT_FOUND({ message: "Agent not found for this user" }), ); } - - const requestedExpiry = input.expiresAt ? Date.parse(input.expiresAt) : Number.NaN; - if (input.expiresAt && Number.isNaN(requestedExpiry)) { - return yield* Effect.fail( - errors.BAD_REQUEST({ message: "expiresAt must be an ISO datetime" }), - ); - } - const resolvedExpiryMs = Number.isNaN(requestedExpiry) - ? Date.now() + DEFAULT_GRANT_TTL_MS - : Math.min(requestedExpiry, Date.now() + MAX_GRANT_TTL_MS); - - const { token, commitment } = createGrantCredential(); - const sealed = yield* vault.seal(token); - const grantRow = yield* store.insertGrantRow({ - id: newId(), - userId, - agentId: input.agentId, - label: input.label, - tokenCiphertext: sealed.ciphertext, - tokenIv: sealed.iv, - commitment, - expiresAt: new Date(resolvedExpiryMs), - }); - - const idempotencyKey = createIdempotencyKey(); - yield* store.insertPendingIntent({ - id: newId(), - correlationId: `pending-${idempotencyKey}`, - idempotencyKey, - intentType: "grant_issue", - agentId: input.agentId, - grantRowId: grantRow.id, - userId, - expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), - }); - - const generated = yield* client.generateIntent( - { - type: "grant_issue", - agent_id: input.agentId, - label: input.label, - credential: commitment, - expires_at: new Date(resolvedExpiryMs).toISOString(), - }, - { idempotencyKey }, - ); - - yield* store - .linkIntent({ - idempotencyKey, - correlationId: generated.correlation_id, - status: generated.status, - expiresAt: new Date(generated.expires_at), - }) - .pipe( - Effect.catch(() => - Effect.fail( - errors.INTERNAL_SERVER_ERROR({ - message: - "Agent intent was created but could not be recorded — retry to reuse it", - }), - ), - ), - ); - - return generated; - } - - const owned = yield* store.findAgentByUser(userId, input.agentId); - if (!owned) { - return yield* Effect.fail(errors.NOT_FOUND({ message: "Agent not found for this user" })); - } - const grantRow = yield* store.findGrantByUser(userId, input.agentId, input.grantId); - if (!grantRow) { - return yield* Effect.fail(errors.NOT_FOUND({ message: "Grant not found for this user" })); } - const idempotencyKey = createIdempotencyKey(); - yield* store.insertPendingIntent({ - id: newId(), - correlationId: `pending-${idempotencyKey}`, - idempotencyKey, - intentType: "grant_revoke", - agentId: input.agentId, - grantRowId: grantRow.id, + return yield* prepareWithLedger({ + store, + client, userId, - expiresAt: new Date(Date.now() + PRE_SEND_TTL_MS), - }); - - const generated = yield* client.generateIntent( - { - type: "grant_revoke", - agent_id: input.agentId, - grant_id: input.grantId, + intentType: input.type, + idempotencyKeyInput: input.idempotencyKey, + buildFreshRequest: () => { + if (input.type === "grant_issue") { + const requestedExpiry = input.expiresAt ? Date.parse(input.expiresAt) : Number.NaN; + const resolvedExpiryMs = Number.isNaN(requestedExpiry) + ? Date.now() + DEFAULT_GRANT_TTL_MS + : Math.min(requestedExpiry, Date.now() + MAX_GRANT_TTL_MS); + + return Effect.gen(function* () { + const { token, commitment } = createGrantCredential(); + const sealed = yield* vault.seal(token); + const grantRow = yield* store.insertGrantRow({ + id: newId(), + userId, + agentId: input.agentId, + label: input.label, + tokenCiphertext: sealed.ciphertext, + tokenIv: sealed.iv, + commitment, + expiresAt: new Date(resolvedExpiryMs), + }); + return { + request: { + type: "grant_issue", + agent_id: input.agentId, + label: input.label, + credential: commitment, + expires_at: new Date(resolvedExpiryMs).toISOString(), + }, + grantRowId: grantRow.id, + }; + }); + } + if (input.type === "grant_revoke") { + return Effect.succeed({ + request: { + type: "grant_revoke", + agent_id: input.agentId, + grant_id: input.grantId, + }, + }); + } + return Effect.fail( + new ORPCError("BAD_REQUEST", { message: "Unsupported intent type" }), + ); }, - { idempotencyKey }, - ); - - yield* store - .linkIntent({ - idempotencyKey, - correlationId: generated.correlation_id, - status: generated.status, - expiresAt: new Date(generated.expires_at), - }) - .pipe( - Effect.catch(() => - Effect.fail( - errors.INTERNAL_SERVER_ERROR({ - message: "Agent intent was created but could not be recorded — retry to reuse it", - }), - ), - ), - ); - - return generated; + }); }), intentSubmit: builder.intentSubmit.effect(function* ({ input, context, errors }) { @@ -443,48 +463,7 @@ export default createPlugin({ signed_data: input.signedData as never, }); - yield* store - .updateIntentStatus(input.correlationId, status.status) - .pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to update intent status:", error), - ), - ); - - if (status.status === "SUCCESS" && intentRow.grantRowId) { - if (input.type === "grant_issue") { - const details = status.details as { grant?: { grant_id?: string } | null } | undefined; - const issuedGrantId = details?.grant?.grant_id; - if (issuedGrantId) { - yield* store - .linkGrantId(intentRow.grantRowId, issuedGrantId) - .pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to link grant row:", error), - ), - ); - } - } - if (input.type === "grant_revoke") { - yield* store - .markGrantRevoked(intentRow.grantRowId) - .pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to mark grant revoked:", error), - ), - ); - } - } - - if (status.status === "SUCCESS" && input.type === "agent_create" && intentRow.agentId) { - yield* store - .activateAgent(userId, intentRow.agentId) - .pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to activate agent row:", error), - ), - ); - } + yield* reconcileIntent(store, userId, intentRow, status); return status; }), @@ -501,13 +480,7 @@ export default createPlugin({ const status = yield* client.getStatus(input.correlationId, input.waitMs); - yield* store - .updateIntentStatus(input.correlationId, status.status) - .pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to update intent status:", error), - ), - ); + yield* reconcileIntent(store, userId, intentRow, status); return status; }), diff --git a/plugins/agent-connect/src/services/store.ts b/plugins/agent-connect/src/services/store.ts index e2deb96be..be35db7aa 100644 --- a/plugins/agent-connect/src/services/store.ts +++ b/plugins/agent-connect/src/services/store.ts @@ -36,6 +36,8 @@ export interface OwnerIntentRow { userId: string; status: string; expiresAt: Date; + request: unknown; + response: unknown; createdAt: Date; } @@ -84,6 +86,7 @@ export interface AgentStoreShape { agentId: string | null; grantRowId: string | null; userId: string; + request: unknown; expiresAt: Date; }): Effect.Effect; linkIntent(input: { @@ -91,11 +94,17 @@ export interface AgentStoreShape { correlationId: string; status: string; expiresAt: Date; + agentId?: string | null; + response?: unknown; }): Effect.Effect; findIntentByUser( userId: string, correlationId: string, ): Effect.Effect; + findIntentByKey( + userId: string, + idempotencyKey: string, + ): Effect.Effect; updateIntentStatus(correlationId: string, status: string): Effect.Effect; insertGrantRow(input: { id: string; @@ -162,12 +171,18 @@ export const agentStoreLayer = Layer.effect( }, catch: (cause) => storeError("Failed to record pending intent", cause), }), - linkIntent: ({ idempotencyKey, correlationId, status, expiresAt }) => + linkIntent: ({ idempotencyKey, correlationId, status, expiresAt, agentId, response }) => Effect.tryPromise({ try: async () => { await db .update(ownerIntent) - .set({ correlationId, status, expiresAt }) + .set({ + correlationId, + status, + expiresAt, + ...(agentId !== undefined ? { agentId } : {}), + ...(response !== undefined ? { response } : {}), + }) .where(eq(ownerIntent.idempotencyKey, idempotencyKey)); }, catch: (cause) => storeError("Failed to link the generated intent to the ledger", cause), @@ -185,6 +200,22 @@ export const agentStoreLayer = Layer.effect( .then((rows) => rows[0] ?? null)) as OwnerIntentRow | null, catch: (cause) => storeError("Failed to load intent", cause), }), + findIntentByKey: (userId, idempotencyKey) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(ownerIntent) + .where( + and( + eq(ownerIntent.idempotencyKey, idempotencyKey), + eq(ownerIntent.userId, userId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null)) as OwnerIntentRow | null, + catch: (cause) => storeError("Failed to load intent by idempotency key", cause), + }), updateIntentStatus: (correlationId, status) => Effect.tryPromise({ try: async () => { From c18d373fc0cd2fe17c853a8c4ec6f73e671da278 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 15:42:21 -0500 Subject: [PATCH 19/20] =?UTF-8?q?test(agent-connect):=20cover=20review=20f?= =?UTF-8?q?indings=20=E2=80=94=20later-success=20reconciliation,=20idempot?= =?UTF-8?q?ent=20prepare,=20type=20fidelity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stateful SDK mock (submit/poll status controls, generate-calls counter) - reconciliation tests: a SUCCESS observed by polling links the grant id and activates the agent row exactly like an immediate success (asserted against the store via the runtime's effect context) - idempotency tests: the same key replays the stored response without a second upstream generate; cross-type key reuse conflicts - type-level drift gate: the generated oRPC client returns the SDK's own response types, so signAgentIntent and the UI consume them without casts - contract outputs switched to z.custom (the SDK is the shape authority — zero re-declaration), README invariants + changeset updated --- .changeset/agent-connect-plugin.md | 2 +- plugins/agent-connect/README.md | 10 +- plugins/agent-connect/src/contract.ts | 86 +- .../src/db/migrations/meta/0000_snapshot.json | 2 +- .../src/db/migrations/meta/_journal.json | 2 +- plugins/agent-connect/src/index.ts | 44 +- plugins/agent-connect/src/services/store.ts | 17 +- .../tests/integration/agent-connect.test.ts | 168 +- plugins/agent-connect/tests/setup.ts | 15 + .../tests/unit/contract-types.test.ts | 24 + plugins/agent-connect/types/contract.d.ts | 2884 ++++++++++++++++- 11 files changed, 3107 insertions(+), 147 deletions(-) create mode 100644 plugins/agent-connect/tests/unit/contract-types.test.ts diff --git a/.changeset/agent-connect-plugin.md b/.changeset/agent-connect-plugin.md index f0483b50e..0aae6017b 100644 --- a/.changeset/agent-connect-plugin.md +++ b/.changeset/agent-connect-plugin.md @@ -2,4 +2,4 @@ "@everything-dev/agent-connect-plugin": minor --- -New plugin: the Agent Connect control plane — every signed-in user gets an owner-controlled agent account on the NEAR Intents Agent API (https://api.agentsonintents.com). The owner signs the rules once with their wallet; agents hold revocable `ngt_` grant tokens that only act inside the owner-signed policy. The plugin consumes `@near-intents-agent-api/sdk` (catalog-pinned, the only protocol dependency — types re-exported, never re-declared) and adds only platform concerns: session ownership via the auth context, the sealed grant-token vault (HKDF-SHA256 → AES-256-GCM, never sent to a browser), the pre-send intent ledger (persist before send, idempotent retry reuses the key), and the oRPC surface (`agent/create`, `agent/list`, `agent/{id}`, `agent/intents/prepare|submit|status`, `agent/{id}/grants`) which exposes the domain to REST and MCP through the host's OpenAPI mounts. Owner intents follow the BFF split — prepare returns `intent.payload` + preview, the browser signs via better-near-auth's `signAgentIntent`, submit carries the wallet's output. Dormant until `NEAR_INTENTS_AGENT_API_KEY` is configured; own `plugin_agent_connect` schema (`AGENT_CONNECT_DATABASE_URL`, pglite default); `AGENT_CONNECT_ENCRYPTION_KEY` isolates the vault key. +New plugin: the Agent Connect control plane — every signed-in user gets an owner-controlled agent account on the NEAR Intents Agent API (https://api.agentsonintents.com). The owner signs the rules once with their wallet; agents hold revocable `ngt_` grant tokens that only act inside the owner-signed policy. The plugin consumes `@near-intents-agent-api/sdk` (catalog-pinned, the only protocol dependency — route outputs are the SDK's own types, never re-declared) and adds only platform concerns: session ownership via the auth context, the sealed grant-token vault (HKDF-SHA256 → AES-256-GCM, never sent to a browser), the pre-send intent ledger (persist before send; prepare routes accept an optional client idempotency key — same key replays the stored intent or resends the stored request, cross-type reuse conflicts), submit and status polling share one reconciliation path (a later success applies the same local effects as an immediate one), and the oRPC surface (`agent/create`, `agent/list`, `agent/{id}`, `agent/intents/prepare|submit|status`, `agent/{id}/grants`) which exposes the domain to REST and MCP through the host's OpenAPI mounts. Owner intents follow the BFF split — prepare returns `intent.payload` + preview, the browser signs via better-near-auth's `signAgentIntent`, submit carries the wallet's output. Dormant until `NEAR_INTENTS_AGENT_API_KEY` is configured; own `plugin_agent_connect` schema (`AGENT_CONNECT_DATABASE_URL`, pglite default); `AGENT_CONNECT_ENCRYPTION_KEY` isolates the vault key. diff --git a/plugins/agent-connect/README.md b/plugins/agent-connect/README.md index ee7e40011..51fda6171 100644 --- a/plugins/agent-connect/README.md +++ b/plugins/agent-connect/README.md @@ -30,7 +30,15 @@ The `naa_` partner key and `ngt_` grant tokens never reach a browser. 3. Intents are persisted **before** the send (status `PENDING_SIGNATURE`, 15-minute pre-send TTL) and linked to the real correlation id after; a failed link asks the caller to retry, which reuses the same idempotency key. -4. Status updates from submit/poll are best-effort; ledger link failures are fatal. +4. Idempotent prepare: `agentCreate`/`prepareIntent` accept an optional client + `idempotencyKey` (8–128 chars, `[A-Za-z0-9._:-]`) — the same key with the same + request replays the stored intent (or resends the exact stored request body under + the same key), and the same key with a different request type conflicts. Upstream + still enforces body equality (`409 idempotency_conflict`). +5. Submit and status polling share one reconciliation path: a `SUCCESS` observed + later applies the same local effects as an immediate one (grant id linked, revoke + marked, agent row activated). Ledger writes are best-effort; the intent-link + failure is fatal. ## Configuration diff --git a/plugins/agent-connect/src/contract.ts b/plugins/agent-connect/src/contract.ts index 432b110f8..261886565 100644 --- a/plugins/agent-connect/src/contract.ts +++ b/plugins/agent-connect/src/contract.ts @@ -1,4 +1,11 @@ import "@orpc/openapi/extensions/route"; +import type { + AgentPage, + AgentView, + GenerateIntentResponse, + GrantView, + StatusResponse, +} from "@near-intents-agent-api/sdk"; import { oc } from "@orpc/contract"; import { z } from "zod"; @@ -54,67 +61,34 @@ const IdempotencyKeySchema = z ); /** - * The browser-visible intent envelope: `intent.payload` + `preview` are all a - * browser ever sees — never the `naa_` partner key or an `ngt_` grant token. - * Fields beyond the typed subset pass through unwrapped. + * Output envelopes are the SDK's own types (`z.custom`) — the hosted Agent + * API (via `@near-intents-agent-api/sdk`) is the source of truth for every + * domain shape; re-declaring them here would drift. The type-level test + * asserts the generated client drives `signAgentIntent` and the UI without + * casts. The browser sees `intent.payload` + `preview` only — never the + * `naa_` partner key or an `ngt_` grant token. */ -const GeneratedIntentSchema = z.looseObject({ - correlation_id: z.string().describe("Tracking id of the generated owner intent"), - agent_id: z - .string() - .nullable() - .optional() - .describe("The agent account id — agent_create reserves it and returns it immediately"), - type: z.string().describe("Intent type, e.g. agent_create, grant_issue, grant_revoke"), - status: z.string().describe("Intent status, e.g. PENDING_SIGNATURE"), - expires_at: z.string().describe("ISO datetime after which the intent can no longer be signed"), - idempotencyKey: z.string().optional().describe("The idempotency key used for this intent"), - intent: z.looseObject({ - standard: z.string().describe("Signing standard: nep413 | nep366 | eip712 | webauthn"), - payload: z.record(z.string(), z.unknown()).describe("The exact bytes the owner's wallet signs"), - }), - signer: z - .looseObject({ type: z.string() }) - .nullable() - .optional() - .describe("The owner wallet that must sign this intent"), - preview: z - .unknown() - .optional() - .describe("Human-readable summary shown to the owner before signing"), -}); - -const StatusSchema = z.looseObject({ - correlation_id: z.string().describe("Tracking id of the intent"), - agent_id: z.string().nullable().optional().describe("The agent account the intent acts on"), - status: z - .string() - .describe("SUCCESS | FAILED | PENDING_SIGNATURE | UNCERTAIN | NEEDS_REVIEW | …"), - type: z.string().optional().describe("Intent type"), - details: z.unknown().optional().describe("Intent-type-specific result details"), -}); +const GeneratedIntentSchema = z + .custom() + .describe( + "Generated owner intent: correlation_id, agent_id, type, status (PENDING_SIGNATURE), expires_at, signer, intent { standard, payload }, preview, idempotencyKey", + ); -const AgentViewSchema = z.looseObject({ - id: z.string().describe("Agent account id"), - name: z.string().optional().describe("Display name"), - status: z.string().optional().describe("Agent status, e.g. PENDING | ACTIVE | FROZEN"), -}); +const StatusSchema = z + .custom() + .describe( + "Intent/operation status: correlation_id, agent_id, type, status, failure_code, details (shape varies by type)", + ); -const AgentPageSchema = z.looseObject({ - data: z.array(AgentViewSchema).describe("Agent views"), - next_cursor: z.string().nullable().optional().describe("Cursor for the next page"), -}); +const AgentPageSchema = z + .custom() + .describe("Page of agent views: { data, next_cursor }"); -const GrantViewSchema = z.looseObject({ - grant_id: z.string().describe("Grant id (64 hex)"), - label: z.string().optional().describe("Grant label"), - expires_at: z.string().optional().describe("Grant expiry"), - revoked_at: z.string().nullable().optional().describe("When the grant was revoked, if it was"), -}); +const AgentViewSchema = z.custom().describe("Agent account view (SDK AgentView)"); -const GrantsSchema = z.object({ - data: z.array(GrantViewSchema).describe("Grant views for the agent account"), -}); +const GrantsSchema = z + .object({ data: z.array(z.custom()) }) + .describe("Grant views for the agent account (revoked included)"); const PrepareIntentInputSchema = z.discriminatedUnion("type", [ z.object({ diff --git a/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json index cb35451b6..1ee797669 100644 --- a/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json +++ b/plugins/agent-connect/src/db/migrations/meta/0000_snapshot.json @@ -373,4 +373,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/plugins/agent-connect/src/db/migrations/meta/_journal.json b/plugins/agent-connect/src/db/migrations/meta/_journal.json index 582098d75..c47d811e7 100644 --- a/plugins/agent-connect/src/db/migrations/meta/_journal.json +++ b/plugins/agent-connect/src/db/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/plugins/agent-connect/src/index.ts b/plugins/agent-connect/src/index.ts index 98aa63f67..358dd4456 100644 --- a/plugins/agent-connect/src/index.ts +++ b/plugins/agent-connect/src/index.ts @@ -12,9 +12,19 @@ import { z } from "zod"; import { contract } from "./contract"; import { DatabaseLive } from "./db/layer"; import { ContextSchema } from "./lib/context"; -import { AgentApiClient, type AgentApiClientShape, type AgentApiServiceError, agentApiClientLayer } from "./services/agent-api"; -import { agentStoreLayer, AgentStore, type AgentStoreShape, type OwnerIntentRow } from "./services/store"; -import { grantVaultLayer, GrantVault } from "./services/vault"; +import { + AgentApiClient, + type AgentApiClientShape, + type AgentApiServiceError, + agentApiClientLayer, +} from "./services/agent-api"; +import { + AgentStore, + type AgentStoreShape, + agentStoreLayer, + type OwnerIntentRow, +} from "./services/store"; +import { GrantVault, grantVaultLayer } from "./services/vault"; /** * Agent Connect — the control plane giving every signed-in user an @@ -289,22 +299,26 @@ export default createPlugin({ } } if (intentRow.intentType === "grant_revoke" && intentRow.grantRowId) { - yield* store.markGrantRevoked(intentRow.grantRowId).pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to mark grant revoked:", error).pipe( - Effect.asVoid, + yield* store + .markGrantRevoked(intentRow.grantRowId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to mark grant revoked:", error).pipe( + Effect.asVoid, + ), ), - ), - ); + ); } if (intentRow.intentType === "agent_create" && intentRow.agentId) { - yield* store.activateAgent(userId, intentRow.agentId).pipe( - Effect.catch((error) => - Effect.logWarning("[agent-connect] failed to activate agent row:", error).pipe( - Effect.asVoid, + yield* store + .activateAgent(userId, intentRow.agentId) + .pipe( + Effect.catch((error) => + Effect.logWarning("[agent-connect] failed to activate agent row:", error).pipe( + Effect.asVoid, + ), ), - ), - ); + ); } }); diff --git a/plugins/agent-connect/src/services/store.ts b/plugins/agent-connect/src/services/store.ts index be35db7aa..7ac6ba815 100644 --- a/plugins/agent-connect/src/services/store.ts +++ b/plugins/agent-connect/src/services/store.ts @@ -118,6 +118,7 @@ export interface AgentStoreShape { }): Effect.Effect; linkGrantId(rowId: string, grantId: string): Effect.Effect; markGrantRevoked(rowId: string): Effect.Effect; + findGrantById(rowId: string): Effect.Effect; findGrantByUser( userId: string, agentId: string, @@ -207,10 +208,7 @@ export const agentStoreLayer = Layer.effect( .select() .from(ownerIntent) .where( - and( - eq(ownerIntent.idempotencyKey, idempotencyKey), - eq(ownerIntent.userId, userId), - ), + and(eq(ownerIntent.idempotencyKey, idempotencyKey), eq(ownerIntent.userId, userId)), ) .limit(1) .then((rows) => rows[0] ?? null)) as OwnerIntentRow | null, @@ -251,6 +249,17 @@ export const agentStoreLayer = Layer.effect( }, catch: (cause) => storeError("Failed to mark grant revoked", cause), }), + findGrantById: (rowId) => + Effect.tryPromise({ + try: async () => + (await db + .select() + .from(agentGrant) + .where(eq(agentGrant.id, rowId)) + .limit(1) + .then((rows) => rows[0] ?? null)) as AgentGrantRow | null, + catch: (cause) => storeError("Failed to load grant", cause), + }), findGrantByUser: (userId, agentId, grantId) => Effect.tryPromise({ try: async () => diff --git a/plugins/agent-connect/tests/integration/agent-connect.test.ts b/plugins/agent-connect/tests/integration/agent-connect.test.ts index f9f6b7b80..bf6e3ec24 100644 --- a/plugins/agent-connect/tests/integration/agent-connect.test.ts +++ b/plugins/agent-connect/tests/integration/agent-connect.test.ts @@ -1,10 +1,21 @@ import type { ContractRouterClient } from "@orpc/contract"; +import { Context, Effect } from "effect"; import { describe, expect, it, vi } from "vitest"; import type { contract } from "@/contract"; +import { AgentStore } from "@/services/store"; const MOCK_ACCOUNT_ID = "test.near"; const MOCK_PUBLIC_KEY = "ed25519:abcdefghijklmnopqrstuvwxyz0123456789ABCD"; +const state = vi.hoisted(() => ({ + /** Status returned by the mocked submitIntent call. */ + submitStatus: "SUCCESS", + /** Status returned by the mocked getStatus call (polling). */ + pollStatus: "SUCCESS", + /** How many times the mock's generateIntent was invoked. */ + generateCalls: 0, +})); + const TEST_POLICY = { frozen: false, actions: ["swap", "transfer", "withdraw"], @@ -18,13 +29,22 @@ const TEST_POLICY = { timelock_ms: 0, }; +const SIGNED_DATA = { + standard: "nep413", + payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, + public_key: MOCK_PUBLIC_KEY, + signature: "sig", +}; + /** * In-memory stand-in for the hosted Agent API — the SDK module is mocked so * the plugin's control-plane invariants are tested against the same - * generate/submit/status surface without network. + * generate/submit/status surface without network. `state` drives the + * non-terminal/later-success paths. */ function makeAgentApiStub() { const intents: Array<{ type: string; agent_id?: string }> = []; + const intentTypeByCorrelationId = new Map(); return class AgentApiMock { constructor(options?: { apiKey?: string }) { if (!options?.apiKey) throw new Error("apiKey required"); @@ -33,15 +53,19 @@ function makeAgentApiStub() { request: { type: string; agent_id?: string }, options?: { idempotencyKey?: string }, ) { + state.generateCalls += 1; intents.push(request); + const correlationId = `intent_${intents.length}`; + intentTypeByCorrelationId.set(correlationId, request.type); const agentId = "agent_id" in request && request.agent_id ? request.agent_id : "ag_test1"; return Promise.resolve({ - correlation_id: `intent_${intents.length}`, + correlation_id: correlationId, type: request.type, agent_id: agentId, status: "PENDING_SIGNATURE", expires_at: new Date(Date.now() + 600_000).toISOString(), signer: { type: "near", account_id: MOCK_ACCOUNT_ID, public_key: MOCK_PUBLIC_KEY }, + idempotencyKey: options?.idempotencyKey, intent: { standard: "nep413", payload: { @@ -54,11 +78,13 @@ function makeAgentApiStub() { }); } submitIntent(request: { type: string }) { - const details = request.type === "grant_issue" ? { grant: { grant_id: "64hexgrantid" } } : {}; + const settled = state.submitStatus === "SUCCESS"; + const details = + request.type === "grant_issue" && settled ? { grant: { grant_id: "64hexgrantid" } } : {}; return Promise.resolve({ correlation_id: "intent_1", agent_id: "ag_test1", - status: "SUCCESS", + status: state.submitStatus, failure_code: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -69,17 +95,24 @@ function makeAgentApiStub() { }); } getStatus(correlationId: string) { + const settled = state.pollStatus === "SUCCESS"; + const intentType = intentTypeByCorrelationId.get(correlationId) ?? "agent_create"; + const details = !settled + ? {} + : intentType === "grant_issue" + ? { grant: { grant_id: "64hexgrantid" } } + : { agent_id: "ag_test1", revision: 1, policy_hash: "hash", transaction_hash: null }; return Promise.resolve({ correlation_id: correlationId, agent_id: "ag_test1", - status: "SUCCESS", + status: state.pollStatus, failure_code: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), dispatch_committed_at: null, grant: null, - type: "agent_create", - details: { agent_id: "ag_test1", revision: 1, policy_hash: "hash", transaction_hash: null }, + type: intentType, + details, }); } listAgents() { @@ -102,7 +135,7 @@ vi.mock("@near-intents-agent-api/sdk", async (importOriginal) => { }; }); -const { getPluginClient, runtime } = await import("../setup"); +const { getPluginClient, getPluginEffectContext, runtime } = await import("../setup"); const { createPluginRuntime } = await import("every-plugin"); const Plugin = (await import("@/index")).default; @@ -139,12 +172,7 @@ describe("agent connect routes", () => { client.intentSubmit({ type: "agent_create", correlationId: "intent_not_issued", - signedData: { - standard: "nep413", - payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, - public_key: MOCK_PUBLIC_KEY, - signature: "sig", - }, + signedData: SIGNED_DATA, }), ).rejects.toThrow(/Unknown correlation id/); }); @@ -157,12 +185,7 @@ describe("agent connect routes", () => { client.intentSubmit({ type: "grant_revoke", correlationId: created.correlation_id, - signedData: { - standard: "nep413", - payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, - public_key: MOCK_PUBLIC_KEY, - signature: "sig", - }, + signedData: SIGNED_DATA, }), ).rejects.toThrow(/does not match the issued correlation id/); }); @@ -197,18 +220,111 @@ describe("agent connect routes", () => { const status = await client.intentSubmit({ type: "agent_create", correlationId: created.correlation_id, - signedData: { - standard: "nep413", - payload: { message: "m", recipient: "r", nonce: "dGVzdA==" }, - public_key: MOCK_PUBLIC_KEY, - signature: "sig", - }, + signedData: SIGNED_DATA, }); expect(status.status).toBe("SUCCESS"); const view = await client.agentGet({ agentId: "ag_test1" }); expect(view.id).toBe("ag_test1"); }); + + it("reuses the prepared intent for the same idempotency key", async () => { + const client = await getPluginClient({ userId: "user1" }); + const first = await client.agentCreate({ + name: "my agent", + policy: TEST_POLICY, + idempotencyKey: "retry-key-1234", + }); + const callsAfterFirst = state.generateCalls; + + const second = await client.agentCreate({ + name: "my agent", + policy: TEST_POLICY, + idempotencyKey: "retry-key-1234", + }); + + expect(second.correlation_id).toBe(first.correlation_id); + expect(state.generateCalls).toBe(callsAfterFirst); + }); + + it("conflicts when the same idempotency key is reused for a different request type", async () => { + const client = await getPluginClient({ userId: "user1" }); + await client.agentCreate({ + name: "my agent", + policy: TEST_POLICY, + idempotencyKey: "retry-key-1234", + }); + + await expect( + client.prepareIntent({ + type: "grant_issue", + agentId: "ag_test1", + label: "l", + idempotencyKey: "retry-key-1234", + }), + ).rejects.toThrow(/different request/); + }); + + it("reconciles local state when polling reveals a later success", async () => { + state.submitStatus = "PROCESSING"; + state.pollStatus = "SUCCESS"; + try { + const client = await getPluginClient({ userId: "user1" }); + await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + const grantIntent = await client.prepareIntent({ + type: "grant_issue", + agentId: "ag_test1", + label: "assistant-session-42", + }); + await client.intentSubmit({ + type: "grant_issue", + correlationId: grantIntent.correlation_id, + signedData: SIGNED_DATA, + }); + + const store = Context.get(getPluginEffectContext(), AgentStore); + const intentRow = await Effect.runPromise( + store.findIntentByUser("user1", grantIntent.correlation_id), + ); + const grantRowBefore = await Effect.runPromise( + intentRow?.grantRowId ? store.findGrantById(intentRow.grantRowId) : Effect.succeed(null), + ); + expect(grantRowBefore?.grantId).toBeNull(); + + await client.intentStatus({ correlationId: grantIntent.correlation_id }); + + const grantRowAfter = await Effect.runPromise( + intentRow?.grantRowId ? store.findGrantById(intentRow.grantRowId) : Effect.succeed(null), + ); + expect(grantRowAfter?.grantId).toBe("64hexgrantid"); + } finally { + state.submitStatus = "SUCCESS"; + state.pollStatus = "SUCCESS"; + } + }); + + it("activates the agent row when polling reveals a later success", async () => { + state.submitStatus = "PROCESSING"; + state.pollStatus = "SUCCESS"; + try { + const client = await getPluginClient({ userId: "user1" }); + const created = await client.agentCreate({ name: "my agent", policy: TEST_POLICY }); + + await client.intentSubmit({ + type: "agent_create", + correlationId: created.correlation_id, + signedData: SIGNED_DATA, + }); + + const store = Context.get(getPluginEffectContext(), AgentStore); + const agentRow = await Effect.runPromise(store.findAgentByUser("user1", "ag_test1")); + expect(agentRow?.status).toBe("ACTIVE"); + } finally { + state.submitStatus = "SUCCESS"; + state.pollStatus = "SUCCESS"; + } + }); }); describe("dormant plugin", () => { diff --git a/plugins/agent-connect/tests/setup.ts b/plugins/agent-connect/tests/setup.ts index adefc9d06..3ce2a8385 100644 --- a/plugins/agent-connect/tests/setup.ts +++ b/plugins/agent-connect/tests/setup.ts @@ -3,6 +3,8 @@ import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; import type { ContractRouterClient } from "@orpc/contract"; import { RPCHandler } from "@orpc/server/node"; +import type { Context } from "effect"; + import { createPluginRuntime } from "every-plugin"; import type { contract } from "@/contract"; import Plugin from "@/index"; @@ -27,6 +29,18 @@ export const runtime = createPluginRuntime({ let server: ReturnType | null = null; let baseUrl = ""; let port = 0; +let pluginEffectContext: Context.Context | null = null; + +/** + * The plugin's initialized Effect context — services (AgentStore, …) can be + * read from it in tests via `Context.get`. + */ +export function getPluginEffectContext(): Context.Context { + if (!pluginEffectContext) { + throw new Error("getPluginClient() must run before getPluginEffectContext()"); + } + return pluginEffectContext; +} export async function getPluginClient(context?: { userId?: string; @@ -37,6 +51,7 @@ export async function getPluginClient(context?: { const { router, initialized } = await runtime.usePlugin(TEST_PLUGIN_ID, TEST_CONFIG); const rpcHandler = new RPCHandler(router); const effectContext = initialized.effectContext; + pluginEffectContext = effectContext; // Find an available port const testPort = 3000 + Math.floor(Math.random() * 1000); diff --git a/plugins/agent-connect/tests/unit/contract-types.test.ts b/plugins/agent-connect/tests/unit/contract-types.test.ts new file mode 100644 index 000000000..fc6a9c4c0 --- /dev/null +++ b/plugins/agent-connect/tests/unit/contract-types.test.ts @@ -0,0 +1,24 @@ +import type { GenerateIntentResponse, StatusResponse } from "@near-intents-agent-api/sdk"; +import type { ContractRouterClient } from "@orpc/contract"; +import { describe, expectTypeOf, it } from "vitest"; +import type { contract } from "@/contract"; + +/** + * Type-level drift gate: the generated oRPC client's outputs must be the + * SDK's own types, so the wallet-signing helper (`signAgentIntent`) and the + * upcoming UI consume route responses without casts. + */ +describe("contract type fidelity", () => { + type Client = ContractRouterClient; + + it("agentCreate/prepareIntent return the SDK's GenerateIntentResponse", () => { + type Prepared = Awaited>; + expectTypeOf().toExtend(); + expectTypeOf>>().toExtend(); + }); + + it("intentSubmit/intentStatus return the SDK's StatusResponse", () => { + expectTypeOf>>().toExtend(); + expectTypeOf>>().toExtend(); + }); +}); diff --git a/plugins/agent-connect/types/contract.d.ts b/plugins/agent-connect/types/contract.d.ts index faf8183dd..1cb845c0d 100644 --- a/plugins/agent-connect/types/contract.d.ts +++ b/plugins/agent-connect/types/contract.d.ts @@ -4,23 +4,355 @@ export declare const contract: { agentCreate: import("@orpc/contract").ProcedureContract; - }, z.core.$strip>, z.ZodObject<{ - correlation_id: z.ZodString; - type: z.ZodString; - status: z.ZodString; - expires_at: z.ZodString; - intent: z.ZodObject<{ - standard: z.ZodString; - payload: z.ZodRecord; - }, z.core.$loose>; - preview: z.ZodOptional; - }, z.core.$loose>, { + idempotencyKey: z.ZodOptional; + }, z.core.$strip>, z.ZodCustom<{ + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }, { + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }>, { UNAUTHORIZED: { message: string; }; BAD_REQUEST: { message: string; }; + CONFLICT: { + message: string; + }; SERVICE_UNAVAILABLE: { message: string; }; @@ -34,10 +366,87 @@ export declare const contract: { message: string; }; }>; - agentList: import("@orpc/contract").ProcedureContract; - next_cursor: z.ZodOptional>; - }, z.core.$loose>, { + agentList: import("@orpc/contract").ProcedureContract, { UNAUTHORIZED: { message: string; }; @@ -62,9 +471,81 @@ export declare const contract: { }>; agentGet: import("@orpc/contract").ProcedureContract, z.ZodObject<{ - id: z.ZodString; - }, z.core.$loose>, { + }, z.core.$strip>, z.ZodCustom<{ + id: string; + name: string; + external_user_id: string | null; + status: "PENDING" | "ACTIVE" | "ARCHIVED" | "DELETED" | "ABANDONED"; + archived: boolean; + deleted: boolean; + owner: ({ + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }) | null; + owner_account: { + account_id: string; + public_key: string; + authority: "wallet"; + } | null; + wallet: { + wallet_id: string; + near_account_id: string; + } | null; + created_at: string; + cooldowns: { + policy_change_available_at: string | null; + unfreeze_available_at: string | null; + }; + }, { + id: string; + name: string; + external_user_id: string | null; + status: "PENDING" | "ACTIVE" | "ARCHIVED" | "DELETED" | "ABANDONED"; + archived: boolean; + deleted: boolean; + owner: ({ + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }) | null; + owner_account: { + account_id: string; + public_key: string; + authority: "wallet"; + } | null; + wallet: { + wallet_id: string; + near_account_id: string; + } | null; + created_at: string; + cooldowns: { + policy_change_available_at: string | null; + unfreeze_available_at: string | null; + }; + }>, { UNAUTHORIZED: { message: string; }; @@ -94,26 +575,357 @@ export declare const contract: { type: z.ZodLiteral<"agent_create">; name: z.ZodString; policy: z.ZodRecord; + idempotencyKey: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ type: z.ZodLiteral<"grant_issue">; agentId: z.ZodString; label: z.ZodString; expiresAt: z.ZodOptional; + idempotencyKey: z.ZodOptional; }, z.core.$strip>, z.ZodObject<{ type: z.ZodLiteral<"grant_revoke">; agentId: z.ZodString; grantId: z.ZodString; - }, z.core.$strip>], "type">, z.ZodObject<{ - correlation_id: z.ZodString; - type: z.ZodString; - status: z.ZodString; - expires_at: z.ZodString; - intent: z.ZodObject<{ - standard: z.ZodString; - payload: z.ZodRecord; - }, z.core.$loose>; - preview: z.ZodOptional; - }, z.core.$loose>, { + idempotencyKey: z.ZodOptional; + }, z.core.$strip>], "type">, z.ZodCustom<{ + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }, { + correlation_id: string; + type: "agent_create" | "policy_update" | "agent_freeze" | "agent_unfreeze" | "grant_issue" | "grant_revoke" | "execution_cancel" | "agent_archive" | "agent_restore" | "agent_delete" | "approval_vote"; + agent_id: string; + status: "PENDING_SIGNATURE"; + expires_at: string; + signer: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + intent: { + standard: "nep413"; + payload: { + message: string; + recipient: string; + nonce: string; + }; + } | { + standard: "nep366"; + payload: { + receiverId: string; + actions: { + type: "FunctionCall"; + params: { + methodName: string; + args: { + [key: string]: unknown; + }; + gas: string; + deposit: string; + }; + }[]; + }; + } | { + standard: "eip712"; + payload: { + domain: { + [key: string]: unknown; + }; + types: { + [key: string]: { + name: string; + type: string; + }[]; + }; + primaryType: string; + message: { + [key: string]: unknown; + }; + }; + } | { + standard: "webauthn"; + payload: { + challenge: string; + rpId: string; + allowCredentials: { + id: string; + type: "public-key"; + }[]; + userVerification: "required"; + timeout: number; + }; + }; + preview: { + summary: string; + revision?: number; + previous_revision?: number; + policy_hash?: string; + policy?: { + frozen: boolean; + actions: ("swap" | "transfer" | "withdraw")[]; + confidential: boolean; + owner_approval: boolean; + assets: "any" | string[]; + limits: { + per_transaction?: { + [key: string]: string; + }; + hourly?: { + [key: string]: string; + }; + daily?: { + [key: string]: string; + }; + monthly?: { + [key: string]: string; + }; + }; + max_actions_per_hour: number | null; + destinations: { + mode: "any" | "only" | "except"; + list: ({ + action: "withdraw"; + chain: string; + address: string; + memo: string | null; + } | { + action: "transfer"; + address: string; + confidential: boolean; + })[]; + }; + budget: { + daily_usd: string | null; + weekly_usd: string | null; + monthly_usd: string | null; + }; + timelock_ms: number; + schedule?: { + mode: "only" | "except"; + time_zone: string; + windows: { + days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[]; + start: string; + end: string; + }[]; + }; + sign?: { + recipients: string[]; + }; + }; + deletion?: { + near_account_id: string; + beneficiary: string; + native_balance: string; + public: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + confidential: { + asset_id: string; + symbol: string | null; + decimals: number | null; + blockchain: string | null; + price: number | null; + price_updated_at: string | null; + price_expires_at: string | null; + balance_raw: string; + balance: string | null; + }[]; + assets_lost: boolean; + retirement: "provider_delete" | "credential_erase"; + }; + approval?: { + approval_id: string; + request_type: string; + request_hash: string; + verdict: "approve" | "reject"; + }; + }; + }>, { UNAUTHORIZED: { message: string; }; @@ -123,6 +935,9 @@ export declare const contract: { BAD_REQUEST: { message: string; }; + CONFLICT: { + message: string; + }; SERVICE_UNAVAILABLE: { message: string; }; @@ -144,12 +959,963 @@ export declare const contract: { }>; correlationId: z.ZodString; signedData: z.ZodRecord; - }, z.core.$strip>, z.ZodObject<{ - correlation_id: z.ZodString; - status: z.ZodString; - type: z.ZodOptional; - details: z.ZodOptional; - }, z.core.$loose>, { + }, z.core.$strip>, z.ZodCustom<{ + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }, { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }>, { UNAUTHORIZED: { message: string; }; @@ -159,6 +1925,9 @@ export declare const contract: { BAD_REQUEST: { message: string; }; + CONFLICT: { + message: string; + }; SERVICE_UNAVAILABLE: { message: string; }; @@ -175,12 +1944,963 @@ export declare const contract: { intentStatus: import("@orpc/contract").ProcedureContract; - }, z.core.$strip>, z.ZodObject<{ - correlation_id: z.ZodString; - status: z.ZodString; - type: z.ZodOptional; - details: z.ZodOptional; - }, z.core.$loose>, { + }, z.core.$strip>, z.ZodCustom<{ + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }, { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_create"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + agent_id: string; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "policy_update"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_freeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_unfreeze"; + details: { + revision: number | null; + policy_hash: string | null; + transaction_hash: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_issue"; + details: { + grant: { + grant_id: string; + agent_id: string; + wallet_id: string; + label: string; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revoked_reason: string | null; + owner_epoch: number; + owner_message: { + domain: "near-intents-agent-api.agent-grant.v6"; + owner: { + type: "near"; + account_id: string; + public_key: string; + } | { + type: "evm"; + address: string; + chain_id: number; + public_key: string; + } | { + type: "passkey"; + credential_id: string; + public_key: string; + rp_id: string; + origin: string; + }; + tenant_id: string; + agent_id: string; + wallet_id: string; + label: string; + credential: string; + network: "mainnet"; + issued_at_ms: number; + expires_at_ms: number; + owner_epoch: number; + nonce: string; + recipient: string; + }; + } | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "grant_revoke"; + details: { + grant_id: string | null; + committed_correlation_ids: string[] | null; + committed_truncated: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "execution_cancel"; + details: { + cancelled_correlation_id: string | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_archive"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_restore"; + details: { + archived: boolean | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "agent_delete"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "approval_vote"; + details: { + approval_id: string | null; + verdict: ("approve" | "reject") | null; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "swap"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "withdraw"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "transfer"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "shield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "unshield"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + } | { + correlation_id: string; + agent_id: string | null; + status: "PENDING_SIGNATURE" | "QUEUED" | "PENDING_APPROVAL" | "PENDING_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED" | "UNCERTAIN" | "NEEDS_REVIEW"; + failure_code: string | null; + created_at: string; + updated_at: string; + dispatch_committed_at: string | null; + grant: { + id: string; + label: string; + } | null; + type: "deposit"; + details: { + action: string; + near_account_id?: string; + provider_request_id?: string | null; + provider_status?: string; + confidential?: boolean; + chain?: string; + execute_after?: string; + delay_ms?: number; + approval_id?: string | null; + intent_hash?: string | null; + tx_hash?: string | null; + destination_tx_hash?: string | null; + amount_out?: string | null; + deposit_address?: string | null; + memo?: string | null; + min_amount?: string | null; + min_amount_out?: string | null; + expires_at?: string | null; + refund_to?: string | null; + refund_tx_hash?: string | null; + failure_reason?: string | null; + } & { + [key: string]: unknown; + }; + }>, { UNAUTHORIZED: { message: string; }; @@ -203,7 +2923,87 @@ export declare const contract: { grantList: import("@orpc/contract").ProcedureContract, z.ZodObject<{ - data: z.ZodArray; + data: z.ZodArray>; }, z.core.$strip>, { UNAUTHORIZED: { message: string; From ffd8f012be749ffadbabad69c458987509482606 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 16 Oct 2026 18:01:53 -0500 Subject: [PATCH 20/20] fix(regression): map AGENT_CONNECT_DATABASE_URL to the test database The regression env guard refused to run: the new plugin's database secret was absent from the committed .env.test, so it resolved to the dev database (localhost:5432) and the stack preflight probed a port with nothing listening. Map it to the shared test api database, matching every other plugin *_DATABASE_URL and byte-for-byte what bos dev regenerates. Verified locally: regression HTTP dev (20/20) and start (20/20) green with the plugin booting in the stack. --- .env.test | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.test b/.env.test index b0d6f72e4..85705889e 100644 --- a/.env.test +++ b/.env.test @@ -17,3 +17,6 @@ PROPOSALS_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/api # plugins.votes VOTES_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/api_test_db +# plugins.agent-connect +AGENT_CONNECT_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/api_test_db +