@oneie/sdk 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/receivers.d.ts +934 -8
- package/dist/receivers.d.ts.map +1 -1
- package/dist/receivers.js +1549 -20
- package/dist/receivers.js.map +1 -1
- package/dist/vault-file.d.ts +20 -0
- package/dist/vault-file.d.ts.map +1 -1
- package/dist/vault-file.js +30 -2
- package/dist/vault-file.js.map +1 -1
- package/dist/wallet.d.ts +55 -5
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +116 -9
- package/dist/wallet.js.map +1 -1
- package/package.json +2 -1
package/dist/receivers.js
CHANGED
|
@@ -589,6 +589,278 @@ export const RECEIVERS = {
|
|
|
589
589
|
version: "1.0.0",
|
|
590
590
|
examples: [{ head: "feat/x", base: "main", title: "feat: x" }],
|
|
591
591
|
}),
|
|
592
|
+
// ── directory:* — the curated business-directory registry and the submission
|
|
593
|
+
// run over it. 306 directories, an operator board and a Chrome autofill skill
|
|
594
|
+
// shipped 2026-07-19 REACHABLE ONLY AS REST: `meta:catalog` listed zero
|
|
595
|
+
// `directory:*` names, so no agent could discover the capability or drive it,
|
|
596
|
+
// and a collaborator rebuilt a worse version from scratch rather than find this
|
|
597
|
+
// one. By-name reachability is the feature these three rows buy.
|
|
598
|
+
//
|
|
599
|
+
// `directory:list` is `public` because the registry IS public — every field
|
|
600
|
+
// ships in the client bundle already. `run` and `update` write tenant rows, so
|
|
601
|
+
// they carry `manage_integrations`, which AUTH_POLICY resolves to the `tenant`
|
|
602
|
+
// floor. That label is chosen from the existing table on purpose: an unlisted
|
|
603
|
+
// label falls through to `authenticated` (bind-receiver.ts), so inventing
|
|
604
|
+
// `manage_directories` would have shipped these WEAKER and no test would have
|
|
605
|
+
// said so. The object of the decision is the WORKSPACE, resolved from the
|
|
606
|
+
// attested `ctx.ownerSlug`, never from a body `actorId`.
|
|
607
|
+
"directory:list": receiver({
|
|
608
|
+
receiver: "directory:list",
|
|
609
|
+
summary: "List the curated business-directory registry (306 entries) — read-only public data, filterable by category, region and paid",
|
|
610
|
+
request: z.object({
|
|
611
|
+
category: z.string().optional().describe("Exact category: general | review | b2b | startup | ai-tools | vertical"),
|
|
612
|
+
region: z.string().optional().describe("Exact region key: global | us | ie | gb | ca | au | in"),
|
|
613
|
+
paid: z.boolean().optional().describe("true = only directories whose LISTING is paid-only; false = only free ones. A free tier that merely upsells is paid:false. Omit for both."),
|
|
614
|
+
}),
|
|
615
|
+
response: z.object({
|
|
616
|
+
ok: z.literal(true),
|
|
617
|
+
directories: z.array(z.object({
|
|
618
|
+
slug: z.string().describe("Stable kebab-case id — this is what directory:run takes"),
|
|
619
|
+
name: z.string(),
|
|
620
|
+
category: z.string(),
|
|
621
|
+
region: z.string(),
|
|
622
|
+
url: z.string(),
|
|
623
|
+
submitUrl: z.string().describe("The public submission / claim / add-listing URL a human or an autofill skill drives"),
|
|
624
|
+
method: z.string().optional().describe("form | email | api | claim; absent means form"),
|
|
625
|
+
da: z.number().optional().describe("Approximate domain authority 0-100, for ranking only — NOT authoritative"),
|
|
626
|
+
paid: z.boolean(),
|
|
627
|
+
fields: z.array(z.object({ key: z.string(), label: z.string(), type: z.string(), required: z.boolean() })),
|
|
628
|
+
notes: z.string().optional(),
|
|
629
|
+
})),
|
|
630
|
+
total: z.number().describe("Rows returned. UNCAPPED — total always equals directories.length; no budget bit, so there is no truncated field to read."),
|
|
631
|
+
}),
|
|
632
|
+
effect: "ask",
|
|
633
|
+
auth: "public",
|
|
634
|
+
cost: "free",
|
|
635
|
+
idempotent: true,
|
|
636
|
+
reversible: true,
|
|
637
|
+
settles: "none",
|
|
638
|
+
version: "1.0.0",
|
|
639
|
+
examples: [{ category: "review", paid: false }],
|
|
640
|
+
}),
|
|
641
|
+
// A run is NOT idempotent: each call mints a fresh runId. What makes a repeat
|
|
642
|
+
// SAFE is the cross-run duplicate guard — a directory already `submitted` or
|
|
643
|
+
// `live` for this workspace is never re-queued, and every one it drops is named
|
|
644
|
+
// in `skipped`. Before that guard, a second run over the same picker selection
|
|
645
|
+
// re-queued everything and the board (which loads only the latest run) hid it.
|
|
646
|
+
"directory:run": receiver({
|
|
647
|
+
receiver: "directory:run",
|
|
648
|
+
summary: "Start a submission run over selected directories for a workspace — freezes a submission pack per row; directories already submitted or live are SKIPPED and named, never silently re-queued",
|
|
649
|
+
request: z.object({
|
|
650
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's. Authority is derived from the attested context, never from this field alone — a slug you do not control is refused."),
|
|
651
|
+
directories: z.array(z.string()).min(1).describe("Directory slugs from directory:list. Repeats within one call are collapsed. An unknown slug refuses the WHOLE run (unknown_directory) rather than filing a partial one."),
|
|
652
|
+
}),
|
|
653
|
+
response: z.object({
|
|
654
|
+
ok: z.boolean(),
|
|
655
|
+
runId: z.string().optional().describe("Present only on success. Absent on all_duplicates — no empty run is minted, because an empty run would become the board's latest and blank it."),
|
|
656
|
+
count: z.number().optional().describe("Rows actually queued = directories.length - skipped.length"),
|
|
657
|
+
skipped: z.array(z.object({
|
|
658
|
+
directorySlug: z.string(),
|
|
659
|
+
status: z.string().describe("submitted | live — why it was skipped"),
|
|
660
|
+
runId: z.string().describe("The earlier run that already carries it"),
|
|
661
|
+
})).optional().describe("ALWAYS present on success, empty array included — never infer 'nothing was dropped' from a missing key. Also carried on an all_duplicates refusal, naming every one."),
|
|
662
|
+
unknown: z.array(z.string()).optional().describe("The slugs that are not in the registry, on an unknown_directory refusal"),
|
|
663
|
+
error: z.string().optional().describe("no_db | workspace required | forbidden | directories required | unknown_directory | all_duplicates"),
|
|
664
|
+
}),
|
|
665
|
+
effect: "ask",
|
|
666
|
+
auth: "manage_integrations",
|
|
667
|
+
roleAction: "manage_integrations",
|
|
668
|
+
cost: "free",
|
|
669
|
+
// Each call mints a new runId, so a retry is a NEW run — not a no-op.
|
|
670
|
+
idempotent: false,
|
|
671
|
+
// There is no door that deletes a run or a queued row.
|
|
672
|
+
reversible: false,
|
|
673
|
+
settles: "none",
|
|
674
|
+
version: "1.0.0",
|
|
675
|
+
examples: [{ directories: ["google-business-profile", "bing-places"] }],
|
|
676
|
+
}),
|
|
677
|
+
"directory:update": receiver({
|
|
678
|
+
receiver: "directory:update",
|
|
679
|
+
summary: "Walk one directory submission to its next status (queued → submitted → live|rejected) — listingUrl is required to mark live",
|
|
680
|
+
request: z.object({
|
|
681
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's. The row is looked up by (id, workspace), so another tenant's id reads as not_found."),
|
|
682
|
+
id: z.string().describe("The directory_submissions row id, from the run board"),
|
|
683
|
+
status: z.string().describe("Next status. The walk is queued→submitted, submitted→live|rejected; live and rejected are terminal. Anything else is refused as invalid_transition."),
|
|
684
|
+
listingUrl: z.string().optional().describe("The published listing URL. REQUIRED when status is live — a live listing nobody can point at is not a listing."),
|
|
685
|
+
note: z.string().optional().describe("Operator note; COALESCEd, so omitting it never clears an existing one"),
|
|
686
|
+
filedBy: z.string().optional().describe("PROVENANCE, not authority: 'human' | 'agent', who filed it at the directory. Stamped only on the submitted transition and defaults to 'human'. It can never widen what this caller may touch."),
|
|
687
|
+
}),
|
|
688
|
+
response: z.object({
|
|
689
|
+
ok: z.boolean(),
|
|
690
|
+
id: z.string().optional(),
|
|
691
|
+
status: z.string().optional().describe("The status now held"),
|
|
692
|
+
from: z.string().optional().describe("On invalid_transition: the status the row is actually in"),
|
|
693
|
+
to: z.string().optional().describe("On invalid_transition: the status that was asked for"),
|
|
694
|
+
error: z.string().optional().describe("no_db | workspace required | forbidden | id_and_status_required | not_found | invalid_transition | listing_url_required"),
|
|
695
|
+
}),
|
|
696
|
+
effect: "ask",
|
|
697
|
+
auth: "manage_integrations",
|
|
698
|
+
roleAction: "manage_integrations",
|
|
699
|
+
cost: "free",
|
|
700
|
+
// Re-asserting the status a row already holds is refused as invalid_transition
|
|
701
|
+
// (live and rejected are terminal, and queued→queued is not in the walk), so a
|
|
702
|
+
// blind retry is a REFUSAL rather than a silent second write.
|
|
703
|
+
idempotent: false,
|
|
704
|
+
// The walk is one-way — there is no un-transition door.
|
|
705
|
+
reversible: false,
|
|
706
|
+
settles: "none",
|
|
707
|
+
version: "1.0.0",
|
|
708
|
+
examples: [{ id: "6f1c...", status: "live", listingUrl: "https://example.com/listing/acme" }],
|
|
709
|
+
}),
|
|
710
|
+
// ── crm: · campaign:targeting · analytics:revenue · funnel:def-get ──────────
|
|
711
|
+
//
|
|
712
|
+
// The same defect `directory:*` above records, counted: of 133 top-level doors
|
|
713
|
+
// under `one.ie/web/src/pages/api/`, 75 answered to no receiver name at all
|
|
714
|
+
// (measured 2026-09-22 against `src/data/api-surface.json`). `crm/`,
|
|
715
|
+
// `campaigns/` and `analytics/` were three whole product areas with ZERO rows
|
|
716
|
+
// here, so `meta:catalog` could not name one of them and the only door was a
|
|
717
|
+
// hand-rolled fetch at an Astro route — the shape an agent cannot discover.
|
|
718
|
+
//
|
|
719
|
+
// These five are the ones an external builder reaches for, NOT one row per
|
|
720
|
+
// route: analytics alone is fifteen route files and exactly one of them is
|
|
721
|
+
// here, because a receiver per dashboard chart is noise, not discoverability.
|
|
722
|
+
// The other doors stay REST and are recorded, with their reasons, in
|
|
723
|
+
// `tests/unit/api-catalog-parity.test.ts`.
|
|
724
|
+
//
|
|
725
|
+
// EVERY BODY IS SHARED WITH ITS ROUTE. `lib/combo-read.ts`,
|
|
726
|
+
// `lib/analytics/revenue.ts` and `lib/funnel/types.ts` hold the one
|
|
727
|
+
// implementation; the resolver and the route differ ONLY in how the caller is
|
|
728
|
+
// attested (`callerControlsWorkspace(ctx.ownerSlug, …)` here,
|
|
729
|
+
// `authorizeWorkspace(locals, …)` there). A `workspace` in a payload NAMES a
|
|
730
|
+
// target and never authorizes one.
|
|
731
|
+
//
|
|
732
|
+
// The `auth` labels are chosen from the EXISTING table, never invented: an
|
|
733
|
+
// unlisted label falls through to `authenticated` (bind-receiver.ts), which
|
|
734
|
+
// would ship these weaker than they read. `manage_lifecycle` is the tenant
|
|
735
|
+
// floor `POST /api/funnels/def` already requires, and the combo cache IS
|
|
736
|
+
// lifecycle-stage data. `analytics:revenue` pairs the `manage_workspace`
|
|
737
|
+
// CLASS floor with the `read_revenue` ACTION the role union already names —
|
|
738
|
+
// the two fields answer two different questions (see `packages/sdk/CLAUDE.md`).
|
|
739
|
+
"crm:context": receiver({
|
|
740
|
+
receiver: "crm:context",
|
|
741
|
+
summary: "Instant prospect context for a contact's tag set — lifecycle stage, recent threads, matched workflows, path strength. The read a sales agent makes on contact lookup, before it writes a word",
|
|
742
|
+
request: z.object({
|
|
743
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's. Authority comes from the attested context — a slug you do not control is refused."),
|
|
744
|
+
tags: z.array(z.string()).min(1).describe("The contact's tags — lifecycle stage, persona, industry. MAX 8, each matching [a-z0-9_:][a-z0-9_.:-]*; more or malformed is refused by name, never silently truncated."),
|
|
745
|
+
}),
|
|
746
|
+
response: z.object({
|
|
747
|
+
ok: z.boolean(),
|
|
748
|
+
tags: z.array(z.string()).optional(),
|
|
749
|
+
strength: z.number().optional().describe("Max pairwise path strength across the tag set — how hot this combination is"),
|
|
750
|
+
primaryStage: z.string().nullable().optional().describe("The lifecycle stage to open on, or null when the combo has none"),
|
|
751
|
+
suggestedAction: z.object({ id: z.string(), name: z.string(), tags: z.array(z.string()) }).nullable().optional(),
|
|
752
|
+
lastThread: z.object({ id: z.string(), lastMsgAt: z.number() }).nullable().optional(),
|
|
753
|
+
recentThreads: z.array(z.object({ id: z.string(), lastMsgAt: z.number() })).optional().describe("At most 3"),
|
|
754
|
+
matchedWorkflows: z.array(z.object({ id: z.string(), name: z.string(), tags: z.array(z.string()) })).optional().describe("At most 3"),
|
|
755
|
+
cachedAt: z.number().optional().describe("Unix seconds the combo was resolved. A KV hit can be up to its strength-derived TTL old (300s hot / 3600s warm / 86400s cold)."),
|
|
756
|
+
error: z.string().optional().describe("no_db | workspace required | forbidden | the tag-validation reason"),
|
|
757
|
+
}),
|
|
758
|
+
effect: "ask",
|
|
759
|
+
auth: "manage_lifecycle",
|
|
760
|
+
roleAction: "manage_lifecycle",
|
|
761
|
+
cost: "free",
|
|
762
|
+
idempotent: true,
|
|
763
|
+
reversible: true,
|
|
764
|
+
settles: "none",
|
|
765
|
+
version: "1.0.0",
|
|
766
|
+
examples: [{ tags: ["consideration", "saas"] }],
|
|
767
|
+
}),
|
|
768
|
+
"crm:starters": receiver({
|
|
769
|
+
receiver: "crm:starters",
|
|
770
|
+
summary: "The hottest sales-stage tag combos in a workspace, as ready-to-send conversation openers — SAMPLED from a 200-key scan, and it says when it sampled",
|
|
771
|
+
request: z.object({
|
|
772
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's."),
|
|
773
|
+
limit: z.number().optional().describe("Openers to return, 1-20, default 5. Values above 20 are clamped, not refused."),
|
|
774
|
+
}),
|
|
775
|
+
response: z.object({
|
|
776
|
+
ok: z.boolean(),
|
|
777
|
+
starters: z.array(z.object({
|
|
778
|
+
tags: z.array(z.string()),
|
|
779
|
+
strength: z.number(),
|
|
780
|
+
lifecycles: z.array(z.string()),
|
|
781
|
+
suggestedOpener: z.string().describe("The opener text, written from the stage — send it or rewrite it"),
|
|
782
|
+
workflows: z.array(z.object({ id: z.string(), name: z.string(), tags: z.array(z.string()) })),
|
|
783
|
+
recentThreads: z.array(z.object({ id: z.string(), lastMsgAt: z.number() })),
|
|
784
|
+
})).optional(),
|
|
785
|
+
scanned: z.number().optional().describe("Cached combos actually read for this answer. It may be BELOW scanLimit on a truncated read — KV can return a short page with more to come, so this is not the completeness signal."),
|
|
786
|
+
scanLimit: z.number().optional().describe("The scan ceiling — 200"),
|
|
787
|
+
truncated: z.boolean().optional().describe("true = the KV listing was NOT complete, so these are the hottest of a SAMPLE and not of the workspace. Read from the store's own list_complete, never from scanned vs scanLimit. Always present on success, false included — never infer completeness from a missing key."),
|
|
788
|
+
workspace: z.string().optional(),
|
|
789
|
+
error: z.string().optional().describe("no_db | workspace required | forbidden"),
|
|
790
|
+
}),
|
|
791
|
+
effect: "ask",
|
|
792
|
+
auth: "manage_lifecycle",
|
|
793
|
+
roleAction: "manage_lifecycle",
|
|
794
|
+
cost: "free",
|
|
795
|
+
idempotent: true,
|
|
796
|
+
reversible: true,
|
|
797
|
+
settles: "none",
|
|
798
|
+
version: "1.0.0",
|
|
799
|
+
examples: [{ limit: 5 }],
|
|
800
|
+
}),
|
|
801
|
+
"campaign:targeting": receiver({
|
|
802
|
+
receiver: "campaign:targeting",
|
|
803
|
+
summary: "Size a campaign's audience BEFORE spending on reach — the tag combo plus a distinct-entity count over entity_tags",
|
|
804
|
+
request: z.object({
|
|
805
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's."),
|
|
806
|
+
tags: z.array(z.string()).min(1).describe("The targeting tags. MAX 8, charset-checked; more or malformed is refused by name."),
|
|
807
|
+
}),
|
|
808
|
+
response: z.object({
|
|
809
|
+
ok: z.boolean(),
|
|
810
|
+
tags: z.array(z.string()).optional(),
|
|
811
|
+
strength: z.number().optional(),
|
|
812
|
+
audienceSize: z.number().optional().describe("DISTINCT entities holding ANY of the tags — an OR, so it is an UPPER BOUND on a multi-tag segment and never an intersection"),
|
|
813
|
+
lifecycles: z.array(z.string()).optional().describe("Which stages this audience is actually in"),
|
|
814
|
+
workflows: z.array(z.object({ id: z.string(), name: z.string(), tags: z.array(z.string()) })).optional(),
|
|
815
|
+
threads: z.array(z.object({ id: z.string(), lastMsgAt: z.number() })).optional(),
|
|
816
|
+
ttl: z.number().optional(),
|
|
817
|
+
cachedAt: z.number().optional().describe("Unix seconds. The combo half may be a KV hit; audienceSize is always counted live."),
|
|
818
|
+
error: z.string().optional().describe("no_db | workspace required | forbidden | the tag-validation reason"),
|
|
819
|
+
}),
|
|
820
|
+
effect: "ask",
|
|
821
|
+
auth: "manage_lifecycle",
|
|
822
|
+
roleAction: "manage_lifecycle",
|
|
823
|
+
cost: "free",
|
|
824
|
+
idempotent: true,
|
|
825
|
+
reversible: true,
|
|
826
|
+
settles: "none",
|
|
827
|
+
version: "1.0.0",
|
|
828
|
+
examples: [{ tags: ["lead", "saas"] }],
|
|
829
|
+
}),
|
|
830
|
+
"analytics:revenue": receiver({
|
|
831
|
+
receiver: "analytics:revenue",
|
|
832
|
+
summary: "Workspace inference cost and revenue rollup over a date window — cost by model, cost per resolution, and a NAMED list of the figures that are not wired yet",
|
|
833
|
+
request: z.object({
|
|
834
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the attested caller's."),
|
|
835
|
+
from: z.number().optional().describe("Window start, epoch ms. Default: 30 days before `to`. CLAMPED to at most 366 days before `to` — the window actually read comes back as `from`."),
|
|
836
|
+
to: z.number().optional().describe("Window end, epoch ms. Default: now."),
|
|
837
|
+
}),
|
|
838
|
+
response: z.object({
|
|
839
|
+
ok: z.boolean(),
|
|
840
|
+
workspace: z.string().optional(),
|
|
841
|
+
from: z.number().optional().describe("The window actually read, after the clamp — compare it against what you asked for"),
|
|
842
|
+
to: z.number().optional(),
|
|
843
|
+
totalTokens: z.number().optional(),
|
|
844
|
+
totalCost: z.number().optional().describe("Inference cost in credits/100000, from credit_burns"),
|
|
845
|
+
costPerResolution: z.number().optional().describe("totalCost / journey-complete events; 0 when there were none"),
|
|
846
|
+
revenue: z.number().optional(),
|
|
847
|
+
ltv: z.number().optional(),
|
|
848
|
+
tokensByAgent: z.array(z.object({ label: z.string(), tokens: z.number(), cost: z.number() })).optional().describe("By MODEL, ranked by cost, top 30"),
|
|
849
|
+
tokensBySkill: z.array(z.object({ label: z.string(), tokens: z.number(), cost: z.number() })).optional(),
|
|
850
|
+
attribution: z.array(z.unknown()).optional(),
|
|
851
|
+
pending: z.array(z.string()).optional().describe("THE FIELDS WHOSE ZERO IS NOT A MEASUREMENT — no signal carries a skill field and payment events do not reach this rollup yet, so tokensBySkill/revenue/attribution are empty by wiring, not by finding. Read revenue:0 as 'unbuilt', never as 'earned nothing'."),
|
|
852
|
+
error: z.string().optional().describe("no_db | workspace required | forbidden"),
|
|
853
|
+
}),
|
|
854
|
+
effect: "ask",
|
|
855
|
+
auth: "manage_workspace",
|
|
856
|
+
roleAction: "read_revenue",
|
|
857
|
+
cost: "free",
|
|
858
|
+
idempotent: true,
|
|
859
|
+
reversible: true,
|
|
860
|
+
settles: "none",
|
|
861
|
+
version: "1.0.0",
|
|
862
|
+
examples: [{ workspace: "one" }],
|
|
863
|
+
}),
|
|
592
864
|
"tools:composio": receiver({
|
|
593
865
|
receiver: "tools:composio",
|
|
594
866
|
summary: "Execute one connected Composio tool from a workflow `tool` step (config.composio)",
|
|
@@ -597,6 +869,107 @@ export const RECEIVERS = {
|
|
|
597
869
|
effect: "ask", auth: "manage_integrations", reversible: false, idempotent: false,
|
|
598
870
|
examples: [{ workspace: "acme", tool: "GMAIL_SEND_EMAIL", args: { to: "x@y.com" } }],
|
|
599
871
|
}),
|
|
872
|
+
// ── endpoint:* — a tenant's OWN https endpoints, entered once, called from a
|
|
873
|
+
// workflow `tool` step. Four receivers and NO new step kind: `stepBinding`'s
|
|
874
|
+
// first branch is an explicit `config.receiver`, so a tool step already reaches
|
|
875
|
+
// any receiver by name. The eight step kinds stay eight.
|
|
876
|
+
// config: { receiver: "endpoint:call", args: { endpoint: "thumbs", input: {…} } }
|
|
877
|
+
//
|
|
878
|
+
// A registered endpoint is ALSO how an outside agent gets a face: a persona
|
|
879
|
+
// whose `model:` is `endpoint/<name>` is served as an OpenAI-compatible client
|
|
880
|
+
// against that row, so it has an actorId and an `AIChatBlock` renders it —
|
|
881
|
+
// with none of the tenant's code on anyone's machine. That path is
|
|
882
|
+
// `channels/src/tenant-endpoint.ts` and it needs the service to speak OpenAI
|
|
883
|
+
// chat completions; `endpoint:call` here takes any request and response shape.
|
|
884
|
+
// Handlers: one.ie/web/src/lib/resolvers/endpoints.ts — the SSRF guard runs at
|
|
885
|
+
// registration AND on every call (never cached), `redirect:'manual'` with any
|
|
886
|
+
// 3xx refused as `redirect_refused` (workerd rejects `redirect:'error'`), a
|
|
887
|
+
// byte ceiling, a timeout, and only the step's declared input crosses.
|
|
888
|
+
"endpoint:register": receiver({
|
|
889
|
+
receiver: "endpoint:register",
|
|
890
|
+
surfaces: { mcp: true },
|
|
891
|
+
summary: "Register (or update) one of the workspace's own https endpoints under a handle a workflow step can name. The url is SSRF-checked here and again on every call; a secret is sealed under the worker's envelope key and never returned.",
|
|
892
|
+
request: z.object({
|
|
893
|
+
workspace: z.string().optional().describe("Group slug. Omit for your own. A scope REQUEST — refused when the authority walk says no, never swapped for your own."),
|
|
894
|
+
name: z.string().describe("The handle a workflow step names. Lowercase letters, digits, `-` and `_`, 1–48 chars. Unique per workspace; re-registering the same name updates it in place so a live workflow keeps working."),
|
|
895
|
+
url: z.string().describe("A public https address. Refused BY NAME — https_required · ip_literal · private_hostname · dns_unresolved · blocked_address — so a retry is informed."),
|
|
896
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH"]).optional().describe("Default POST. GET folds `input` into the query string; the others send it as a JSON body."),
|
|
897
|
+
authHeader: z.string().optional().describe("The header NAME the secret travels in, e.g. `Authorization` or `X-Api-Key`. The VALUE is `secret` and is stored sealed."),
|
|
898
|
+
secret: z.string().optional().describe("Your endpoint's own credential. Sealed AES-256-GCM under the worker's PII_ENVELOPE_KEY, opened into memory for one outbound header, never logged, never returned. Requires `authHeader`. Omitting it on a re-register KEEPS the existing secret rather than disarming it."),
|
|
899
|
+
returnMode: z.enum(["json", "url", "bytes"]).optional().describe("What the endpoint answers. `json` = the answer itself. `url` = a small JSON body naming a url, which is fetched under the SAME guard and filed to the media library. `bytes` = the asset itself, filed the same way. Default json."),
|
|
900
|
+
timeoutMs: z.number().optional().describe("Per-call AbortSignal timeout. Default 20000, clamped to 1000–120000."),
|
|
901
|
+
maxBytes: z.number().optional().describe("Response byte ceiling, enforced while reading — `content-length` is a claim and is not read. Default 5 MiB, clamped to 10 MiB."),
|
|
902
|
+
}),
|
|
903
|
+
response: z.object({
|
|
904
|
+
ok: z.boolean(),
|
|
905
|
+
workspace: z.string().optional(),
|
|
906
|
+
endpoint: z.record(z.string(), z.unknown()).optional().describe("The stored row, minus the sealed secret. `hasSecret` is a boolean; the value has no read door."),
|
|
907
|
+
error: z.string().optional(),
|
|
908
|
+
}),
|
|
909
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true,
|
|
910
|
+
auth: "manage_integrations", roleAction: "manage_integrations",
|
|
911
|
+
examples: [{ name: "thumbs", url: "https://api.dave.example/thumbnail", method: "POST", returnMode: "bytes", authHeader: "Authorization", secret: "Bearer …" }],
|
|
912
|
+
}),
|
|
913
|
+
"endpoint:list": receiver({
|
|
914
|
+
receiver: "endpoint:list",
|
|
915
|
+
surfaces: { mcp: true },
|
|
916
|
+
summary: "List the workspace's registered endpoints — never the sealed secrets, only whether each has one",
|
|
917
|
+
request: z.object({ workspace: z.string().optional() }),
|
|
918
|
+
response: z.object({
|
|
919
|
+
ok: z.boolean(),
|
|
920
|
+
workspace: z.string().optional(),
|
|
921
|
+
total: z.number().optional(),
|
|
922
|
+
endpoints: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
923
|
+
error: z.string().optional(),
|
|
924
|
+
}),
|
|
925
|
+
effect: "ask", cost: "free", idempotent: true,
|
|
926
|
+
auth: "manage_integrations", roleAction: "manage_integrations",
|
|
927
|
+
examples: [{ workspace: "dave" }],
|
|
928
|
+
}),
|
|
929
|
+
"endpoint:remove": receiver({
|
|
930
|
+
receiver: "endpoint:remove",
|
|
931
|
+
surfaces: { mcp: true },
|
|
932
|
+
summary: "Delete one registered endpoint. A delete of something absent answers not_found — gone and never-existed are different answers",
|
|
933
|
+
request: z.object({ workspace: z.string().optional(), name: z.string() }),
|
|
934
|
+
response: z.object({
|
|
935
|
+
ok: z.boolean(), workspace: z.string().optional(), name: z.string().optional(),
|
|
936
|
+
removed: z.number().optional(), error: z.string().optional(),
|
|
937
|
+
}),
|
|
938
|
+
effect: "ask", cost: "free", reversible: false, idempotent: false,
|
|
939
|
+
auth: "manage_integrations", roleAction: "manage_integrations",
|
|
940
|
+
examples: [{ name: "thumbs" }],
|
|
941
|
+
}),
|
|
942
|
+
"endpoint:call": receiver({
|
|
943
|
+
receiver: "endpoint:call",
|
|
944
|
+
surfaces: { mcp: true },
|
|
945
|
+
summary: "Call one registered endpoint and return its answer — or, in url/bytes mode, file the asset in the media library and return the reader url. The SSRF guard re-runs on EVERY call because a stored verdict is a fact about the past.",
|
|
946
|
+
request: z.object({
|
|
947
|
+
workspace: z.string().optional(),
|
|
948
|
+
endpoint: z.string().describe("The handle from endpoint:register."),
|
|
949
|
+
input: z.record(z.string(), z.unknown()).optional().describe("The ONLY thing that crosses to the endpoint, beside its own sealed credential. Never the caller's session, the workspace key, or any ONE secret."),
|
|
950
|
+
returnMode: z.enum(["json", "url", "bytes"]).optional().describe("Overrides the row's mode for this call."),
|
|
951
|
+
dryRun: z.boolean().optional().describe("Run the guard and answer its verdict without spending a request. What `one endpoint test` uses."),
|
|
952
|
+
}),
|
|
953
|
+
response: z.object({
|
|
954
|
+
ok: z.boolean(),
|
|
955
|
+
endpoint: z.string().optional(),
|
|
956
|
+
status: z.number().optional(),
|
|
957
|
+
json: z.unknown().optional(),
|
|
958
|
+
text: z.string().optional().describe("Present instead of `json` when the endpoint answered something that is not JSON. Named rather than coerced, so a step never branches on an invented shape."),
|
|
959
|
+
workspace: z.string().optional(),
|
|
960
|
+
key: z.string().optional(),
|
|
961
|
+
url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — the same reader media:generate returns."),
|
|
962
|
+
kind: z.literal("image").optional(),
|
|
963
|
+
contentType: z.string().optional().describe("Sniffed from the MAGIC BYTES, never from what the endpoint declared."),
|
|
964
|
+
size: z.number().optional(),
|
|
965
|
+
dryRun: z.boolean().optional(),
|
|
966
|
+
guard: z.string().optional(),
|
|
967
|
+
error: z.string().optional().describe("Named: not_found · disabled · blocked_url:<reason> · redirect_refused: <the 3xx status> · fetch_failed · endpoint_error · too_large · bad_response · unsupported_type · credential_unreadable · storage_error."),
|
|
968
|
+
}),
|
|
969
|
+
effect: "ask", cost: "variable", reversible: false, idempotent: false, settles: "none",
|
|
970
|
+
auth: "manage_integrations", roleAction: "manage_integrations",
|
|
971
|
+
examples: [{ endpoint: "thumbs", input: { title: "How we shipped in a week", style: "bold" } }],
|
|
972
|
+
}),
|
|
600
973
|
"brand:set": receiver({
|
|
601
974
|
receiver: "brand:set",
|
|
602
975
|
summary: "Write up to 6 brand color tokens (primary/secondary/accent/bg/text/border) to a workspace theme",
|
|
@@ -781,6 +1154,93 @@ export const RECEIVERS = {
|
|
|
781
1154
|
response: z.object({ uid: z.string(), address: z.string() }),
|
|
782
1155
|
effect: "ask", cost: "free", idempotent: true,
|
|
783
1156
|
}),
|
|
1157
|
+
// C11 — Verified: soulbound claims per address (text/key.md § Verified).
|
|
1158
|
+
//
|
|
1159
|
+
// THE FAMILY IS `identity:`, and the alternative was considered and refused.
|
|
1160
|
+
// `identify` is one of the eight lifecycle words (identify · engage · sell ·
|
|
1161
|
+
// nurture · upsell · share · educate · refine), written as a BARE tag that agents
|
|
1162
|
+
// subscribe to; a receiver family spelled the same way collides with it in
|
|
1163
|
+
// every tag search and becomes the next dead name. `api:` was refused too: it
|
|
1164
|
+
// names how a request ARRIVED, not a thing that is done.
|
|
1165
|
+
//
|
|
1166
|
+
// THE INVARIANT, and it is the whole row: an email, a phone number, a name or
|
|
1167
|
+
// a document number appears in NO field below. What travels is `commitment`
|
|
1168
|
+
// — blake2b(domain ‖ len(salt) ‖ salt ‖ len(identifier) ‖ identifier), 32 bytes
|
|
1169
|
+
// of hex — and the holder keeps the salt. Each request is `.strict()`, which
|
|
1170
|
+
// is the load-bearing choice: zod's DEFAULT is to STRIP an unknown key, so a
|
|
1171
|
+
// caller that puts an email in the payload would be told nothing, logged
|
|
1172
|
+
// nothing, and believe it had been accepted. Strict REFUSES it at the edge.
|
|
1173
|
+
"identity:verify": receiver({
|
|
1174
|
+
receiver: "identity:verify",
|
|
1175
|
+
summary: "Start a verification method for an address and get back a single-use challenge plus the fresh random salt to commit with. Takes no identifier and returns none: the caller proves the contact point to the issuer out of band, and only the salted commitment ever travels.",
|
|
1176
|
+
request: z.object({
|
|
1177
|
+
subject: z.string().min(1).describe("the Sui address being verified — public, never a person"),
|
|
1178
|
+
method: z.enum(["passkey", "google", "phone", "domain", "kyc"]).describe("which rung is being attempted; `key` needs no challenge and `owner-of` is issued by the spawns walk, so neither is startable here"),
|
|
1179
|
+
}).strict(),
|
|
1180
|
+
response: z.object({
|
|
1181
|
+
ok: z.boolean(),
|
|
1182
|
+
challenge: z.string().optional().describe("single-use nonce; the attestation that spends it can never be replayed"),
|
|
1183
|
+
salt: z.string().optional().describe("32 fresh random bytes as hex, for the holder to KEEP — it is what lets them open the commitment later, to one counterparty, off chain"),
|
|
1184
|
+
method: z.string().optional(),
|
|
1185
|
+
level: z.number().optional().describe("the rung this method would confer: 0 key · 1 passkey · 2 google/phone · 3 domain · 4 kyc"),
|
|
1186
|
+
expiresAt: z.number().optional(),
|
|
1187
|
+
error: z.string().optional().describe("a refusal CODE — never quotes an identifier, a salt or a key"),
|
|
1188
|
+
}),
|
|
1189
|
+
effect: "ask", auth: "session", cost: "free", reversible: true,
|
|
1190
|
+
}),
|
|
1191
|
+
"identity:attest": receiver({
|
|
1192
|
+
receiver: "identity:attest",
|
|
1193
|
+
summary: "An issuer writes one soulbound verification claim for an address: method, rung, issuer, salted commitment, expiry. Authorised by an on-chain IssuerCap (ONE's attester holds rung 3; KYC is delegated and ONE deliberately does not carry it), bound to the challenge's subject and method, and single-use — the nonce is spent only once every other check has passed.",
|
|
1194
|
+
request: z.object({
|
|
1195
|
+
subject: z.string().min(1),
|
|
1196
|
+
method: z.enum(["key", "passkey", "google", "phone", "domain", "kyc", "owner-of"]),
|
|
1197
|
+
commitment: z.string().regex(/^[0-9a-f]{64}$/).describe("blake2b-256 hex. A raw identifier fails this pattern at the edge — which is the point"),
|
|
1198
|
+
challenge: z.string().min(1).describe("the nonce from identity:verify; replay is refused"),
|
|
1199
|
+
expiresAt: z.number().int().nonnegative().optional().describe("epoch ms; 0 or absent = never"),
|
|
1200
|
+
}).strict(),
|
|
1201
|
+
response: z.object({
|
|
1202
|
+
ok: z.boolean(),
|
|
1203
|
+
claimId: z.string().optional(),
|
|
1204
|
+
subject: z.string().optional(),
|
|
1205
|
+
method: z.string().optional(),
|
|
1206
|
+
level: z.number().optional(),
|
|
1207
|
+
issuer: z.string().optional().describe("the attesting ADDRESS — the signing key stays server-side and is never returned, logged or thrown"),
|
|
1208
|
+
state: z.number().optional().describe("0 active · 1 revoked"),
|
|
1209
|
+
error: z.string().optional(),
|
|
1210
|
+
}),
|
|
1211
|
+
effect: "ask", auth: "mint_capability", cost: "free", reversible: false, settles: "onchain",
|
|
1212
|
+
}),
|
|
1213
|
+
"identity:proofs": receiver({
|
|
1214
|
+
receiver: "identity:proofs",
|
|
1215
|
+
surfaces: { mcp: true },
|
|
1216
|
+
summary: "Read an address's ACTIVE claims and its level — public, no auth, no PII. Excludes revoked and expired claims; an agent's level is its own key proof plus its verified owner's rungs, walked up spawns, and an unowned agent is said plainly as level 0 with owner null.",
|
|
1217
|
+
request: z.object({
|
|
1218
|
+
subject: z.string().min(1),
|
|
1219
|
+
}).strict(),
|
|
1220
|
+
response: z.object({
|
|
1221
|
+
ok: z.boolean(),
|
|
1222
|
+
subject: z.string().optional(),
|
|
1223
|
+
level: z.number().optional().describe("the MAX active rung, never a sum"),
|
|
1224
|
+
owner: z.string().nullable().optional().describe("the verified human behind an agent when BOTH the owner-of claim and the spawns edge hold; null is a legitimate answer, not a missing one"),
|
|
1225
|
+
claims: z.array(z.object({
|
|
1226
|
+
id: z.string(),
|
|
1227
|
+
method: z.string(),
|
|
1228
|
+
level: z.number(),
|
|
1229
|
+
issuer: z.string(),
|
|
1230
|
+
commitment: z.string(),
|
|
1231
|
+
issuedAt: z.number(),
|
|
1232
|
+
expiresAt: z.number(),
|
|
1233
|
+
state: z.number(),
|
|
1234
|
+
})).optional(),
|
|
1235
|
+
reputation: z.object({
|
|
1236
|
+
kept: z.number(), broken: z.number(),
|
|
1237
|
+
escrowReleased: z.number(), escrowRefunded: z.number(),
|
|
1238
|
+
proven: z.number(), settled: z.number(),
|
|
1239
|
+
}).optional().describe("SETTLED outcomes only — promises kept/broken, escrow released/refunded, UnitProven. Never intent, never activity, never a hand-written score"),
|
|
1240
|
+
error: z.string().optional(),
|
|
1241
|
+
}),
|
|
1242
|
+
effect: "ask", auth: "public", cost: "free", idempotent: true,
|
|
1243
|
+
}),
|
|
784
1244
|
// ── auth (human session) ──
|
|
785
1245
|
// auth:sign-in killed (C7): Better Auth handles all human auth; signal/ask pipeline returns dissolved
|
|
786
1246
|
"auth:sign-out": receiver({
|
|
@@ -1801,7 +2261,7 @@ export const RECEIVERS = {
|
|
|
1801
2261
|
"tasks:priority": receiver({
|
|
1802
2262
|
receiver: "tasks:priority",
|
|
1803
2263
|
surfaces: { mcp: true },
|
|
1804
|
-
summary: "Set a task's priority
|
|
2264
|
+
summary: "Set a task's priority. Two scales, no overlap: a value strictly between 0 and 1 is a fraction and is stored as-is; anything else is the 1–100 slider (stored as slider/100, so exactly 1 is the MINIMUM — send 100 or 0.99 for the top). Stored as the 0–1 `task-priority` attribute the board's ranking reads",
|
|
1805
2265
|
request: z.object({
|
|
1806
2266
|
tid: z.string(),
|
|
1807
2267
|
priority: z.number(),
|
|
@@ -1988,7 +2448,7 @@ export const RECEIVERS = {
|
|
|
1988
2448
|
surfaces: { mcp: true },
|
|
1989
2449
|
summary: "The whole task board in ONE request: every task in a group (or its whole subtree with scope:'tree'), filtered in RAM, with `total` before paging, a `nextCursor` when there is more, `truncated` naming any budget that bit, and a `summary` over ALL matched rows — counts by status/tag/assignee/workspace, the ready set, the blocked set, overdue, unassigned, orphans, no-notes. Use this to plan; use tasks:bulk to act on what it shows.",
|
|
1990
2450
|
request: z.object({
|
|
1991
|
-
workspace: z.string().optional().describe("Group slug. Omit for your own.
|
|
2451
|
+
workspace: z.string().optional().describe("Group slug. Omit for your own. A scope REQUEST — refused ({ok:false, error:'forbidden', workspace}) when you may not read it, never swapped for your own."),
|
|
1992
2452
|
scope: z.enum(["own", "tree"]).optional().describe("tree = this group AND every descendant group (the CEO / agency lens). Default own."),
|
|
1993
2453
|
status: z.union([z.string(), z.array(z.string())]).optional()
|
|
1994
2454
|
.describe("open | blocked | picked | done | verified | failed | dissolved, one or many. 'active' = open+blocked+picked (the default). 'all' = every status."),
|
|
@@ -1999,7 +2459,9 @@ export const RECEIVERS = {
|
|
|
1999
2459
|
search: z.string().optional().describe("Case-insensitive substring on the task name."),
|
|
2000
2460
|
ready: z.boolean().optional().describe("true = only rows claimable now (open, and every blocker closed). false = only rows that are NOT claimable now (not open, or at least one blocker still open). Omit for both."),
|
|
2001
2461
|
include: z.array(z.enum(["notes", "graph", "dates", "thread"])).optional()
|
|
2002
|
-
.describe("Widen each row. notes = the prose goal,
|
|
2462
|
+
.describe("Widen each row. notes = the prose goal, WHOLE and unclipped (it was silently cut at 2000 chars until 2026-09-21, mid-word, while `truncated` named the tag budget beside it and stayed silent about this one; the escape hatch its comment named, tasks:get, was never built, so no door returned a longer note at all). Pass `notesClip` for a bound and the response reports `truncated.notes`; graph = children + blocks (the reverse edges); dates = startAt/createdAt/closedAt; thread = the task's inbox conversation (id, comment count, last message). Compact rows already carry parent, blockedBy, dueAt."),
|
|
2463
|
+
notesClip: z.number().optional()
|
|
2464
|
+
.describe("Opt-in per-row ceiling on `include:'notes'` prose. Omitted = WHOLE notes, the default. When it bites, the response carries `truncated.notes` = the number of rows cut, so a clipped board can never read as complete. Values under 200 are ignored rather than honoured — a ceiling that cuts every row is a request for a different field, not a budget."),
|
|
2003
2465
|
view: z.enum(["rows", "summary", "both"]).optional().describe("summary = counts and sets only, no rows (cheapest way to see a 2,000-row board). Default both."),
|
|
2004
2466
|
sort: z.enum(["priority", "due-at", "updated-at", "created-at", "name"]).optional().describe("Default priority (desc)."),
|
|
2005
2467
|
dir: z.enum(["asc", "desc"]).optional(),
|
|
@@ -2086,9 +2548,10 @@ export const RECEIVERS = {
|
|
|
2086
2548
|
// It reimplements no write: the resolver loops and calls the existing
|
|
2087
2549
|
// single-tid resolvers with the SAME attested ctx, so authority, the status
|
|
2088
2550
|
// gates and the D1 tag mirror are all inherited rather than re-derived. The
|
|
2089
|
-
// `workspace` field (envelope or per row) is a REQUEST
|
|
2090
|
-
//
|
|
2091
|
-
//
|
|
2551
|
+
// `workspace` field (envelope or per row) is a REQUEST: one the caller is
|
|
2552
|
+
// neither staff for nor in control of REFUSES the whole call before any write
|
|
2553
|
+
// ({ok:false, error:'forbidden', workspace}) — never silently swapped for the
|
|
2554
|
+
// caller's own group, which is where such a write used to land.
|
|
2092
2555
|
"tasks:bulk": receiver({
|
|
2093
2556
|
receiver: "tasks:bulk",
|
|
2094
2557
|
surfaces: { mcp: true },
|
|
@@ -2098,7 +2561,7 @@ export const RECEIVERS = {
|
|
|
2098
2561
|
tid: z.string(),
|
|
2099
2562
|
title: z.string().optional(),
|
|
2100
2563
|
status: z.string().optional().describe("open | blocked | picked | done | verified | failed | dissolved"),
|
|
2101
|
-
priority: z.number().optional().describe("1-100 slider
|
|
2564
|
+
priority: z.number().optional().describe("Same rule as tasks:priority: strictly between 0 and 1 = a fraction stored as-is; otherwise the 1-100 slider (1 is the minimum)"),
|
|
2102
2565
|
notes: z.string().optional().describe("Prose goal; empty string clears"),
|
|
2103
2566
|
parent: z.string().optional().describe("MOVE this row under another — the only door that writes a `containment` edge AFTER birth (tasks:create and tasks:subtask write one only at birth, which is why a story could never be made a sub-story of another). An existing task id, or a `ref` created EARLIER in `creates`. EMPTY STRING CLEARS: the row becomes a root again, and clearing a row that already had no parent is a silent success. Refused by name, never silently: `cycle` if the new parent is this row or any row beneath it (walked transitively, and a walk that could not FINISH answers `undetermined` and is refused — never guessed at); `not_found` if the parent does not exist, OR the caller lacks operate access to it, OR the row's CURRENT parent is one the caller cannot operate (detaching changes that tree too) — the same rule, and the same word, tasks:create's `parent` uses; `cross_workspace` if the two ends live in different workspaces and the caller is not staff. CLAIM-GATE CONSEQUENCE — this is not only a tree edit: `tasks:claim` refuses `picked` while an OPEN `containment` child exists, so hanging an open row under a claimable parent makes that parent UNCLAIMABLE, and moving the last open child away makes it claimable again. Deliberately absent from `set`: a filter-driven mass re-parent moves whole subtrees at once — and multiplies that claim-gate consequence by the match count — so this is per-row only."),
|
|
2104
2567
|
assignee: z.string().optional().describe("Actor slug; empty string unassigns"),
|
|
@@ -2109,7 +2572,7 @@ export const RECEIVERS = {
|
|
|
2109
2572
|
addBlockedBy: z.array(z.string()).optional().describe("Task ids (or `ref`s from `creates`) this row must wait for — tasks:depend per id"),
|
|
2110
2573
|
removeBlockedBy: z.array(z.string()).optional().describe("tasks:undepend per id"),
|
|
2111
2574
|
comment: z.string().optional().describe("Post to the task's inbox thread (tasks:comment) — say WHY you changed it, so the humans watching the thread see the decision"),
|
|
2112
|
-
workspace: z.string().optional().describe("Per-row override of the envelope workspace. A request, never a grant."),
|
|
2575
|
+
workspace: z.string().optional().describe("Per-row override of the envelope workspace. A request, never a grant — one you may not operate refuses the whole call before any write."),
|
|
2113
2576
|
})).max(25).optional(),
|
|
2114
2577
|
creates: z.array(z.object({
|
|
2115
2578
|
ref: z.string().optional().describe("A local handle, e.g. 'a'. Other rows in THIS call may name it in parent/blockedBy/addBlockedBy; the receipt maps ref → tid."),
|
|
@@ -2117,7 +2580,7 @@ export const RECEIVERS = {
|
|
|
2117
2580
|
notes: z.string().optional(),
|
|
2118
2581
|
tags: z.array(z.string()).optional(),
|
|
2119
2582
|
assignee: z.string().optional(),
|
|
2120
|
-
priority: z.number().optional(),
|
|
2583
|
+
priority: z.number().optional().describe("Same rule as tasks:priority: strictly between 0 and 1 = a fraction stored as-is; otherwise the 1-100 slider"),
|
|
2121
2584
|
parent: z.string().optional().describe("Existing task id or a ref created EARLIER in this array"),
|
|
2122
2585
|
blockedBy: z.array(z.string()).optional().describe("Existing task ids or refs created earlier in this array"),
|
|
2123
2586
|
dueAt: z.string().optional(),
|
|
@@ -2144,13 +2607,14 @@ export const RECEIVERS = {
|
|
|
2144
2607
|
}).optional().describe("The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row. TWO ROUTES, AND THIS IS THE NARROWER ONE. `assignee` here is the DIRECT route: you decide the owner, for work whose owner is not in doubt. The DEFAULT is the other route — `tasks:announce` (or `signal(\"world\", {tags})`) walks the weighted tag→receiver paths to whoever STAKED those tags, marks the delivery, and the next route is smarter for it; the CEO is an observer there, not a bottleneck. Prefer the stake route and keep this one for the unambiguous case. And when you read the announce back: its `matched` is reach, never delivery."),
|
|
2145
2608
|
dryRun: z.boolean().optional().describe("For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates."),
|
|
2146
2609
|
cursor: z.string().optional().describe("For `where`: the nextCursor from the previous call"),
|
|
2147
|
-
workspace: z.string().optional().describe("Workspace to act in, for every row. Honoured only if you are staff or control it."),
|
|
2610
|
+
workspace: z.string().optional().describe("Workspace to act in, for every row. Omit for your own. Honoured only if you are staff or control it — otherwise the call is REFUSED ({ok:false, error:'forbidden', workspace}) before any write, never swapped for your own."),
|
|
2148
2611
|
}).refine((v) => (v.edits?.length ?? 0) + (v.creates?.length ?? 0) > 0 || (!!v.where && !!v.set), {
|
|
2149
2612
|
message: "send edits, creates, or where+set",
|
|
2150
2613
|
path: ["edits"],
|
|
2151
2614
|
}).refine((v) => !v.where === !v.set, { message: "where and set go together", path: ["where"] }),
|
|
2152
2615
|
response: z.object({
|
|
2153
2616
|
ok: z.boolean().describe("The CALL's outcome, not the rows'. Read applied/failed."),
|
|
2617
|
+
workspace: z.string().optional().describe("On error:'forbidden' — the requested workspace the caller may not operate."),
|
|
2154
2618
|
total: z.number().optional(),
|
|
2155
2619
|
attempted: z.number().optional(),
|
|
2156
2620
|
applied: z.number().optional(),
|
|
@@ -3305,6 +3769,47 @@ export const RECEIVERS = {
|
|
|
3305
3769
|
effect: "signal", cost: "free", reversible: false, idempotent: true, auth: "public",
|
|
3306
3770
|
}),
|
|
3307
3771
|
// ── FUNNELS (text/funnels-plan.md) — author + run funnel definitions ──────────
|
|
3772
|
+
// funnel:def-get — READ ONE BACK. The three writers below shipped without it,
|
|
3773
|
+
// so an agent could create, edit and publish a funnel and had no receiver to see
|
|
3774
|
+
// what it had built: the only door was `GET /api/funnels/def`, which
|
|
3775
|
+
// `meta:catalog` cannot name. The row read is `readFunnelDef` in
|
|
3776
|
+
// `lib/funnel/types.ts`, shared verbatim with that route. `manage_lifecycle` is
|
|
3777
|
+
// the label the route's own POST and DELETE already require. The lookup is keyed
|
|
3778
|
+
// on (workspace, id), so another tenant's id reads as not_found — never a leak.
|
|
3779
|
+
"funnel:def-get": receiver({
|
|
3780
|
+
receiver: "funnel:def-get",
|
|
3781
|
+
summary: "Read one funnel definition by id — DRAFT OR PUBLISHED, the operator's read. Another tenant's id answers not_found, never their funnel",
|
|
3782
|
+
request: z.object({
|
|
3783
|
+
workspaceSlug: z.string().optional().describe("Workspace slug; defaults to the attested caller's. A slug you do not control is refused."),
|
|
3784
|
+
funnelId: z.string().describe("The funnel_definitions row id returned by funnel:create"),
|
|
3785
|
+
}),
|
|
3786
|
+
response: z.object({
|
|
3787
|
+
ok: z.boolean(),
|
|
3788
|
+
def: z.object({
|
|
3789
|
+
id: z.string(),
|
|
3790
|
+
slug: z.string(),
|
|
3791
|
+
workspaceSlug: z.string(),
|
|
3792
|
+
name: z.string(),
|
|
3793
|
+
status: z.string().describe("draft | published"),
|
|
3794
|
+
playbookNode: z.string().optional(),
|
|
3795
|
+
steps: z.array(z.unknown()),
|
|
3796
|
+
edges: z.array(z.unknown()),
|
|
3797
|
+
version: z.number(),
|
|
3798
|
+
createdAt: z.number(),
|
|
3799
|
+
updatedAt: z.number(),
|
|
3800
|
+
}).optional(),
|
|
3801
|
+
error: z.string().optional().describe("missing_fields | forbidden | not_found"),
|
|
3802
|
+
}),
|
|
3803
|
+
effect: "ask",
|
|
3804
|
+
auth: "manage_lifecycle",
|
|
3805
|
+
roleAction: "manage_lifecycle",
|
|
3806
|
+
cost: "free",
|
|
3807
|
+
idempotent: true,
|
|
3808
|
+
reversible: true,
|
|
3809
|
+
settles: "none",
|
|
3810
|
+
version: "1.0.0",
|
|
3811
|
+
examples: [{ funnelId: "6f1c9b2e-0000-4000-8000-000000000000" }],
|
|
3812
|
+
}),
|
|
3308
3813
|
// funnel:create — create a new funnel definition (draft).
|
|
3309
3814
|
"funnel:create": receiver({
|
|
3310
3815
|
receiver: "funnel:create",
|
|
@@ -3457,7 +3962,10 @@ export const RECEIVERS = {
|
|
|
3457
3962
|
"products:list": receiver({
|
|
3458
3963
|
receiver: "products:list",
|
|
3459
3964
|
summary: "List a workspace's storefront products with their prices (public)",
|
|
3460
|
-
request: z.object({
|
|
3965
|
+
request: z.object({
|
|
3966
|
+
slug: z.string().optional(),
|
|
3967
|
+
collection: z.string().optional().describe("Narrow the catalogue to one collection, matched TRIMMED on both sides (the facet label is derived as `(collection ?? '').trim()`, so an untrimmed predicate would match zero rows and filter everything). Empty or unknown returns the whole catalogue, never an error. Public: it can only narrow rows an anonymous caller could already see — unlike includeDrafts, which is honoured only for the attested owner and is deliberately NOT declared here."),
|
|
3968
|
+
}),
|
|
3461
3969
|
response: z.object({ products: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
|
|
3462
3970
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
3463
3971
|
}),
|
|
@@ -3578,6 +4086,93 @@ export const RECEIVERS = {
|
|
|
3578
4086
|
response: z.object({ cid: z.string().optional(), cleared: z.number().optional(), error: z.string().optional() }),
|
|
3579
4087
|
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "public",
|
|
3580
4088
|
}),
|
|
4089
|
+
// ── discounts (C6) — ONE coupon system, two rails ────────────────────────────
|
|
4090
|
+
// There is no `discounts` table. These five receivers read and write the
|
|
4091
|
+
// BILLING coupon tables (one.ie/web/migrations/0077_coupons.sql) widened by
|
|
4092
|
+
// 0253_coupons_scope.sql with a `scope` column, so a seller's SAVE10 can
|
|
4093
|
+
// discount a SaaS credit purchase, a shop cart, or both — one validator, one
|
|
4094
|
+
// pricing core (`priceDiscount`, lib/billing/coupon.ts), one redemption ledger
|
|
4095
|
+
// that records WHAT each redemption was applied to. The operator's ruling,
|
|
4096
|
+
// 2026-09-22: "we should offer saas and ecom customers a discount. same."
|
|
4097
|
+
//
|
|
4098
|
+
// `coupons.code` is GLOBALLY unique, so every storefront statement binds
|
|
4099
|
+
// (code, workspace) TOGETHER — a code alone never reaches another seller's
|
|
4100
|
+
// sale, and an unknown code and a foreign one answer the same `not_found`.
|
|
4101
|
+
"discounts:create": receiver({
|
|
4102
|
+
receiver: "discounts:create",
|
|
4103
|
+
summary: "Mint a storefront discount code as a scoped coupon row — the workspace is the attested caller, never a body field",
|
|
4104
|
+
request: z.object({
|
|
4105
|
+
code: z.string().describe("The code a shopper types. Upper-cased and globally unique: a code another seller already owns answers `code_taken`."),
|
|
4106
|
+
name: z.string().optional().describe("Human label for the seller's console. Defaults to the code."),
|
|
4107
|
+
discount_type: z.enum(["percentage", "fixed_cents"]).describe("`percentage` is unitless and prices both rails. `fixed_cents` is MONEY and prices a cart only — `fixed_credits` is the billing unit and this door will not mint it."),
|
|
4108
|
+
discount_value: z.number().positive().describe("Percent (1-100) or whole cents off. Must be finite and positive: a negative value would invert into a surcharge."),
|
|
4109
|
+
currency: z.string().optional().describe("Currency a `fixed_cents` value is denominated in. Defaults to usd; ignored for a percentage."),
|
|
4110
|
+
scope: z.enum(["storefront"]).optional().describe("Storefront only, and the enum is narrow on purpose. This door does NOT mint a coupon the platform-billing rail can redeem: `both` and `billing` are refused BY NAME (`scope_requires_billing_door`), never silently narrowed. The credits rail is minted at POST /api/billing/coupons, behind `requireAgency`/`locals.controls` — one rail, one door. Until 2026-09-22 `both` was accepted here at `auth:\"member\"`, which made this a second, lower-rung door onto the discount that prices a tenant's own ONE invoice (11.4 security review)."),
|
|
4111
|
+
cadence: z.enum(["once", "repeating", "forever"]).optional().describe("Billing-rail redemption cadence, carried on the shared row. Defaults to `forever`."),
|
|
4112
|
+
max_redemptions: z.number().int().positive().optional().describe("Total redemptions across all buyers before the code answers `exhausted`. Absent = unlimited."),
|
|
4113
|
+
valid_until: z.number().int().optional().describe("Unix SECONDS after which the code answers `expired`. Absent = no expiry."),
|
|
4114
|
+
}),
|
|
4115
|
+
response: z.object({ ok: z.boolean().optional(), code: z.string().optional(), scope: z.string().optional(), workspace: z.string().optional(), error: z.string().optional() }),
|
|
4116
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
4117
|
+
}),
|
|
4118
|
+
"discounts:list": receiver({
|
|
4119
|
+
receiver: "discounts:list",
|
|
4120
|
+
summary: "List the calling workspace's storefront discount codes with their redemption counts",
|
|
4121
|
+
request: z.object({}),
|
|
4122
|
+
response: z.object({
|
|
4123
|
+
discounts: z.array(z.record(z.string(), z.unknown())).optional().describe("Coupon rows scoped `storefront` or `both`, newest first, each with a live `used` count."),
|
|
4124
|
+
cap: z.number().optional().describe("The row ceiling this read applied. Always present: a capped read must say what bit it."),
|
|
4125
|
+
truncated: z.boolean().optional().describe("TRUE when more rows match than were returned — quote `total` as a floor when it is."),
|
|
4126
|
+
total: z.number().nullable().optional().describe("Exact count when `truncated` is false; null when it is true, because the read never saw the rest."),
|
|
4127
|
+
error: z.string().optional(),
|
|
4128
|
+
}),
|
|
4129
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
4130
|
+
}),
|
|
4131
|
+
"discounts:archive": receiver({
|
|
4132
|
+
receiver: "discounts:archive",
|
|
4133
|
+
summary: "Retire a discount code — deactivates, never deletes, because redemption rows reference it",
|
|
4134
|
+
request: z.object({
|
|
4135
|
+
code: z.string().optional().describe("The code to retire. Either this or `cid`."),
|
|
4136
|
+
cid: z.string().optional().describe("The coupon row id. Either this or `code`."),
|
|
4137
|
+
}),
|
|
4138
|
+
response: z.object({ ok: z.boolean().optional(), code: z.string().nullable().optional(), cid: z.string().nullable().optional(), is_active: z.boolean().optional(), error: z.string().optional() }),
|
|
4139
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
4140
|
+
}),
|
|
4141
|
+
// The two cart doors are `auth: "public"` for the same reason the rest of
|
|
4142
|
+
// `cart:*` is: `cid` is a server-minted bearer capability, so an anonymous
|
|
4143
|
+
// shopper holds a discount the way they hold a basket. NO AMOUNT CROSSES THE
|
|
4144
|
+
// WIRE IN EITHER DIRECTION — the shopper sends a code, the cart stores a code,
|
|
4145
|
+
// and the money comes off at mint from the coupon row against a server-priced
|
|
4146
|
+
// basket. A stored amount would be the stale-price exploit under a new name.
|
|
4147
|
+
"cart:apply-discount": receiver({
|
|
4148
|
+
receiver: "cart:apply-discount",
|
|
4149
|
+
summary: "Apply a discount code to a cart — the code is stored, never a computed amount",
|
|
4150
|
+
request: z.object({
|
|
4151
|
+
slug: z.string().describe("Seller workspace. Bound together with the code, so a cart never reaches another seller's sale."),
|
|
4152
|
+
cid: z.string().describe("Server-minted cart id — the bearer capability that authorizes this call."),
|
|
4153
|
+
code: z.string().describe("What the shopper typed. Upper-cased before lookup."),
|
|
4154
|
+
}),
|
|
4155
|
+
response: z.object({
|
|
4156
|
+
cid: z.string().optional(),
|
|
4157
|
+
code: z.string().nullable().optional().describe("The coupon's own code as stored, never the raw input."),
|
|
4158
|
+
name: z.string().optional(),
|
|
4159
|
+
discount_type: z.string().optional().describe("`percentage` or `fixed_cents` — read from the coupon row so a cart can render the offer."),
|
|
4160
|
+
discount_value: z.number().optional().describe("From the coupon row. A value in the request body is ignored."),
|
|
4161
|
+
currency: z.string().nullable().optional(),
|
|
4162
|
+
error: z.string().optional().describe("A refusal BY NAME: `not_found` (unknown or another seller's), `inactive`, `expired`, `exhausted`, `wrong_scope` (a billing/credits code on a cart), `cart_not_found`."),
|
|
4163
|
+
}),
|
|
4164
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
4165
|
+
}),
|
|
4166
|
+
"cart:remove-discount": receiver({
|
|
4167
|
+
receiver: "cart:remove-discount",
|
|
4168
|
+
summary: "Clear the discount code from a cart",
|
|
4169
|
+
request: z.object({
|
|
4170
|
+
slug: z.string().describe("Seller workspace, scoping the cart."),
|
|
4171
|
+
cid: z.string().describe("Server-minted cart id — the bearer capability."),
|
|
4172
|
+
}),
|
|
4173
|
+
response: z.object({ cid: z.string().optional(), code: z.string().nullable().optional().describe("Always null on success."), error: z.string().optional() }),
|
|
4174
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
4175
|
+
}),
|
|
3581
4176
|
// ── orders (the one operator write door) — handler lands in resolvers (C5) ─────
|
|
3582
4177
|
// data-bindings.ts:62 marks `order` create/update/archive as null by design:
|
|
3583
4178
|
// orders are webhook-minted and otherwise read-only. This is the deliberate
|
|
@@ -3590,6 +4185,129 @@ export const RECEIVERS = {
|
|
|
3590
4185
|
response: z.object({ oid: z.string().optional(), workspace: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
|
|
3591
4186
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3592
4187
|
}),
|
|
4188
|
+
// ── orders:mine — the buyer's OWN history (handler: resolvers/storefront/orders-mine.ts) ──
|
|
4189
|
+
// The sibling of orders:status and its opposite: that one is the seller's
|
|
4190
|
+
// single write, this one is the buyer's read of their own purchases.
|
|
4191
|
+
//
|
|
4192
|
+
// `auth: "public"` for the same reason order:claim is — no buyer signs in to a
|
|
4193
|
+
// storefront (orders.workspace is the SELLER; 0177 dropped customer_email and
|
|
4194
|
+
// left customer_uid = SHA-256(lower(email))), so the caller is anonymous and
|
|
4195
|
+
// the proof travels with the request. Declaring it is load-bearing, not
|
|
4196
|
+
// documentation: an UNDECLARED receiver skips bind-receiver's envelope guard
|
|
4197
|
+
// and a flat body validates as {}.
|
|
4198
|
+
//
|
|
4199
|
+
// `token` is the whole identity. It is a MAC this server minted over
|
|
4200
|
+
// (domain, workspace, uid, exp) — lib/orders-link-token.ts — and the handler
|
|
4201
|
+
// RECOMPUTES it, so the buyer id is server-derived, never body-supplied.
|
|
4202
|
+
// There is deliberately NO `customer_uid`, `email` or `buyer` field: a
|
|
4203
|
+
// contract that declares one invites a handler to read it.
|
|
4204
|
+
"orders:mine": receiver({
|
|
4205
|
+
receiver: "orders:mine",
|
|
4206
|
+
summary: "List the buyer's own orders at one shop — identity comes from a signed, short-lived link token, never from the body",
|
|
4207
|
+
request: z.object({
|
|
4208
|
+
workspace: z.string().describe("The SELLER's slug — whose shop these orders were placed at. Bound into the token's MAC, so it narrows the read and cannot widen it."),
|
|
4209
|
+
token: z.string().describe("The signed order-history link token (`<uid>.<exp>.<sig>`), minted server-side and emailed to the address that placed the orders. The ONLY source of buyer identity; forged, tampered or cross-shop tokens are refused before D1 is touched."),
|
|
4210
|
+
oid: z.string().optional().describe("Read ONE order instead of the list. An oid belonging to another buyer answers `not_found` — never `forbidden`, which would confirm the row exists."),
|
|
4211
|
+
cursor: z.string().optional().describe("Keyset cursor `<created_at>:<oid>` from a previous page's `nextCursor`. Absent starts at the newest order."),
|
|
4212
|
+
limit: z.number().int().positive().optional().describe("Page size, default 20, clamped to 50."),
|
|
4213
|
+
}),
|
|
4214
|
+
response: z.object({
|
|
4215
|
+
orders: z.array(z.record(z.string(), z.unknown())).optional().describe("Newest first. Each row: oid, status, amountTotal (cents), currency, createdAt, items[{label, quantity, unitAmount}]. No PII and no payment plumbing — pii_ref, payment_intent, stripe_account and session_id are never selected."),
|
|
4216
|
+
order: z.record(z.string(), z.unknown()).optional().describe("Present instead of `orders` when `oid` was supplied."),
|
|
4217
|
+
total: z.number().optional().describe("EXACT count of this buyer's orders at this shop, over all pages — a COUNT(*), not a floor."),
|
|
4218
|
+
nextCursor: z.string().optional().describe("Present if and only if more rows match. Absent means you have them all; absent is not `false`."),
|
|
4219
|
+
error: z.string().optional().describe("`forbidden` (no/forged/cross-shop token), `link_expired` (a real token past its 30-minute life), `not_found` (an oid that is not this buyer's), `link_not_configured` (the signing secret is unset — fails closed), `workspace_required`, `no_db`."),
|
|
4220
|
+
}),
|
|
4221
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "public",
|
|
4222
|
+
examples: [{ workspace: "acme", token: "<uid>.<exp>.<sig>" }],
|
|
4223
|
+
}),
|
|
4224
|
+
// ── orders:send-link — the MINT orders:mine named and did not build ──────────
|
|
4225
|
+
// (handler: resolvers/storefront/orders-send-link.ts)
|
|
4226
|
+
//
|
|
4227
|
+
// `orders:mine` is authorised by a signed link and nothing else, so until some
|
|
4228
|
+
// door MAILED that link the feature had no reachable caller. Its own threat
|
|
4229
|
+
// table says exactly this — "the link MINT — NOT BUILT IN C8 … This is a gap,
|
|
4230
|
+
// not a defence" — and names why it was held back: this is where the
|
|
4231
|
+
// address-enumeration oracle and the send-amplification live.
|
|
4232
|
+
//
|
|
4233
|
+
// `auth: "public"` for the same reason orders:mine and order:claim are: a
|
|
4234
|
+
// storefront buyer holds no ONE session. Declaring it is load-bearing rather
|
|
4235
|
+
// than documentation — an UNDECLARED receiver skips bind-receiver's envelope
|
|
4236
|
+
// guard and a flat body validates as {}.
|
|
4237
|
+
//
|
|
4238
|
+
// TWO FIELDS, AND THE ABSENCES ARE THE CONTRACT. There is no `workspace`, no
|
|
4239
|
+
// `customer_uid`, no `token` and no `actorId`. The token is minted over the
|
|
4240
|
+
// `slug` THIS CALL NAMED — the same string the D1 read was scoped by — so the
|
|
4241
|
+
// credential can never be wider than the query that earned it, and a declared
|
|
4242
|
+
// identity field would be an invitation for a future handler to prefer it.
|
|
4243
|
+
//
|
|
4244
|
+
// `idempotent: true` is the RATE LIMITER's doing, not a no-op's: a repeat
|
|
4245
|
+
// inside the window is refused rather than re-mailed, and the window is keyed
|
|
4246
|
+
// on the submitted address BEFORE any read — a window that only closed after a
|
|
4247
|
+
// match would make the refusal itself the oracle the response shape avoids.
|
|
4248
|
+
"orders:send-link": receiver({
|
|
4249
|
+
receiver: "orders:send-link",
|
|
4250
|
+
summary: "Mail a buyer a signed, short-lived link to their own order history at one shop — the same answer whether or not the address has orders",
|
|
4251
|
+
request: z.object({
|
|
4252
|
+
slug: z.string().describe("The SELLER's slug — whose shop to look in, and the workspace bound into the minted token's MAC. The read and the credential use this one value, so a link can never open a shop this call did not name."),
|
|
4253
|
+
email: z.string().describe("A LOOKUP KEY, never an identity. Lower-cased and SHA-256'd (lib/identity/ladder.ts emailHash) and matched against `orders.customer_uid` — the only buyer-shaped column, since 0177 dropped `customer_email`. Never stored, never echoed, and the PII vault is never opened: the mail goes to the address that was submitted, which IS the matched address by construction."),
|
|
4254
|
+
}),
|
|
4255
|
+
response: z.object({
|
|
4256
|
+
ok: z.boolean().optional().describe("True for every accepted request. Reaching D1 at all means this is true."),
|
|
4257
|
+
sent: z.boolean().optional().describe("ALWAYS true beside `ok:true`, and it is a statement about the REQUEST, never about a message. A match, a miss, a foreign shop, an unsettled order and a dead mail provider all answer identically — a varying field here would be a membership oracle over the seller's whole customer list, one guess per call."),
|
|
4258
|
+
error: z.string().optional().describe("`rate_limited` (one send per address per 15-minute window, counted BEFORE the lookup so the refusal is not an oracle either), `link_not_configured` (the signing secret is unset — fails closed), `no_db`, or the argument refusal, which is the one refusal safe to distinguish because it says nothing about any order."),
|
|
4259
|
+
}),
|
|
4260
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "public",
|
|
4261
|
+
examples: [{ slug: "acme", email: "buyer@example.com" }],
|
|
4262
|
+
}),
|
|
4263
|
+
// ── orders:fulfil — the parcel left (handler: resolvers/storefront/fulfilment.ts) ──
|
|
4264
|
+
// The SIBLING of orders:status, and deliberately not a value inside it.
|
|
4265
|
+
// `orders:status` refuses 'shipped' on purpose: nine shipped predicates read
|
|
4266
|
+
// `status IN ('paid','active')` — delivery (api/storefront/download.ts:27,
|
|
4267
|
+
// orderClaim) plus seven revenue rollups — so a fulfilment value in that
|
|
4268
|
+
// column would strand a paid buyer's download AND drop the sale out of
|
|
4269
|
+
// revenue, in one write, silently. Fulfilment therefore gets its own axis
|
|
4270
|
+
// (migration 0254) and its own door. The money axis is never touched here.
|
|
4271
|
+
//
|
|
4272
|
+
// NO `slug` AND NO `workspace` IN THE REQUEST, unlike every sibling above.
|
|
4273
|
+
// That is the contract, not an omission: `oid` is a globally unique PK, so a
|
|
4274
|
+
// body-named workspace would be a second, spoofable answer to "whose order is
|
|
4275
|
+
// this". The handler reads the order by `oid`, takes the workspace OFF THE
|
|
4276
|
+
// ROW, and then walks `callerControlsWorkspace(ctx.ownerSlug, row.workspace)`.
|
|
4277
|
+
// An order the caller does not control answers `not_found` — never
|
|
4278
|
+
// `forbidden`, which would be an existence oracle over every other tenant's
|
|
4279
|
+
// order ids.
|
|
4280
|
+
//
|
|
4281
|
+
// `idempotent: true` is a claim the handler earns rather than asserts: a
|
|
4282
|
+
// repeated UPDATE still reports `changes: 1`, so the re-send check is a
|
|
4283
|
+
// SELECT-then-compare on (carrier, tracking_number, tracking_url) before any
|
|
4284
|
+
// write, and `shipped_at` is stamped once with COALESCE. A corrected tracking
|
|
4285
|
+
// number is a real change and does write — and does re-mail.
|
|
4286
|
+
"orders:fulfil": receiver({
|
|
4287
|
+
receiver: "orders:fulfil",
|
|
4288
|
+
summary: "Mark a paid order shipped with its carrier and tracking, and mail the buyer — the fulfilment axis, never orders.status",
|
|
4289
|
+
request: z.object({
|
|
4290
|
+
oid: z.string().describe("The order's primary key. Globally unique; the workspace is read OFF THE ROW, never from the request — there is deliberately no slug field here."),
|
|
4291
|
+
carrier: z.string().min(1).describe("Carrier as the seller names it, e.g. 'UPS', 'An Post'. Free text: a closed list would refuse a regional courier the seller actually used. Required — an empty carrier in a shipped mail is worse than no mail."),
|
|
4292
|
+
tracking_number: z.string().min(1).describe("The carrier's own tracking number. Required, for the same reason as carrier. Stored verbatim; never normalised, because a corrected number must be distinguishable from the original."),
|
|
4293
|
+
tracking_url: z.string().optional().describe("The carrier's tracking page, if the seller has one. Optional and NEVER synthesised from the carrier name — guessing a URL from 'UPS' is how a buyer lands on the wrong page."),
|
|
4294
|
+
}),
|
|
4295
|
+
response: z.object({
|
|
4296
|
+
ok: z.boolean().optional().describe("True when the order is now recorded shipped, including on an idempotent re-send."),
|
|
4297
|
+
oid: z.string().optional(),
|
|
4298
|
+
workspace: z.string().optional().describe("The order's OWN workspace, echoed from the row — confirmation of which tenant was written, never an input."),
|
|
4299
|
+
fulfilment_status: z.string().optional().describe("'unfulfilled' | 'shipped' | 'delivered'. A separate axis from `status`, which stays 'paid'/'active'."),
|
|
4300
|
+
carrier: z.string().nullable().optional(),
|
|
4301
|
+
tracking_number: z.string().nullable().optional(),
|
|
4302
|
+
tracking_url: z.string().nullable().optional(),
|
|
4303
|
+
shipped_at: z.number().nullable().optional().describe("Unix seconds, stamped ONCE on the first fulfil. A corrected tracking number does not move it."),
|
|
4304
|
+
unchanged: z.boolean().optional().describe("True when this call matched what was already recorded: no row was written and no mail was sent. Absent on a real change."),
|
|
4305
|
+
emailed: z.boolean().optional().describe("Whether a shipped email was ATTEMPTED — not whether it arrived. False when the buyer has no address in the vault (a shredded or never-sealed row), which is a normal state and never fails the fulfilment."),
|
|
4306
|
+
error: z.string().optional().describe("'not_found' covers absent, foreign and unattested alike — a distinguishable refusal would confirm an order id exists in some other shop."),
|
|
4307
|
+
}),
|
|
4308
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, settles: "none", auth: "member",
|
|
4309
|
+
examples: [{ oid: "ord_1", carrier: "UPS", tracking_number: "1Z999AA10123456784" }],
|
|
4310
|
+
}),
|
|
3593
4311
|
// ── order:claim — the buyer's delivery door (handler: resolvers/commerce.ts) ──
|
|
3594
4312
|
// Declared so the route's envelope guard fires: an undeclared receiver skips
|
|
3595
4313
|
// the `envelope_missing` check, and a flat body silently validates as {}.
|
|
@@ -3850,7 +4568,22 @@ export const RECEIVERS = {
|
|
|
3850
4568
|
treasury: z.string().optional(),
|
|
3851
4569
|
receipt: z.object({ id: z.string(), url: z.string() }).optional(),
|
|
3852
4570
|
claimed: z.boolean().optional(),
|
|
3853
|
-
stage: z.string().optional().describe("
|
|
4571
|
+
stage: z.string().optional().describe("Where it stopped. create | quote | claim name the pay call that failed; 'authority' means the two-ledger gate refused BEFORE any pay call ran"),
|
|
4572
|
+
// C10 — ONE AUTHORITY, TWO LEDGERS (resolvers/authority.ts). Before a
|
|
4573
|
+
// transfer is RECORDED, the ONE walk and the on-chain spend-ceiling owner
|
|
4574
|
+
// must agree. These keys are how the response SAYS which of the three
|
|
4575
|
+
// things happened, so no reader has to infer it. All optional: a response
|
|
4576
|
+
// carries the pair that matches its branch, never all of them at once.
|
|
4577
|
+
twoLedgerCheck: z.string().optional().describe("'in_force' = the two-ledger check RAN; twoLedgerVerdict says what it found. 'not_in_force' = the check DID NOT RUN — no SPEND_CEILING_READER is bound, or there is no agent-kind wallet — so nothing here was verified against the chain. Never read 'not_in_force' as a pass"),
|
|
4578
|
+
twoLedgerVerdict: z.string().optional().describe("Only when the check RAN: 'agree' — the ONE walk's key IS the on-chain ceiling owner. 'mismatch' and 'unknown' never reach a success response; both refuse at stage 'authority'"),
|
|
4579
|
+
twoLedgerReason: z.string().optional().describe("Only when twoLedgerCheck is 'not_in_force': why the check did not run — reader_unconfigured | no_agent_wallet"),
|
|
4580
|
+
twoLedgerDetail: z.string().optional().describe("Only when twoLedgerCheck is 'not_in_force': plain-language statement of what was NOT verified, so an absent check can never be mistaken for a passed one"),
|
|
4581
|
+
// The stage 'authority' refusal shape (TwoLedgerRefusal). `cause` is what
|
|
4582
|
+
// tells an operator WHY a transfer was refused; left undeclared, a
|
|
4583
|
+
// response parse would strip exactly that.
|
|
4584
|
+
verdict: z.string().optional().describe("On a stage 'authority' refusal: mismatch | unknown. 'unknown' FAILS CLOSED — an unresolvable read is never 'no objection'"),
|
|
4585
|
+
detail: z.string().optional().describe("On a stage 'authority' refusal: what was refused, and plainly that this refuses to RECORD the transfer — it cannot prevent or reverse an on-chain transfer the caller already signed and broadcast client-side"),
|
|
4586
|
+
cause: z.string().optional().describe("On a stage 'authority' refusal, which read decided it: no_attested_caller | no_snapshot_binding | wallet_read_failed | ceiling_unresolvable | owners_disagree | ceiling_revoked. A public identifier, never a value"),
|
|
3854
4587
|
error: z.string().optional(),
|
|
3855
4588
|
}),
|
|
3856
4589
|
effect: "ask", cost: "variable", reversible: false, idempotent: false, settles: "onchain", auth: "manage_workspace",
|
|
@@ -3946,6 +4679,51 @@ export const RECEIVERS = {
|
|
|
3946
4679
|
}),
|
|
3947
4680
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3948
4681
|
}),
|
|
4682
|
+
// ── shipping:* — the seller's shipping table (ecommerce-site C5) ───────────
|
|
4683
|
+
// ONE contiguous block, per the plan's collision map. `shipping_rates`
|
|
4684
|
+
// (migration 0252) is the ONLY source of a shipping amount: the mint reads it
|
|
4685
|
+
// and Stripe is handed `shipping_options[].shipping_rate_data.fixed_amount`
|
|
4686
|
+
// built from these rows. No request body — buyer's or seller's — contributes
|
|
4687
|
+
// a number to a checkout, which is pre-mortem 3 of text/ecommerce-site-plan.md.
|
|
4688
|
+
// Both doors are owner-gated by the owners-tree walk: `slug` NAMES the target,
|
|
4689
|
+
// only the walk (callerControlsWorkspace, ctx.ownerSlug, attested) authorizes.
|
|
4690
|
+
"shipping:set": receiver({
|
|
4691
|
+
receiver: "shipping:set",
|
|
4692
|
+
summary: "Create or update one shipping rate for a workspace — the only writer of a shipping amount",
|
|
4693
|
+
request: z.object({
|
|
4694
|
+
slug: z.string().describe("The workspace that OWNS the rate. Names the target only; the attested caller must control it or the call is refused `forbidden`."),
|
|
4695
|
+
srid: z.string().optional().describe("Update this rate. Absent mints a new one."),
|
|
4696
|
+
name: z.string().optional().describe("Displayed to the buyer in Checkout (Stripe caps display_name at 100 chars). Required when minting."),
|
|
4697
|
+
amountCents: z.number().int().min(0).optional().describe("The shipping charge in cents. Server truth — this is the number that reaches Stripe. Required when minting."),
|
|
4698
|
+
currency: z.string().optional().describe("Three-letter ISO code, lowercased. Defaults to 'usd'."),
|
|
4699
|
+
countries: z.array(z.string()).optional().describe("ISO-3166-1 alpha-2 codes this rate serves. EMPTY = everywhere, and one everywhere-rate unconstrains the session's allowed_countries entirely."),
|
|
4700
|
+
freeOverCents: z.number().int().min(0).nullable().optional().describe("Subtotal in cents at or above which this rate costs 0. null = never free. This is also the threshold FreeShippingProgress renders against."),
|
|
4701
|
+
sort: z.number().int().optional().describe("Display order. Stripe shows at most 5 options per session, so the first five by sort are what a buyer sees."),
|
|
4702
|
+
active: z.boolean().optional().describe("false archives the rate without deleting the row an old order settled against."),
|
|
4703
|
+
}),
|
|
4704
|
+
response: z.object({
|
|
4705
|
+
srid: z.string().optional().describe("The rate written."),
|
|
4706
|
+
rates: z.array(z.record(z.string(), z.unknown())).optional().describe("Every active rate for the workspace after the write, in sort order."),
|
|
4707
|
+
error: z.string().optional().describe("`forbidden` (the caller does not control the workspace), `not_found` (no such srid in this workspace), or a named validation refusal."),
|
|
4708
|
+
}),
|
|
4709
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
4710
|
+
examples: [{ slug: "acme", name: "Standard", amountCents: 599, freeOverCents: 5000 }],
|
|
4711
|
+
}),
|
|
4712
|
+
"shipping:list": receiver({
|
|
4713
|
+
receiver: "shipping:list",
|
|
4714
|
+
summary: "List a workspace's shipping rates — the seller's own table, owner-gated",
|
|
4715
|
+
request: z.object({
|
|
4716
|
+
slug: z.string().describe("The workspace whose rates to read. Names the target only; the attested caller must control it."),
|
|
4717
|
+
includeArchived: z.boolean().optional().describe("Include rows with active = 0. Default false."),
|
|
4718
|
+
}),
|
|
4719
|
+
response: z.object({
|
|
4720
|
+
rates: z.array(z.record(z.string(), z.unknown())).optional().describe("Rates in sort order: { srid, name, amount_cents, currency, countries, free_over_cents, sort, active }."),
|
|
4721
|
+
taxEnabled: z.boolean().optional().describe("Whether this seller has switched Stripe Tax on (owners.storefront_tax_enabled). OFF by default — automatic_tax on an account whose tax setup is incomplete errors or calculates zero."),
|
|
4722
|
+
error: z.string().optional().describe("`forbidden` when the attested caller does not control the workspace."),
|
|
4723
|
+
}),
|
|
4724
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
4725
|
+
examples: [{ slug: "acme" }],
|
|
4726
|
+
}),
|
|
3949
4727
|
// ── view:* — saved views (custom-views C1). A view is a workspace-scoped Thing (world_things type 'view'). ──
|
|
3950
4728
|
"view:create": receiver({
|
|
3951
4729
|
receiver: "view:create",
|
|
@@ -3984,13 +4762,187 @@ export const RECEIVERS = {
|
|
|
3984
4762
|
response: z.object({ ok: z.boolean(), id: z.string().optional(), error: z.string().optional() }),
|
|
3985
4763
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3986
4764
|
}),
|
|
4765
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
4766
|
+
// blocks: — the BLOCK VOCABULARY. Handlers in resolvers/blocks.ts.
|
|
4767
|
+
//
|
|
4768
|
+
// `pages:*` could always DELIVER a page; nothing could say what a page may be
|
|
4769
|
+
// made of. Measured 2026-09-21: `blocks:`, `puck:` and `component:` were all
|
|
4770
|
+
// absent from the 361-receiver catalog, so a client with the whole page rail
|
|
4771
|
+
// still had to guess a component name out of 278 and a prop name out of that
|
|
4772
|
+
// member's own spelling. Both guesses fail SILENTLY — an unknown component is
|
|
4773
|
+
// stored and paints nothing, and a prop from the wrong family member survives
|
|
4774
|
+
// every filter and renders nothing (`@oneie/sdk/blocks`, the 2026-08-19
|
|
4775
|
+
// measurement: six model-written hero fields, zero visible).
|
|
4776
|
+
//
|
|
4777
|
+
// Both doors are PURE PROJECTIONS over `block-manifest.json`, the artifact
|
|
4778
|
+
// `.claude/scripts/blocks-manifest.mjs` generates from the live Puck registry.
|
|
4779
|
+
// Nothing is hand-declared here — a count this door states is a count it
|
|
4780
|
+
// walked, which is why `total` is the entries seen and never the manifest's
|
|
4781
|
+
// own header claim.
|
|
4782
|
+
//
|
|
4783
|
+
// No `auth` label, matching `meta:types`: the registry is first-party code,
|
|
4784
|
+
// identical for every caller, and an omitted label is not the same as a
|
|
4785
|
+
// mis-spelled one (an UNRECOGNISED label falls to `authenticated` — see the
|
|
4786
|
+
// `git:pr` note). Scope is still read off the attested `ctx.ownerSlug` and
|
|
4787
|
+
// never a body field, so the tenant axis is additive rather than a re-auth.
|
|
4788
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
4789
|
+
"blocks:list": receiver({
|
|
4790
|
+
receiver: "blocks:list",
|
|
4791
|
+
surfaces: { mcp: true },
|
|
4792
|
+
summary: "Discover the page blocks that exist — name, category, surface, description and variant list, filterable and paged",
|
|
4793
|
+
request: z.object({
|
|
4794
|
+
category: z.string().optional().describe("Exact category filter: ai · app · auth · content · data · ecommerce · email · learning · marketing · social · wallet. Read `facets.categories` for the live list."),
|
|
4795
|
+
surface: z.string().optional().describe("Exact surface filter: section · container · atomic · app · social-preview — how much chrome the block may carry."),
|
|
4796
|
+
search: z.string().optional().describe("Case-insensitive substring over name + description + category."),
|
|
4797
|
+
limit: z.number().optional().describe("Rows per page. Default 50, max 200."),
|
|
4798
|
+
cursor: z.string().optional().describe("Opaque offset from a previous response's `nextCursor`. The order is by name, so a cursor is stable."),
|
|
4799
|
+
}),
|
|
4800
|
+
response: z.object({
|
|
4801
|
+
ok: z.literal(true),
|
|
4802
|
+
workspace: z.string().nullable().describe("The attested caller's slug, or null when anonymous. Never echoed from the request."),
|
|
4803
|
+
blocks: z.array(z.object({
|
|
4804
|
+
name: z.string(),
|
|
4805
|
+
source: z.enum(["registry", "tenant"]).describe("`registry` is ONE's first-party Puck registry; `tenant` is a composition THIS workspace declared with `blocks:declare`. A tenant key can never shadow a registry name — the declare door refuses one that does."),
|
|
4806
|
+
category: z.string(),
|
|
4807
|
+
surface: z.string(),
|
|
4808
|
+
description: z.string(),
|
|
4809
|
+
icon: z.string(),
|
|
4810
|
+
chatFit: z.string(),
|
|
4811
|
+
pagesUsing: z.number().describe("How many stored pages hold an instance, at the manifest's `measuredAt`. A popularity signal, not a live count — always 0 for a tenant block."),
|
|
4812
|
+
canonicalVariant: z.string().optional().describe("The variant that renders when none is sent. Absent on a block that is not a collapsed family, and on a tenant block."),
|
|
4813
|
+
variants: z.array(z.string()).optional().describe("The variant values this family accepts. Absent on a non-family block."),
|
|
4814
|
+
composedOf: z.array(z.string()).optional().describe("TENANT ONLY — the registry components this composition expands into, in render order."),
|
|
4815
|
+
})),
|
|
4816
|
+
total: z.number().describe("Rows matching the filters BEFORE paging — exact. Tenant rows are counted in it and sort first."),
|
|
4817
|
+
returned: z.number().describe("Rows in this response."),
|
|
4818
|
+
registryTotal: z.number().describe("Every block in the registry, ignoring filters."),
|
|
4819
|
+
tenantTotal: z.number().describe("The caller's own declared blocks matching the filters. 0 for an anonymous caller — this axis is read off the attested slug, never a body field."),
|
|
4820
|
+
measuredAt: z.string().describe("When the manifest this projects was generated."),
|
|
4821
|
+
nextCursor: z.string().optional().describe("Present ⟺ more rows match. Absent means you have them all."),
|
|
4822
|
+
truncated: z.object({
|
|
4823
|
+
rows: z.number().describe("How many matching rows are NOT in this response."),
|
|
4824
|
+
limit: z.number().describe("The budget that bit."),
|
|
4825
|
+
reason: z.literal("page"),
|
|
4826
|
+
}).optional().describe("Present ⟺ rows were cut. ABSENT IS NOT FALSE — an uncut answer carries no `truncated` key."),
|
|
4827
|
+
stale: z.object({ declared: z.number(), actual: z.number() }).optional()
|
|
4828
|
+
.describe("Present ⟺ the manifest's own header count disagrees with its contents. Regenerate with `node .claude/scripts/blocks-manifest.mjs`."),
|
|
4829
|
+
facets: z.object({ categories: z.array(z.string()), surfaces: z.array(z.string()) })
|
|
4830
|
+
.describe("Every value the filters accept, read off the registry — never hand-listed."),
|
|
4831
|
+
}),
|
|
4832
|
+
effect: "ask", cost: "free", idempotent: true, reversible: true,
|
|
4833
|
+
examples: [{ category: "marketing", limit: 20 }],
|
|
4834
|
+
}),
|
|
4835
|
+
"blocks:schema": receiver({
|
|
4836
|
+
receiver: "blocks:schema",
|
|
4837
|
+
surfaces: { mcp: true },
|
|
4838
|
+
summary: "Read one block's full prop contract — per-variant fields, the block's own heading/body/cta/image mapping, and the props the render reads that the editor cannot express",
|
|
4839
|
+
request: z.object({
|
|
4840
|
+
block: z.union([z.string(), z.array(z.string())])
|
|
4841
|
+
.describe("One block name, or up to 10. A retired or family-member name (HeroCentered, Wallet) resolves to its canonical and is reported in `aliasOf` — it is NOT unknown."),
|
|
4842
|
+
}),
|
|
4843
|
+
response: z.object({
|
|
4844
|
+
ok: z.literal(true),
|
|
4845
|
+
workspace: z.string().nullable(),
|
|
4846
|
+
blocks: z.array(z.object({
|
|
4847
|
+
name: z.string().describe("The name as asked."),
|
|
4848
|
+
source: z.enum(["registry", "tenant"]),
|
|
4849
|
+
component: z.string().describe("The REGISTERED component this name draws. Differs from `name` only for an alias, and for a tenant block it is the FIRST member."),
|
|
4850
|
+
aliasOf: z.string().optional().describe("Present ⟺ `name` is a retired or collapsed member name that the render path rewrites."),
|
|
4851
|
+
aliasVariant: z.string().optional(),
|
|
4852
|
+
label: z.string().optional().describe("TENANT ONLY."),
|
|
4853
|
+
composedOf: z.array(z.object({ component: z.string(), props: z.record(z.string(), z.unknown()) })).optional()
|
|
4854
|
+
.describe("TENANT ONLY — the registry blocks this composition expands into, with the props bound to each. There is no synthetic field table for a tenant block: ask `blocks:schema` for `propsLandOn` to learn what a placement may set."),
|
|
4855
|
+
propsLandOn: z.string().optional().describe("TENANT ONLY — the member a placement's own props merge onto (always the first). Stored-wins."),
|
|
4856
|
+
category: z.string().optional().describe("Absent on a tenant block."),
|
|
4857
|
+
surface: z.string().optional().describe("Absent on a tenant block."),
|
|
4858
|
+
description: z.string().optional(),
|
|
4859
|
+
canonicalVariant: z.string().nullable().optional().describe("REGISTRY ONLY — the member that renders when no `variant` prop is sent."),
|
|
4860
|
+
declaredProps: z.array(z.string()).optional().describe("REGISTRY ONLY. Every prop name the collapsed registration accepts. A SUPERSET of what any one variant reads — a prop from the wrong member is accepted and renders nothing, which is why `variants` below is the real answer."),
|
|
4861
|
+
options: z.record(z.string(), z.array(z.unknown())).optional().describe("REGISTRY ONLY — legal values for each enum-shaped prop."),
|
|
4862
|
+
variants: z.record(z.string(), z.object({
|
|
4863
|
+
fields: z.record(z.string(), z.object({
|
|
4864
|
+
type: z.string().optional(),
|
|
4865
|
+
of: z.array(z.string()).optional().describe("For an array field — the keys each ITEM object takes."),
|
|
4866
|
+
options: z.array(z.unknown()).optional(),
|
|
4867
|
+
})).describe("The props the member that renders THIS variant actually reads. Write to these names."),
|
|
4868
|
+
semantics: z.record(z.string(), z.string()).describe("This block's own role → prop map: heading · body · cta · image. `{}` is honest for a Spacer."),
|
|
4869
|
+
renders: z.record(z.string(), z.array(z.string())).optional().describe("Props the render reads that the editor's fields cannot express — nested array item keys, mostly."),
|
|
4870
|
+
})).optional().describe("REGISTRY ONLY — keyed by variant value; the single key '' for a block that is not a collapsed family."),
|
|
4871
|
+
})),
|
|
4872
|
+
unknown: z.array(z.string()).describe("Names the registry does not know, in the caller's own spelling. ALWAYS PRESENT, empty when every name resolved — a caller must be able to branch on this rather than infer it from a short list."),
|
|
4873
|
+
total: z.number().describe("Distinct names asked for."),
|
|
4874
|
+
returned: z.number().describe("Rows in `blocks` — registry and tenant together."),
|
|
4875
|
+
truncated: z.object({
|
|
4876
|
+
rows: z.number(),
|
|
4877
|
+
limit: z.number(),
|
|
4878
|
+
reason: z.literal("names").describe("More than 10 names were asked for; the rest were not read."),
|
|
4879
|
+
}).optional().describe("Present ⟺ names were cut. Absent is not false."),
|
|
4880
|
+
stale: z.object({ declared: z.number(), actual: z.number() }).optional(),
|
|
4881
|
+
}),
|
|
4882
|
+
effect: "ask", cost: "free", idempotent: true, reversible: true,
|
|
4883
|
+
examples: [{ block: "Hero" }, { block: ["Faq", "Pricing"] }],
|
|
4884
|
+
}),
|
|
4885
|
+
// blocks:declare — a TENANT BLOCK is a named composition of existing registry
|
|
4886
|
+
// blocks with bound props. Declarative data, never uploaded code: the manifest
|
|
4887
|
+
// can name only components `classifyBlockName` already knows, so every tenant
|
|
4888
|
+
// block inherits `withChrome`, the 6-token palette and the R2 image field for
|
|
4889
|
+
// free — those are properties of the components it composes.
|
|
4890
|
+
//
|
|
4891
|
+
// No new entity and no new `thing-type`. The manifest rides
|
|
4892
|
+
// `workspace_settings.blocks`, the same JSON-column idiom `world:declare-types`
|
|
4893
|
+
// uses for `manifest` and `theme`/`mcp_settings`/`lenses`/`sources` use beside
|
|
4894
|
+
// it, and it is the precedent this door copies down to the authority rule:
|
|
4895
|
+
// the row is keyed on the ATTESTED caller (`ctx.ownerSlug`), a `group` in the
|
|
4896
|
+
// body is an ADDITIONAL gate that must be a workspace the caller controls, and
|
|
4897
|
+
// it is never the destination. A body slug names a target; it never authorizes
|
|
4898
|
+
// one.
|
|
4899
|
+
//
|
|
4900
|
+
// REPLACE, not patch — read your manifest from `blocks:list` and send the
|
|
4901
|
+
// whole array back. And the write is ALL OR NOTHING: one unknown component
|
|
4902
|
+
// refuses the manifest and names every offender, because a half-landed
|
|
4903
|
+
// vocabulary is a vocabulary that lies about what it can render.
|
|
4904
|
+
//
|
|
4905
|
+
// Expansion happens at the PAGE WRITE boundary (`pages:create`,
|
|
4906
|
+
// `pages:add-block`), not at render, so a stored page holds ordinary registry
|
|
4907
|
+
// blocks and no page render pays a tenant read. The cost is that expansion is
|
|
4908
|
+
// a SNAPSHOT: editing a composition later does not rewrite pages already built
|
|
4909
|
+
// from it.
|
|
4910
|
+
"blocks:declare": receiver({
|
|
4911
|
+
receiver: "blocks:declare",
|
|
4912
|
+
surfaces: { mcp: true },
|
|
4913
|
+
summary: "Declare this workspace's own named block compositions — each one an ordered list of existing registry blocks with bound props. Replaces the whole manifest",
|
|
4914
|
+
request: z.object({
|
|
4915
|
+
blocks: z.array(z.object({
|
|
4916
|
+
key: z.string().describe("The name a page will use. Letters and digits, 2-64 chars, starting with a letter. MUST NOT be a registry or alias name — `Hero` means Hero in every workspace."),
|
|
4917
|
+
label: z.string().optional(),
|
|
4918
|
+
description: z.string().optional(),
|
|
4919
|
+
blocks: z.array(z.object({
|
|
4920
|
+
component: z.string().describe("A registry block name (or an alias, which is stored resolved to its canonical). Anything else refuses the whole write."),
|
|
4921
|
+
props: z.record(z.string(), z.unknown()).describe("The props bound to this member. Write to the names `blocks:schema` reports for the variant that renders."),
|
|
4922
|
+
})).describe("1-8 registry blocks, in render order."),
|
|
4923
|
+
})).describe("The whole manifest, max 50. An empty array clears it."),
|
|
4924
|
+
group: z.string().optional().describe("OPTIONAL extra gate — a workspace the caller must control. It is NEVER the destination: the manifest is always written to the attested caller's own row."),
|
|
4925
|
+
}),
|
|
4926
|
+
response: z.object({
|
|
4927
|
+
ok: z.boolean(),
|
|
4928
|
+
workspace: z.string().optional().describe("The row that was written — the ATTESTED caller, never `group`."),
|
|
4929
|
+
count: z.number().optional().describe("Tenant blocks now declared."),
|
|
4930
|
+
error: z.string().optional(),
|
|
4931
|
+
errors: z.array(z.string()).optional().describe("Every reason the manifest was refused, each naming the offending key or component. Present ⟺ `error` is `invalid_manifest`; nothing was written."),
|
|
4932
|
+
}),
|
|
4933
|
+
effect: "ask", auth: "manage_things", cost: "free", idempotent: true, reversible: true,
|
|
4934
|
+
examples: [{ blocks: [{ key: "AcmeHero", label: "Acme hero", blocks: [{ component: "Hero", props: { variant: "centered", heading: "Move with confidence" } }] }] }],
|
|
4935
|
+
}),
|
|
3987
4936
|
// ── pages + workspace settings — handlers in resolvers/pages.ts ────────────────
|
|
3988
4937
|
"pages:create": receiver({
|
|
3989
4938
|
receiver: "pages:create",
|
|
3990
4939
|
surfaces: { mcp: true },
|
|
3991
4940
|
summary: "Create a workspace page (draft) from a title + sections",
|
|
3992
4941
|
request: z.object({ slug: z.string(), title: z.string(), sections: z.array(z.object({ component: z.string(), props: z.record(z.string(), z.unknown()) })), pageSlug: z.string().optional() }),
|
|
3993
|
-
response: z.object({
|
|
4942
|
+
response: z.object({
|
|
4943
|
+
ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(), error: z.string().optional(),
|
|
4944
|
+
unknownBlocks: z.array(z.string()).optional().describe("Block names in this page that the renderer CANNOT DRAW, in your own spelling. The page was still created — the names are stored and will paint nothing. PRESENT ⟺ at least one name was unknown; absent means every name resolved (absent is not an empty array). A retired or collapsed family member name (HeroCentered, Wallet) is NOT listed: the render path rewrites it. Repair with `pages:update-block`, and read `blocks:list` / `blocks:schema` for the names that exist."),
|
|
4945
|
+
}),
|
|
3994
4946
|
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
3995
4947
|
}),
|
|
3996
4948
|
"pages:edit": receiver({
|
|
@@ -4025,8 +4977,20 @@ export const RECEIVERS = {
|
|
|
4025
4977
|
'pages:publish-pack': receiver({
|
|
4026
4978
|
receiver: "pages:publish-pack",
|
|
4027
4979
|
summary: "Publish all (or a listed subset of) a workspace's draft pack pages in one call — the go-live step. Reuses pages:publish's exact UPDATE, looped once, gated once.",
|
|
4028
|
-
request: z.object({
|
|
4029
|
-
|
|
4980
|
+
request: z.object({
|
|
4981
|
+
slug: z.string(),
|
|
4982
|
+
pages: z.array(z.union([z.string(), z.object({ slug: z.string() })])).optional(),
|
|
4983
|
+
shopPack: z
|
|
4984
|
+
.boolean()
|
|
4985
|
+
.optional()
|
|
4986
|
+
.describe("C9.6 — publish only the pages carrying a shop_role, the storefront go-live in one action. SCOPES the bare call rather than widening it: without it an unrelated half-written draft in the same workspace would go live too. Ignored when `pages` names a subset."),
|
|
4987
|
+
}),
|
|
4988
|
+
response: z.object({
|
|
4989
|
+
ok: z.boolean(),
|
|
4990
|
+
published: z.number().optional(),
|
|
4991
|
+
scope: z.string().optional().describe('"shop" when shopPack narrowed the set; absent means every draft was in scope'),
|
|
4992
|
+
error: z.string().optional(),
|
|
4993
|
+
}),
|
|
4030
4994
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
4031
4995
|
}),
|
|
4032
4996
|
// WHY THIS IS DECLARED. `pages:delete` shipped as a resolver with no registry
|
|
@@ -4078,6 +5042,123 @@ export const RECEIVERS = {
|
|
|
4078
5042
|
// envelope cannot be conditional. publish/unpublish are each other's undo.
|
|
4079
5043
|
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
|
|
4080
5044
|
}),
|
|
5045
|
+
// ── C9 · the shop pack's fill doors (text/ecommerce-site-todo.md § C9) ──
|
|
5046
|
+
// ONE contiguous block, per the plan's collision map: six cycles write to this
|
|
5047
|
+
// file and each owns a block it can rebase whole.
|
|
5048
|
+
//
|
|
5049
|
+
// `pages:instantiate-shop-pack` — the seeder these two fill — IS NOW DECLARED,
|
|
5050
|
+
// immediately below. C9 left it out and said why: declaring it would newly
|
|
5051
|
+
// subject `api/billing/stripe/connect/status.ts:119` to edge zod and an auth
|
|
5052
|
+
// floor. The 11.4 security review measured the cost of leaving it out, which is
|
|
5053
|
+
// larger: an anonymous Origin-only POST to /api/ask/pages:instantiate-shop-pack
|
|
5054
|
+
// reached the RESOLVER (`{"ok":false,"error":"not authorized for \"shop-demo\""}`)
|
|
5055
|
+
// where every declared sibling was stopped at the ENVELOPE with
|
|
5056
|
+
// `required:member, callerRole:anonymous`. An undeclared receiver has no request
|
|
5057
|
+
// schema and no auth label, so `requiresAttestedCaller(undefined)` is false and
|
|
5058
|
+
// the edge waves the call through — one resolver-side check was the whole
|
|
5059
|
+
// defence, and its refusal echoed the target workspace slug back to a caller who
|
|
5060
|
+
// had proved nothing.
|
|
5061
|
+
//
|
|
5062
|
+
// THE LIVE CALLER IS UNAFFECTED, and that is measured rather than hoped.
|
|
5063
|
+
// `auth: "member"` resolves through AUTH_POLICY to the `authenticated` class,
|
|
5064
|
+
// and `applyAuth` (one.ie/web/src/lib/bind-receiver.ts) returns early for any
|
|
5065
|
+
// ctx carrying an ownerSlug; status.ts dispatches with
|
|
5066
|
+
// `{ ownerSlug: slug, viewer: 'owner' }`. Only the `tenant` class throws, and no
|
|
5067
|
+
// label used here is in it. Request validation in the binder is WARN-ONLY, so an
|
|
5068
|
+
// in-process caller is not newly parsed either. Pinned by §5.2 of
|
|
5069
|
+
// tests/unit/pages/instantiate-shop-pack.test.ts, which runs that exact body and
|
|
5070
|
+
// that exact ctx through `bindReceiver` and asserts twelve pages still seed.
|
|
5071
|
+
"pages:instantiate-shop-pack": receiver({
|
|
5072
|
+
receiver: "pages:instantiate-shop-pack",
|
|
5073
|
+
summary: "Seed the whole storefront anatomy as twelve draft pages in one call, each stamped with the job it does. Idempotent by the DATABASE, not by this call: a partial unique index on (workspace, shop_role) holds the at-most-one, so a second call creates nothing and a renamed page is still recognised.",
|
|
5074
|
+
request: z.object({
|
|
5075
|
+
slug: z
|
|
5076
|
+
.string()
|
|
5077
|
+
.optional()
|
|
5078
|
+
.describe("which workspace. NOT an authority claim — the caller is authorized by the attested ctx through the same callerControlsWorkspace walk every pages:* receiver uses. Absent = the caller's own workspace."),
|
|
5079
|
+
}),
|
|
5080
|
+
response: z.object({
|
|
5081
|
+
ok: z.boolean().optional().describe("Describes THE CALL. `false` with a named `error` is the only refusal shape; a partial seed answers false AND carries the pages it managed."),
|
|
5082
|
+
pages: z
|
|
5083
|
+
.array(z.object({
|
|
5084
|
+
pageSlug: z.string().describe("The pack's own id for the page — the proposal, not the stored row."),
|
|
5085
|
+
slug: z.string().describe("The slug the row ACTUALLY carries, read back from the table. On a second call this is the existing `home`, never the `home-2` the minter proposed."),
|
|
5086
|
+
shopRole: z.string().describe("What the page IS (`home`, `faq`, `terms` …). The title is what it is called this week; the role is the identity the index is unique on."),
|
|
5087
|
+
title: z.string(),
|
|
5088
|
+
url: z.string().describe("`/p/<slug>`, built from the read-back slug."),
|
|
5089
|
+
created: z.boolean().describe("TRUE only when THIS call inserted the row. False = it was already there."),
|
|
5090
|
+
}))
|
|
5091
|
+
.optional()
|
|
5092
|
+
.describe("One entry per pack page, in pack order — present even on a partial failure, so a caller can see how far the seed got."),
|
|
5093
|
+
created: z.number().optional().describe("How many rows this call inserted. 0 on every call after the first."),
|
|
5094
|
+
existing: z.number().optional().describe("pages.length - created — the rows that were already there."),
|
|
5095
|
+
slug: z.string().optional().describe("The `home` role page's slug, kept because the live Stripe-Connect caller predates the per-page rows."),
|
|
5096
|
+
url: z.string().optional().describe("The `home` role page's url."),
|
|
5097
|
+
error: z.string().optional().describe("`missing slug`, `no_db`, `no_pack`, `seed_failed:<role>`, or the authority walk's own reason."),
|
|
5098
|
+
}),
|
|
5099
|
+
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
|
|
5100
|
+
}),
|
|
5101
|
+
"pages:fill-shop-pack": receiver({
|
|
5102
|
+
receiver: "pages:fill-shop-pack",
|
|
5103
|
+
summary: "Write a seller's real business facts into their twelve seeded shop pages. Fills the LIVE rows, so an edit made between seeding and filling survives. A fact not supplied keeps its visible placeholder — never an invented threshold, address or returns window.",
|
|
5104
|
+
request: z.object({
|
|
5105
|
+
slug: z
|
|
5106
|
+
.string()
|
|
5107
|
+
.optional()
|
|
5108
|
+
.describe("which workspace. NOT an authority claim — the caller is authorized by the attested ctx through the same callerControlsWorkspace walk every pages:* receiver uses. Absent = the caller's own workspace."),
|
|
5109
|
+
facts: z
|
|
5110
|
+
.object({
|
|
5111
|
+
identity: z.object({
|
|
5112
|
+
businessName: z.string().describe("the shop's name — the one required fact; everything else may be withheld"),
|
|
5113
|
+
whatYouSell: z.string().optional().describe('"Hand-thrown stoneware" — the noun phrase the home hero is built around'),
|
|
5114
|
+
heroImage: z.string().optional().describe("URL of the photograph the home hero is built around — fills the `[Hero image]` token. Withheld = no hero image, never a stock photo of somebody else's product"),
|
|
5115
|
+
founderName: z.string().optional(),
|
|
5116
|
+
foundedYear: z.number().optional(),
|
|
5117
|
+
}),
|
|
5118
|
+
commerce: z.object({
|
|
5119
|
+
currency: z.string().describe("ISO 4217, e.g. EUR. Rendered as a symbol where one is known, else as the code — never a guessed symbol"),
|
|
5120
|
+
freeShippingOver: z.number().optional().describe("order value above which delivery is free. 0 is a REAL answer (free on everything), not 'unknown'"),
|
|
5121
|
+
returnDays: z.number().optional().describe("days a buyer has to return. 0 is a REAL answer (no window), not 'unknown'"),
|
|
5122
|
+
}),
|
|
5123
|
+
contact: z.object({
|
|
5124
|
+
email: z.string().optional(),
|
|
5125
|
+
postalAddress: z.string().optional(),
|
|
5126
|
+
phone: z.string().optional(),
|
|
5127
|
+
openingHours: z.string().optional().describe('"9–17" — rendered inside the pack\'s own "Mon-Fri …" copy'),
|
|
5128
|
+
responseWindow: z.string().optional().describe('"two working days" — how fast a privacy request is answered'),
|
|
5129
|
+
}),
|
|
5130
|
+
policies: z
|
|
5131
|
+
.object({
|
|
5132
|
+
jurisdiction: z.string().optional().describe("whose law governs, and whose data authority a buyer may complain to"),
|
|
5133
|
+
effectiveDate: z.string().optional().describe("as the merchant wants it printed — never derived from the clock"),
|
|
5134
|
+
})
|
|
5135
|
+
.optional(),
|
|
5136
|
+
source: z.object({ url: z.string().optional() }).optional().describe("where the facts came from, when they were extracted rather than typed"),
|
|
5137
|
+
})
|
|
5138
|
+
.describe("ShopBusinessFacts — lib/puck/fill-shop-pack.ts is the type of record"),
|
|
5139
|
+
}),
|
|
5140
|
+
response: z.object({
|
|
5141
|
+
ok: z.boolean(),
|
|
5142
|
+
filled: z.number().optional().describe("how many role pages were rewritten"),
|
|
5143
|
+
roles: z.array(z.string()).optional().describe("which shop_role each rewritten page holds"),
|
|
5144
|
+
error: z.string().optional().describe("`not_seeded` = no page carries a shop_role yet; call pages:instantiate-shop-pack first"),
|
|
5145
|
+
}),
|
|
5146
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
5147
|
+
}),
|
|
5148
|
+
"pages:fill-shop-pack-extract": receiver({
|
|
5149
|
+
receiver: "pages:fill-shop-pack-extract",
|
|
5150
|
+
summary: "Best-effort ShopBusinessFacts from the seller's own site — fetch-then-confirm, never fetch-then-apply. Returns candidate facts for a form to pre-fill; nothing here writes a page. Reuses the movers SSRF guard (shape + fail-closed DNS, re-run on every redirect hop).",
|
|
5151
|
+
request: z.object({
|
|
5152
|
+
slug: z.string().optional().describe("which workspace. Authorized from ctx, never from this field; absent = the caller's own"),
|
|
5153
|
+
url: z.string().describe("the seller's own site. Refused by name as `blocked_host` when the SSRF guard — or any redirect hop's guard — says private"),
|
|
5154
|
+
}),
|
|
5155
|
+
response: z.object({
|
|
5156
|
+
ok: z.boolean(),
|
|
5157
|
+
facts: z.record(z.string(), z.unknown()).optional().describe("CANDIDATE facts for a human to correct — never applied by this call"),
|
|
5158
|
+
error: z.string().optional().describe("`invalid_url` · `blocked_host` · `fetch_failed` · `no_content` · `extract_failed`"),
|
|
5159
|
+
}),
|
|
5160
|
+
effect: "ask", cost: "variable", reversible: true, idempotent: true, auth: "member",
|
|
5161
|
+
}),
|
|
4081
5162
|
"pages:fill-pack": receiver({
|
|
4082
5163
|
receiver: "pages:fill-pack",
|
|
4083
5164
|
summary: "Fill a pack's instantiated pages with real business facts — one call, still drafts. Never fabricates a testimonial; pricing derives from one rate, not typed per page.",
|
|
@@ -4155,6 +5236,234 @@ export const RECEIVERS = {
|
|
|
4155
5236
|
response: z.object({ ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(), restoredTs: z.number().optional(), error: z.string().optional() }),
|
|
4156
5237
|
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
4157
5238
|
}),
|
|
5239
|
+
// ── gap-pages · ten pages:* doors that RESOLVED but were never DECLARED ──────
|
|
5240
|
+
//
|
|
5241
|
+
// Each of these has shipped a handler in `one.ie/web/src/lib/resolvers/pages.ts`
|
|
5242
|
+
// for months and reached it only through the undeclared-receiver escape hatch —
|
|
5243
|
+
// no edge zod parse, no auth floor, and invisible to `meta:catalog`, so the
|
|
5244
|
+
// `pages:` rail under-reported itself by ten.
|
|
5245
|
+
//
|
|
5246
|
+
// WHAT DECLARING TURNS ON, and why each schema below is derived rather than
|
|
5247
|
+
// designed. `/api/ask` dispatches `validation.payload`, which is zod's PARSED
|
|
5248
|
+
// output, so a field a live caller sends that the schema omits is SILENTLY
|
|
5249
|
+
// STRIPPED before the handler reads it. Every shape here was read off the
|
|
5250
|
+
// handler AND off every live caller first:
|
|
5251
|
+
//
|
|
5252
|
+
// channels/src/tools/pages.ts get · children · update-block ·
|
|
5253
|
+
// add-block · remove-block · move-block · rename
|
|
5254
|
+
// components/live-edit/LiveEditShell.tsx get · add-block · update-block
|
|
5255
|
+
// (`runBlockOp` sends `{slug, page, ...opData}`)
|
|
5256
|
+
// components/live-edit/LiveEditSheet.tsx move-block · add-block · remove-block
|
|
5257
|
+
// components/chat/BlockFrame.tsx add-block · components/puck/AddToPage.tsx add-block
|
|
5258
|
+
// components/puck/{MoversIntakeForm,PageCreator}.tsx instantiate-pack
|
|
5259
|
+
// scripts/demo-instantiate-movers.ts instantiate-pack
|
|
5260
|
+
// lib/workflow-templates.ts instantiate-pack (tool step, args `{slug}`)
|
|
5261
|
+
// channels/src/cron.ts optimize (`pages-optimize-tick`, `data: {}`)
|
|
5262
|
+
// pages/api/pages/[slug]/experiments.ts propose (in-process dispatchReceiver)
|
|
5263
|
+
//
|
|
5264
|
+
// `actorId` IS NOT DECLARED, on purpose, and the channels tools send it on
|
|
5265
|
+
// seven of these. It is the exact reasoning `pages:delete` records above:
|
|
5266
|
+
// `pagesDenyReason` authorizes off `ctx` alone and its `data` argument is
|
|
5267
|
+
// documented unused (`resolvers/_shared.ts` — the body-`actorId` fallback was a
|
|
5268
|
+
// spoofable alternate authority path and was DELETED, not patched). Declaring
|
|
5269
|
+
// it would publish a field nothing consumes and invite a caller to think it
|
|
5270
|
+
// authorizes something. It is stripped at the edge and nothing reads it.
|
|
5271
|
+
//
|
|
5272
|
+
// `auth: "member"` on all ten, for `pages:delete`'s reason: it is what every
|
|
5273
|
+
// declared sibling in this family carries, and those siblings are called by the
|
|
5274
|
+
// same channels tools over the same gateway hop, so the floor is measured
|
|
5275
|
+
// rather than reasoned. It resolves to `authenticated` in AUTH_POLICY, so the
|
|
5276
|
+
// in-process binder warns rather than refuses; what it does change is the HTTP
|
|
5277
|
+
// edge, where `requiresAttestedCaller("member")` now stops an ANONYMOUS caller
|
|
5278
|
+
// at the envelope instead of letting it reach the resolver to be refused there
|
|
5279
|
+
// — the same gain `pages:instantiate-shop-pack` was declared for.
|
|
5280
|
+
//
|
|
5281
|
+
// And `member` installs NO per-node floor, which is the mechanical half of why
|
|
5282
|
+
// it is the safe label: it is not one of the 40 `RoleAction` names, so
|
|
5283
|
+
// `roleActionFor()` answers `undefined` and `decide(principal, node, action)` is
|
|
5284
|
+
// never consulted. `update_group` — which the channels tools declare as their
|
|
5285
|
+
// own tool CAPABILITY — IS an action name, and picking it here would have
|
|
5286
|
+
// installed a floor nothing upstream argued for, on top of resolving to
|
|
5287
|
+
// `tenant`, which AUTH_POLICY enforces rather than warns.
|
|
5288
|
+
// ── the four block-addressed mutators (lossless; every untouched block survives)
|
|
5289
|
+
"pages:add-block": receiver({
|
|
5290
|
+
receiver: "pages:add-block",
|
|
5291
|
+
summary: "Insert ONE block into a page at a position (default: append). Every other block is preserved verbatim — the targeted alternative to pages:edit's full-page rewrite.",
|
|
5292
|
+
request: z.object({
|
|
5293
|
+
slug: z.string().describe("The WORKSPACE slug. Names which workspace; it never authorizes one — the caller clears `pagesDenyReason`'s attested walk."),
|
|
5294
|
+
page: z.string().describe("The page's own slug."),
|
|
5295
|
+
section: z.object({
|
|
5296
|
+
component: z.string().describe("A block name. Letters and digits, starting with a letter — anything else is refused as `invalid section`. Any of the registry blocks, not only the CRO subset `pages:edit` allowlists, plus any tenant block `blocks:declare` named."),
|
|
5297
|
+
props: z.record(z.string(), z.unknown()).optional().describe("The block's props, sent VERBATIM — the server runs `normalizeBlockProps` and does not fill defaults for you. Omitted is treated as `{}` by the handler."),
|
|
5298
|
+
}),
|
|
5299
|
+
atIndex: z.number().int().min(0).optional().describe("0-based insert position. Omitted = append at the end."),
|
|
5300
|
+
}),
|
|
5301
|
+
response: z.object({
|
|
5302
|
+
ok: z.boolean(),
|
|
5303
|
+
slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(),
|
|
5304
|
+
blocks: z.number().optional().describe("How many blocks the page holds AFTER the insert."),
|
|
5305
|
+
updatedAt: z.number().optional().describe("The write's timestamp, epoch ms."),
|
|
5306
|
+
unknownBlocks: z.array(z.string()).optional().describe("PRESENT ⟺ the name is one the renderer CANNOT DRAW. The block was still stored and will paint nothing; repair with `pages:update-block`. An ALIASED or collapsed family name (HeroCentered, Wallet) is NOT listed — the render path rewrites it."),
|
|
5307
|
+
expandedFrom: z.string().optional().describe("Present ⟺ `section.component` was a TENANT block; this is the name that was sent."),
|
|
5308
|
+
expandedInto: z.array(z.string()).optional().describe("The registry block names the tenant block expanded into, in order. Expansion is a SNAPSHOT: editing the composition later does not rewrite this page."),
|
|
5309
|
+
error: z.string().optional(),
|
|
5310
|
+
}),
|
|
5311
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
5312
|
+
}),
|
|
5313
|
+
"pages:update-block": receiver({
|
|
5314
|
+
receiver: "pages:update-block",
|
|
5315
|
+
summary: "Merge new props into ONE block by id — a SHALLOW merge, so send only the top-level keys you are changing. Every other block is preserved verbatim.",
|
|
5316
|
+
request: z.object({
|
|
5317
|
+
slug: z.string().describe("The workspace slug."),
|
|
5318
|
+
page: z.string().describe("The page's own slug."),
|
|
5319
|
+
blockId: z.string().describe("The block's id, as `pages:get` reports it in `blocks[]`."),
|
|
5320
|
+
props: z.record(z.string(), z.unknown()).describe("REQUIRED and must be an object — a missing or non-object value is refused `props must be an object`. Shallow-merged over the block's existing props, so a nested object is REPLACED, not deep-merged."),
|
|
5321
|
+
}),
|
|
5322
|
+
response: z.object({
|
|
5323
|
+
ok: z.boolean(),
|
|
5324
|
+
slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(),
|
|
5325
|
+
blocks: z.number().optional(), updatedAt: z.number().optional(),
|
|
5326
|
+
error: z.string().optional(),
|
|
5327
|
+
}),
|
|
5328
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
5329
|
+
}),
|
|
5330
|
+
"pages:remove-block": receiver({
|
|
5331
|
+
receiver: "pages:remove-block",
|
|
5332
|
+
summary: "Drop ONE block from a page by id. Every other block is preserved verbatim. NOT undoable through this door — the page's prior state lives only in a pages:versions snapshot if one was taken.",
|
|
5333
|
+
request: z.object({
|
|
5334
|
+
slug: z.string().describe("The workspace slug."),
|
|
5335
|
+
page: z.string().describe("The page's own slug."),
|
|
5336
|
+
blockId: z.string().describe("The block's id, as `pages:get` reports it in `blocks[]`."),
|
|
5337
|
+
}),
|
|
5338
|
+
response: z.object({
|
|
5339
|
+
ok: z.boolean(),
|
|
5340
|
+
slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(),
|
|
5341
|
+
blocks: z.number().optional(), updatedAt: z.number().optional(),
|
|
5342
|
+
error: z.string().optional(),
|
|
5343
|
+
}),
|
|
5344
|
+
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
5345
|
+
}),
|
|
5346
|
+
"pages:move-block": receiver({
|
|
5347
|
+
receiver: "pages:move-block",
|
|
5348
|
+
summary: "Reorder ONE block to a new index. Every block keeps its props; only the order changes.",
|
|
5349
|
+
request: z.object({
|
|
5350
|
+
slug: z.string().describe("The workspace slug."),
|
|
5351
|
+
page: z.string().describe("The page's own slug."),
|
|
5352
|
+
blockId: z.string().describe("The block's id, as `pages:get` reports it in `blocks[]`."),
|
|
5353
|
+
toIndex: z.number().int().min(0).describe("0-based destination index."),
|
|
5354
|
+
}),
|
|
5355
|
+
response: z.object({
|
|
5356
|
+
ok: z.boolean(),
|
|
5357
|
+
slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(),
|
|
5358
|
+
blocks: z.number().optional(), updatedAt: z.number().optional(),
|
|
5359
|
+
error: z.string().optional(),
|
|
5360
|
+
}),
|
|
5361
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
5362
|
+
}),
|
|
5363
|
+
// ── the two block-tree reads ────────────────────────────────────────────────
|
|
5364
|
+
"pages:get": receiver({
|
|
5365
|
+
receiver: "pages:get",
|
|
5366
|
+
summary: "Read one page as the EDITOR sees it — title, the lossless blocks[] (every block, every type, with its id) and the legacy CRO sections[]. Drafts included; call this before any block-addressed edit. pages:view is the public, published-only read.",
|
|
5367
|
+
request: z.object({
|
|
5368
|
+
slug: z.string().describe("The workspace slug."),
|
|
5369
|
+
page: z.string().describe("The page's own slug."),
|
|
5370
|
+
}),
|
|
5371
|
+
response: z.object({
|
|
5372
|
+
ok: z.boolean(),
|
|
5373
|
+
slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(),
|
|
5374
|
+
updatedAt: z.number().optional(),
|
|
5375
|
+
blocks: z.array(z.object({ id: z.string(), type: z.string(), props: z.record(z.string(), z.unknown()) })).optional().describe("THE LOSSLESS READ — every block on the page including the types the CRO `sections` schema cannot carry. `id` is what `pages:update-block` / `remove-block` / `move-block` target."),
|
|
5376
|
+
sections: z.array(z.object({ component: z.string(), props: z.record(z.string(), z.unknown()) })).optional().describe("The legacy CRO-subset view, derived from the canonical blob. LOSSY by design — a non-CRO block is absent here and present in `blocks[]`. Never edit from this."),
|
|
5377
|
+
error: z.string().optional(),
|
|
5378
|
+
}),
|
|
5379
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
5380
|
+
}),
|
|
5381
|
+
"pages:children": receiver({
|
|
5382
|
+
receiver: "pages:children",
|
|
5383
|
+
summary: "Walk a page's own block tree and return every ChildPageBlock's target slug. ONE LEVEL ONLY — never transitive into the embedded page's own tree.",
|
|
5384
|
+
request: z.object({
|
|
5385
|
+
slug: z.string().describe("The workspace slug."),
|
|
5386
|
+
page: z.string().describe("The page's own slug."),
|
|
5387
|
+
}),
|
|
5388
|
+
response: z.object({
|
|
5389
|
+
ok: z.boolean(),
|
|
5390
|
+
slug: z.string().optional(),
|
|
5391
|
+
childPages: z.array(z.string()).optional().describe("The page slugs this page embeds, one level deep."),
|
|
5392
|
+
childBlocks: z.array(z.object({ id: z.string(), type: z.string() })).optional().describe("The blocks that carry those embeds, so a caller can address one."),
|
|
5393
|
+
error: z.string().optional(),
|
|
5394
|
+
}),
|
|
5395
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
5396
|
+
}),
|
|
5397
|
+
// ── address + pack + the experiment ratchet ─────────────────────────────────
|
|
5398
|
+
"pages:rename": receiver({
|
|
5399
|
+
receiver: "pages:rename",
|
|
5400
|
+
summary: "Change a page's URL slug. OLD LINKS BREAK — the page moves to /p/<newSlug> and the old path serves nothing.",
|
|
5401
|
+
request: z.object({
|
|
5402
|
+
slug: z.string().describe("The workspace slug."),
|
|
5403
|
+
page: z.string().describe("The page's CURRENT slug."),
|
|
5404
|
+
newSlug: z.string().describe("The new slug. The collision check is GLOBAL, not per-workspace (`/p/<slug>` resolves by slug alone), so a slug any workspace already serves is refused `slug_taken`. Renaming to the same slug is refused `slug_unchanged`."),
|
|
5405
|
+
}),
|
|
5406
|
+
response: z.object({
|
|
5407
|
+
ok: z.boolean(),
|
|
5408
|
+
slug: z.string().optional().describe("The NEW slug on success."),
|
|
5409
|
+
title: z.string().optional(), url: z.string().optional(), status: z.string().optional(),
|
|
5410
|
+
error: z.string().optional(),
|
|
5411
|
+
}),
|
|
5412
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
5413
|
+
}),
|
|
5414
|
+
"pages:instantiate-pack": receiver({
|
|
5415
|
+
receiver: "pages:instantiate-pack",
|
|
5416
|
+
summary: "Seed the whole movers anatomy as ten draft pages in ONE call. NOT idempotent — a second call creates a second set, deduped by slug (home → home-2). pages:instantiate-shop-pack is the storefront's role-keyed equivalent, and that one IS idempotent.",
|
|
5417
|
+
request: z.object({
|
|
5418
|
+
slug: z.string().describe("The workspace to seed. Names which workspace; the attested `callerControlsWorkspace` walk decides whether this caller may."),
|
|
5419
|
+
}),
|
|
5420
|
+
response: z.object({
|
|
5421
|
+
ok: z.boolean(),
|
|
5422
|
+
pages: z.array(z.object({
|
|
5423
|
+
pageSlug: z.string().describe("The pack's own key for this page — STABLE across calls."),
|
|
5424
|
+
slug: z.string().describe("The real slug that was minted, which differs from `pageSlug` whenever one was already taken."),
|
|
5425
|
+
title: z.string(),
|
|
5426
|
+
url: z.string(),
|
|
5427
|
+
})).optional().describe("The created pages, in MOVERS_PACK order. `pages:fill-pack` takes this array unchanged — same field names, no reshape step exists in the eight workflow kinds."),
|
|
5428
|
+
error: z.string().optional(),
|
|
5429
|
+
}),
|
|
5430
|
+
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
5431
|
+
}),
|
|
5432
|
+
"pages:optimize": receiver({
|
|
5433
|
+
receiver: "pages:optimize",
|
|
5434
|
+
summary: "The page-experiment ratchet: decide promote / dissolve / keep for every active experiment the caller owns. The hourly `pages-optimize-tick` cron fires this with an empty body.",
|
|
5435
|
+
request: z.object({
|
|
5436
|
+
page: z.string().optional().describe("Narrow to one page's experiments. Omitted = every active experiment the caller owns. THE WORKSPACE IS NOT A FIELD — it is always `ctx.ownerSlug`, so a body cannot aim this at someone else's pages."),
|
|
5437
|
+
}),
|
|
5438
|
+
response: z.object({
|
|
5439
|
+
ok: z.boolean(),
|
|
5440
|
+
workspace: z.string().optional().describe("The workspace that was scanned — the ATTESTED caller, never a body field."),
|
|
5441
|
+
receipts: z.array(z.object({
|
|
5442
|
+
page_slug: z.string(), prop_path: z.string(),
|
|
5443
|
+
action: z.string().describe("`promote` (the winner was written into the page), `dissolve` (the experiment was closed with no winner) or `keep` (nothing changed and no path was marked)."),
|
|
5444
|
+
variant: z.string().optional(), lift_pct: z.number().optional(),
|
|
5445
|
+
})).optional().describe("One row per active experiment considered. An empty array means nothing was active, never that the run failed."),
|
|
5446
|
+
error: z.string().optional(),
|
|
5447
|
+
}),
|
|
5448
|
+
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
5449
|
+
}),
|
|
5450
|
+
"pages:propose": receiver({
|
|
5451
|
+
receiver: "pages:propose",
|
|
5452
|
+
summary: "Fill an active experiment's variant pool with up to five LLM-written plain-text candidates. Runs OFFLINE — the render path never calls a model.",
|
|
5453
|
+
request: z.object({
|
|
5454
|
+
slug: z.string().describe("The workspace slug."),
|
|
5455
|
+
page: z.string().describe("The page's own slug."),
|
|
5456
|
+
prop_path: z.string().describe("The experiment's prop path — SNAKE_CASE, matching the `page_experiments` column, not a camelCase spelling."),
|
|
5457
|
+
count: z.number().optional().describe("How many candidates to ask for. CLAMPED to 5; the handler defaults to 3 when absent or non-numeric. The pool itself is capped at 5 total, so fewer may be added than requested."),
|
|
5458
|
+
}),
|
|
5459
|
+
response: z.object({
|
|
5460
|
+
ok: z.boolean(),
|
|
5461
|
+
page_slug: z.string().optional(), prop_path: z.string().optional(),
|
|
5462
|
+
added: z.number().optional().describe("Candidates actually added to the pool, after the 5-total cap. `0` is impossible — an empty model answer is refused `no_candidates`."),
|
|
5463
|
+
error: z.string().optional().describe("`no_active_experiment` · `llm_failed` · `no_candidates` — each a refusal with nothing written."),
|
|
5464
|
+
}),
|
|
5465
|
+
effect: "ask", cost: "variable", reversible: true, idempotent: false, auth: "member",
|
|
5466
|
+
}),
|
|
4158
5467
|
"mover:agent-context": receiver({
|
|
4159
5468
|
receiver: "mover:agent-context",
|
|
4160
5469
|
summary: "The fact→agent bridge — render MoverBusinessFacts into ground-truth prose and write it to the workspace's company_context, so the mounted chat agent answers as the operator's real business. Never invents a service/review/policy; same facts as pages:fill-pack, one intake, two artifacts.",
|
|
@@ -4818,6 +6127,29 @@ export const RECEIVERS = {
|
|
|
4818
6127
|
auth: "manage_workflows",
|
|
4819
6128
|
examples: [{ name: "Client Onboarding" }, { name: "demo", simulate: true }],
|
|
4820
6129
|
}),
|
|
6130
|
+
"workflow:publish": receiver({
|
|
6131
|
+
receiver: "workflow:publish",
|
|
6132
|
+
surfaces: { mcp: true },
|
|
6133
|
+
summary: "Bind a workflow the caller OWNS to a priced market listing. The seller is DERIVED from the workflow's own workspace through the attested ctx — there is deliberately no sellerUid field to spoof.",
|
|
6134
|
+
request: z.object({
|
|
6135
|
+
workflowId: z.string(),
|
|
6136
|
+
price: z.number().nonnegative().optional(), // credits; 0 = free. Defaults to 0.
|
|
6137
|
+
name: z.string().optional(), // defaults to the workflow's own name
|
|
6138
|
+
tags: z.array(z.string()).optional(), // `workflow` is always added
|
|
6139
|
+
}),
|
|
6140
|
+
response: z.object({
|
|
6141
|
+
ok: z.boolean(),
|
|
6142
|
+
sid: z.string().optional(), // `${sellerUid}:workflow:${workflowId}` — seller-scoped, so two creators never collapse into one card
|
|
6143
|
+
sellerUid: z.string().optional(), // the workflow's own workspace, never a body field
|
|
6144
|
+
skillId: z.string().optional(), // `workflow:<workflowId>` — namespaced so it cannot collide with a skill listing
|
|
6145
|
+
price: z.number().optional(),
|
|
6146
|
+
kind: z.string().optional(), // always "listing" — no sixth ItemKind (plan § 8)
|
|
6147
|
+
error: z.string().optional(), // 'not_found' (absent OR not yours — never an existence oracle) | 'no_db' | 'name_required'
|
|
6148
|
+
}),
|
|
6149
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, simulatable: false,
|
|
6150
|
+
auth: "manage_workflows",
|
|
6151
|
+
examples: [{ workflowId: "wf_abc", price: 25 }],
|
|
6152
|
+
}),
|
|
4821
6153
|
"workflow:webhook-subscribe": receiver({
|
|
4822
6154
|
receiver: "workflow:webhook-subscribe",
|
|
4823
6155
|
summary: "Register a URL that receives every future run's events for one workflow as signed, retried POSTs. Mints a signing secret server-side, returned once.",
|
|
@@ -4837,7 +6169,9 @@ export const RECEIVERS = {
|
|
|
4837
6169
|
}),
|
|
4838
6170
|
"workflow:apply-diff": receiver({
|
|
4839
6171
|
receiver: "workflow:apply-diff",
|
|
4840
|
-
|
|
6172
|
+
// Named, not derived: the derived `workflow_apply-diff` duplicated the
|
|
6173
|
+
// hand-written `workflow_apply_diff` under a second separator.
|
|
6174
|
+
surfaces: { mcp: { name: "workflow_apply_diff" } },
|
|
4841
6175
|
summary: "Apply a WorkflowDiff (add/remove/connect/disconnect/update). simulate=true validates the DAG without persisting",
|
|
4842
6176
|
request: z.object({
|
|
4843
6177
|
workflowId: z.string(),
|
|
@@ -4880,10 +6214,14 @@ export const RECEIVERS = {
|
|
|
4880
6214
|
summary: "Start a run — spawns the WorkflowRun DO, executes each step, marks path strength on traversed edges",
|
|
4881
6215
|
request: z.object({
|
|
4882
6216
|
workflowId: z.string(),
|
|
6217
|
+
// Undeclared, zod STRIPPED this and the run fell back to the caller's own
|
|
6218
|
+
// workspace: a staff run of group `david`'s workflow was minted under `tony`,
|
|
6219
|
+
// the DO refused the mismatched graph, and the row sat `open` forever.
|
|
6220
|
+
slug: z.string().optional().describe("Workspace whose workflow to run; honoured only if you control it. Omit for your own. Refused (forbidden), never swapped for your own."),
|
|
4883
6221
|
triggerPayload: z.record(z.string(), z.unknown()).optional(),
|
|
4884
6222
|
idempotencyKey: z.string().optional(),
|
|
4885
6223
|
}),
|
|
4886
|
-
response: z.object({ runId: z.string(), status: z.string(), error: z.string().optional() }),
|
|
6224
|
+
response: z.object({ runId: z.string(), status: z.string().describe("open | done | paused | failed — never `open` for a run whose executor refused to start it"), error: z.string().optional() }),
|
|
4887
6225
|
effect: "ask", cost: "variable", reversible: false, idempotent: true, simulatable: false, settles: "none",
|
|
4888
6226
|
auth: "manage_workflows",
|
|
4889
6227
|
examples: [{ workflowId: "wf_abc" }],
|
|
@@ -5111,6 +6449,11 @@ export const RECEIVERS = {
|
|
|
5111
6449
|
actorId: z.string().optional(),
|
|
5112
6450
|
skill: z.string().optional(),
|
|
5113
6451
|
instructions: z.string().optional(),
|
|
6452
|
+
model: z
|
|
6453
|
+
.string()
|
|
6454
|
+
.max(64)
|
|
6455
|
+
.optional()
|
|
6456
|
+
.describe("Run this turn on a named model (an OpenRouter id, e.g. 'moonshotai/kimi-k3') instead of the actor's own. VETTED server-side against the workspace's enabledModelIds — a claim outside that list degrades to the operator's configured model, never to the claim. Absent = the actor's model, unchanged."),
|
|
5114
6457
|
}).catchall(z.unknown()), // extra keys = the agent's input
|
|
5115
6458
|
response: z.object({ ok: z.boolean(), text: z.string().optional(), actorId: z.string().optional(), error: z.string().optional() }),
|
|
5116
6459
|
effect: "ask", cost: "variable", reversible: false, idempotent: false, simulatable: false, settles: "none", auth: "manage_workflows",
|
|
@@ -5647,6 +6990,190 @@ export const RECEIVERS = {
|
|
|
5647
6990
|
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
5648
6991
|
examples: [{ actorIds: ["u-1", "u-2"], destination: "/launch", campaignId: "q3" }],
|
|
5649
6992
|
}),
|
|
6993
|
+
// ── tags: family — a workspace's tag NAMESPACES ───────────────────────────
|
|
6994
|
+
// WHY. A tag is a bare word and a namespace is the closed set a workspace has
|
|
6995
|
+
// agreed on — the same vocabulary an agent's `subscribes:` stake is written
|
|
6996
|
+
// in. It was reachable only from a browser (`/api/tags`), so `meta:catalog`
|
|
6997
|
+
// could not tell an agent the words its own workspace uses. Handler:
|
|
6998
|
+
// one.ie/web/src/lib/resolvers/tags.ts; both surfaces call lib/in/tags.ts.
|
|
6999
|
+
"tags:list": receiver({
|
|
7000
|
+
receiver: "tags:list",
|
|
7001
|
+
summary: "List a workspace's tag namespaces, with the platform's LOCKED namespaces beside them. `workspace` names which workspace; the caller's authority over it is walked from the attested session, never from the field.",
|
|
7002
|
+
request: z.object({
|
|
7003
|
+
workspace: z.string().optional().describe("Workspace slug to read; defaults to the caller's own. The caller must control it or a 'forbidden' is returned"),
|
|
7004
|
+
}),
|
|
7005
|
+
response: z.object({
|
|
7006
|
+
ok: z.boolean(),
|
|
7007
|
+
workspace: z.string().optional(),
|
|
7008
|
+
namespaces: z.array(z.unknown()).optional(),
|
|
7009
|
+
locked: z.array(z.string()).optional().describe("Platform-reserved namespace names — substrate vocabulary, not tenant vocabulary; a create naming one is refused"),
|
|
7010
|
+
error: z.string().optional(),
|
|
7011
|
+
}),
|
|
7012
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
7013
|
+
examples: [{ workspace: "acme" }],
|
|
7014
|
+
}),
|
|
7015
|
+
"tags:create": receiver({
|
|
7016
|
+
receiver: "tags:create",
|
|
7017
|
+
summary: "Create a tag namespace in a workspace. Refuses a LOCKED name — those are substrate vocabulary, not tenant vocabulary. A re-create is IDEMPOTENT and returns the existing row (the insert is ON CONFLICT DO NOTHING), the same answer `POST /api/tags` gives; `not_created` means the row could not be read back at all, never that it already existed.",
|
|
7018
|
+
request: z.object({
|
|
7019
|
+
workspace: z.string().optional().describe("Workspace slug to create in; defaults to the caller's own"),
|
|
7020
|
+
namespace: z.string().describe("The namespace, one bare word — never `lifecycle:marketing`"),
|
|
7021
|
+
values: z.array(z.string()).optional().describe("Closed set of allowed values; omit for a free namespace"),
|
|
7022
|
+
colour: z.string().optional(),
|
|
7023
|
+
}),
|
|
7024
|
+
response: z.object({ ok: z.boolean(), namespace: z.unknown().optional(), error: z.string().optional() }),
|
|
7025
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
7026
|
+
examples: [{ workspace: "acme", namespace: "industry", values: ["saas", "retail"] }],
|
|
7027
|
+
}),
|
|
7028
|
+
"tags:delete": receiver({
|
|
7029
|
+
receiver: "tags:delete",
|
|
7030
|
+
summary: "Delete a tag namespace by id (`<workspace>:<namespace>`). The authority walk runs against the ROW's stored workspace, never the id's prefix — an id is caller-supplied and may not name its own owner. A locked namespace is refused.",
|
|
7031
|
+
request: z.object({ id: z.string().describe("Namespace row id, `<workspace>:<namespace>`") }),
|
|
7032
|
+
response: z.object({ ok: z.boolean(), id: z.string().optional(), error: z.string().optional() }),
|
|
7033
|
+
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
|
|
7034
|
+
examples: [{ id: "acme:industry" }],
|
|
7035
|
+
}),
|
|
7036
|
+
"tags:suggest": receiver({
|
|
7037
|
+
receiver: "tags:suggest",
|
|
7038
|
+
summary: "Ad-hoc tags used more often than `threshold` in a workspace — the candidates an owner may promote into a namespace. Capped at 100 rows by the query.",
|
|
7039
|
+
request: z.object({
|
|
7040
|
+
workspace: z.string().optional().describe("Workspace slug to read; defaults to the caller's own"),
|
|
7041
|
+
threshold: z.number().optional().describe("Minimum usage count; default 20. A negative or non-numeric value falls back to 20 rather than returning everything"),
|
|
7042
|
+
}),
|
|
7043
|
+
response: z.object({
|
|
7044
|
+
ok: z.boolean(),
|
|
7045
|
+
workspace: z.string().optional(),
|
|
7046
|
+
threshold: z.number().optional(),
|
|
7047
|
+
suggestions: z.array(z.unknown()).optional(),
|
|
7048
|
+
error: z.string().optional(),
|
|
7049
|
+
}),
|
|
7050
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
7051
|
+
examples: [{ workspace: "acme", threshold: 20 }],
|
|
7052
|
+
}),
|
|
7053
|
+
// ── templates: family — a workspace's saved signal templates ──────────────
|
|
7054
|
+
// WHY. An agent that can `signal(...)` could not ask what its workspace had
|
|
7055
|
+
// already agreed to SAY. Handler: one.ie/web/src/lib/resolvers/templates.ts;
|
|
7056
|
+
// both surfaces call lib/in/templates.ts.
|
|
7057
|
+
"templates:list": receiver({
|
|
7058
|
+
receiver: "templates:list",
|
|
7059
|
+
summary: "List a workspace's signal templates — the saved composer pre-fills, newest starred first. Optional role and free-text filters.",
|
|
7060
|
+
request: z.object({
|
|
7061
|
+
workspace: z.string().optional().describe("Workspace slug to read; defaults to the caller's own"),
|
|
7062
|
+
role: z.enum(["marketer", "sales", "service", "owner"]).optional(),
|
|
7063
|
+
search: z.string().optional().describe("Substring matched against name and body"),
|
|
7064
|
+
}),
|
|
7065
|
+
response: z.object({
|
|
7066
|
+
ok: z.boolean(),
|
|
7067
|
+
workspace: z.string().optional(),
|
|
7068
|
+
templates: z.array(z.unknown()).optional(),
|
|
7069
|
+
error: z.string().optional(),
|
|
7070
|
+
}),
|
|
7071
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
7072
|
+
examples: [{ workspace: "acme", role: "sales" }],
|
|
7073
|
+
}),
|
|
7074
|
+
"templates:save": receiver({
|
|
7075
|
+
receiver: "templates:save",
|
|
7076
|
+
summary: "Create or update one signal template. `workspace_id` is stamped from the EXISTING row when the id already exists, otherwise from the walked workspace — a body can never file a template into a workspace the caller does not control. The `_starter` catalogue is read-only.",
|
|
7077
|
+
request: z.object({
|
|
7078
|
+
id: z.string().describe("Stable template id; an existing id updates that row in its own workspace"),
|
|
7079
|
+
name: z.string(),
|
|
7080
|
+
workspace: z.string().optional().describe("Workspace to file a NEW template under; ignored when the id already exists"),
|
|
7081
|
+
role: z.enum(["marketer", "sales", "service", "owner"]).optional().describe("Default 'owner'"),
|
|
7082
|
+
receiverMode: z.enum(["direct", "world", "all", "sub"]).optional().describe("Default 'direct'"),
|
|
7083
|
+
receiverTarget: z.string().optional(),
|
|
7084
|
+
tags: z.array(z.string()).optional().describe("BARE words — never `lifecycle:marketing`"),
|
|
7085
|
+
body: z.string().optional(),
|
|
7086
|
+
sendAt: z.string().optional().describe("'now' | '+24h' | an ISO datetime; default 'now'"),
|
|
7087
|
+
attach: z.array(z.string()).optional(),
|
|
7088
|
+
starred: z.boolean().optional(),
|
|
7089
|
+
createdAt: z.number().optional(),
|
|
7090
|
+
}),
|
|
7091
|
+
response: z.object({ ok: z.boolean(), id: z.string().optional(), workspace: z.string().optional(), error: z.string().optional() }),
|
|
7092
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
7093
|
+
examples: [{ id: "tpl-welcome", name: "Welcome", role: "sales", body: "Hi {{name}} —", tags: ["lead"] }],
|
|
7094
|
+
}),
|
|
7095
|
+
"templates:delete": receiver({
|
|
7096
|
+
receiver: "templates:delete",
|
|
7097
|
+
summary: "Delete one signal template by id. The authority walk runs against the ROW's stored workspace, never a body field. The `_starter` catalogue is read-only.",
|
|
7098
|
+
request: z.object({ id: z.string() }),
|
|
7099
|
+
response: z.object({ ok: z.boolean(), id: z.string().optional(), error: z.string().optional() }),
|
|
7100
|
+
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
|
|
7101
|
+
examples: [{ id: "tpl-welcome" }],
|
|
7102
|
+
}),
|
|
7103
|
+
// ── rewards: family — the referral binding ────────────────────────────────
|
|
7104
|
+
// ALREADY RUNNING, now declared. `pay/backend/src/rewards.ts enrollReferrer`
|
|
7105
|
+
// has dispatched this since the rewards rail shipped; it was absent from the
|
|
7106
|
+
// registry, so `meta:catalog` did not list the referral door at all. The
|
|
7107
|
+
// request shape below is READ OFF THAT CALLER — it posts `{tags, content}`,
|
|
7108
|
+
// and zod returns its PARSED output, so a schema that omitted `content` would
|
|
7109
|
+
// strip the two wallet addresses and bind nothing.
|
|
7110
|
+
"rewards:enroll": receiver({
|
|
7111
|
+
receiver: "rewards:enroll",
|
|
7112
|
+
summary: "Bind a referrer wallet to a referred wallet at signup, as a weighted Path written with `mark`. FIRST BINDING WINS — an existing inbound path to the referred wallet is never re-routed, so a later spoof cannot redirect a payout. Accepts the two addresses either flat or under `content` (the shape pay/backend sends). Self-referral is refused.",
|
|
7113
|
+
request: z.object({
|
|
7114
|
+
content: z.object({
|
|
7115
|
+
referrer: z.string().describe("Referrer wallet address"),
|
|
7116
|
+
referred: z.string().describe("Referred wallet address"),
|
|
7117
|
+
}).optional().describe("The nested shape pay/backend's enrollReferrer posts"),
|
|
7118
|
+
referrer: z.string().optional().describe("Referrer wallet address, flat form"),
|
|
7119
|
+
referred: z.string().optional().describe("Referred wallet address, flat form"),
|
|
7120
|
+
tags: z.array(z.string()).optional().describe("Carried through from the signal envelope; not read by the handler"),
|
|
7121
|
+
}),
|
|
7122
|
+
response: z.object({
|
|
7123
|
+
ok: z.boolean(),
|
|
7124
|
+
bound: z.enum(["created", "existing"]).optional().describe("`existing` = the referred wallet was already bound and nothing moved"),
|
|
7125
|
+
referrer: z.string().optional(),
|
|
7126
|
+
referred: z.string().optional(),
|
|
7127
|
+
error: z.string().optional(),
|
|
7128
|
+
}),
|
|
7129
|
+
effect: "signal", cost: "free", reversible: false, idempotent: true, auth: "agent_key",
|
|
7130
|
+
examples: [{ content: { referrer: "0xabc…", referred: "0xdef…" }, tags: ["rewards", "enroll"] }],
|
|
7131
|
+
}),
|
|
7132
|
+
// ── sui: family — the Move-contract vocabulary + lifecycle read ───────────
|
|
7133
|
+
// ALREADY RUNNING, now declared. `components/activity/SuiSubstrate.tsx` calls
|
|
7134
|
+
// `/api/ask/sui:vocabulary` and `/api/ask/sui:lifecycle` with an empty body
|
|
7135
|
+
// from an ANONYMOUS page, which is why both are labelled `public`: they return
|
|
7136
|
+
// the embedded vocabulary and a testnet run snapshot, no tenant data.
|
|
7137
|
+
"sui:vocabulary": receiver({
|
|
7138
|
+
receiver: "sui:vocabulary",
|
|
7139
|
+
summary: "The ONE vocabulary as the Sui Move contracts express it — the 6 dimensions and 6 verbs with their TQL and Move names, plus the Move module list. Read-only, no arguments, no tenant data.",
|
|
7140
|
+
request: z.object({}),
|
|
7141
|
+
response: z.object({ ok: z.boolean(), vocabulary: z.unknown().optional(), modules: z.array(z.unknown()).optional() }),
|
|
7142
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
7143
|
+
examples: [{}],
|
|
7144
|
+
}),
|
|
7145
|
+
"sui:lifecycle": receiver({
|
|
7146
|
+
receiver: "sui:lifecycle",
|
|
7147
|
+
summary: "The latest lifecycle journey run on Sui — one transaction per stage with its explorer digest. Served from KV when the simulator has written one (`source:'kv'`), otherwise the embedded testnet snapshot. Read-only, no arguments.",
|
|
7148
|
+
request: z.object({}),
|
|
7149
|
+
response: z.object({
|
|
7150
|
+
ok: z.boolean(),
|
|
7151
|
+
source: z.string().optional().describe("`kv` = a live simulator run; absent = the embedded snapshot"),
|
|
7152
|
+
explorer: z.string().optional(),
|
|
7153
|
+
transactions: z.array(z.unknown()).optional(),
|
|
7154
|
+
}),
|
|
7155
|
+
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
7156
|
+
examples: [{}],
|
|
7157
|
+
}),
|
|
7158
|
+
"sui:generate": receiver({
|
|
7159
|
+
receiver: "sui:generate",
|
|
7160
|
+
summary: "Run the vocabulary enrichment workflow for a workspace: extract, validate alignment against the Move modules, then fan out to the cache and task sinks. Returns the consolidated summary, or `{ok:false, stage:'validate', findings}` when alignment fails — a failed validation never fans out. The workspace is walked from the attested caller.",
|
|
7161
|
+
request: z.object({
|
|
7162
|
+
workspace: z.string().optional().describe("Workspace slug; defaults to the caller's own. The caller must control it"),
|
|
7163
|
+
}),
|
|
7164
|
+
response: z.object({
|
|
7165
|
+
ok: z.boolean(),
|
|
7166
|
+
stage: z.string().optional(),
|
|
7167
|
+
vocabulary: z.unknown().optional(),
|
|
7168
|
+
modules: z.array(z.unknown()).optional(),
|
|
7169
|
+
fanout: z.unknown().optional(),
|
|
7170
|
+
validation: z.unknown().optional(),
|
|
7171
|
+
findings: z.array(z.unknown()).optional(),
|
|
7172
|
+
error: z.string().optional(),
|
|
7173
|
+
}),
|
|
7174
|
+
effect: "ask", cost: "variable", reversible: false, idempotent: false, auth: "member",
|
|
7175
|
+
examples: [{ workspace: "one" }],
|
|
7176
|
+
}),
|
|
5650
7177
|
// ── fn: family — typed substrate function calls (C5) ──────────────────────
|
|
5651
7178
|
"fn:run": receiver({
|
|
5652
7179
|
receiver: "fn:run",
|
|
@@ -5862,7 +7389,8 @@ export const RECEIVERS = {
|
|
|
5862
7389
|
}),
|
|
5863
7390
|
"seo:keyword-metrics": receiver({
|
|
5864
7391
|
receiver: "seo:keyword-metrics",
|
|
5865
|
-
|
|
7392
|
+
// Named, not derived — collapses into the hand-written `seo_keyword_metrics`.
|
|
7393
|
+
surfaces: { mcp: { name: "seo_keyword_metrics" } },
|
|
5866
7394
|
summary: "Return monthly search volume, CPC, and competition for a list of keywords via DataForSEO async task",
|
|
5867
7395
|
request: z.object({ keywords: z.array(z.string()), location_code: z.number().int().optional(), language_code: z.string().optional() }),
|
|
5868
7396
|
response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
|
|
@@ -6175,7 +7703,8 @@ export const RECEIVERS = {
|
|
|
6175
7703
|
// and the magic-byte sniff are reimplemented at the same 5 MiB cap.
|
|
6176
7704
|
//
|
|
6177
7705
|
// `url` IS A NEW OUTBOUND-FETCH SURFACE and is treated as one — the shared SSRF guard
|
|
6178
|
-
// (shape + DNS, fail-closed) runs BEFORE the fetch, `redirect:'
|
|
7706
|
+
// (shape + DNS, fail-closed) runs BEFORE the fetch, `redirect:'manual'` with any
|
|
7707
|
+
// 3xx refused as `redirect_refused` (workerd rejects `redirect:'error'`) so a public
|
|
6179
7708
|
// host cannot 302 the worker onto a metadata address, and the body is read against a
|
|
6180
7709
|
// hard byte budget rather than trusting `content-length`.
|
|
6181
7710
|
"media:upload": receiver({
|
|
@@ -6197,7 +7726,7 @@ export const RECEIVERS = {
|
|
|
6197
7726
|
kind: z.literal("image").optional().describe("Always image. Video is a 5 MiB-cap and multipart-shape decision this door does not make; PUT /api/media/video-upload is the door that does."),
|
|
6198
7727
|
contentType: z.string().optional().describe("Sniffed, not declared."),
|
|
6199
7728
|
size: z.number().optional().describe("Bytes actually stored."),
|
|
6200
|
-
error: z.string().optional().describe("Named: forbidden · too_large · unsupported_type · blocked_url · fetch_failed · storage_error. A refusal is never a downgrade to your own workspace."),
|
|
7729
|
+
error: z.string().optional().describe("Named: forbidden · too_large · unsupported_type · blocked_url · redirect_refused: <the 3xx status> · fetch_failed · storage_error. A refusal is never a downgrade to your own workspace."),
|
|
6201
7730
|
}),
|
|
6202
7731
|
effect: "ask", cost: "free", idempotent: false, reversible: false, settles: "none", auth: "member",
|
|
6203
7732
|
examples: [
|