@oneie/sdk 0.15.1 → 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/README.md +1 -0
- package/dist/billing.d.ts +53 -0
- package/dist/billing.d.ts.map +1 -1
- package/dist/billing.js +36 -0
- package/dist/billing.js.map +1 -1
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -0
- package/dist/client.js.map +1 -1
- package/dist/generated/schemas/index.d.ts +7 -7
- package/dist/generated/schemas.d.ts +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts +29 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +127 -0
- package/dist/media.js.map +1 -0
- package/dist/receivers.d.ts +1048 -14
- package/dist/receivers.d.ts.map +1 -1
- package/dist/receivers.js +1755 -39
- package/dist/receivers.js.map +1 -1
- package/dist/schemas.d.ts +2 -2
- package/dist/vault-file.d.ts +81 -0
- package/dist/vault-file.d.ts.map +1 -0
- package/dist/vault-file.js +155 -0
- package/dist/vault-file.js.map +1 -0
- 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/dist/work-contract-grammar.json +7 -0
- package/package.json +6 -1
- package/dist/.build-fingerprint +0 -1
package/dist/receivers.js
CHANGED
|
@@ -226,17 +226,45 @@ export const RECEIVERS = {
|
|
|
226
226
|
request: z.object({ workspace: z.string() }),
|
|
227
227
|
response: ok, effect: "ask", auth: "manage_workspace",
|
|
228
228
|
}),
|
|
229
|
+
/**
|
|
230
|
+
* TWO PATHS, ONE RECEIVER — and the second one is why `slug`/`parent` exist.
|
|
231
|
+
*
|
|
232
|
+
* Bare (`name` + `type`, no `groupSlug`, no `parent`): unchanged legacy behaviour —
|
|
233
|
+
* a random gid in D1 `world_groups`. That is what a campaign or a collection
|
|
234
|
+
* is, and every existing caller stays on it byte for byte.
|
|
235
|
+
*
|
|
236
|
+
* Substrate (`groupSlug` + `parent`, BOTH required together): the real group unit —
|
|
237
|
+
* a TypeDB `group`, a `hierarchy` edge to the parent, an owner `membership`
|
|
238
|
+
* for the attested caller, and the D1 `owners` row — written by `createGroup`
|
|
239
|
+
* in `one.ie/web/src/lib/group-unit.ts`. Measured 2026-09-16: no shipped
|
|
240
|
+
* receiver could write a group with a PARENT and a chosen slug, so a personal
|
|
241
|
+
* group under a world root had to be typed by hand.
|
|
242
|
+
*
|
|
243
|
+
* The gid's SHAPE differs by path and that is the contract change callers
|
|
244
|
+
* notice: `generateTraceId()` on the bare path, deterministic `group:<slug>`
|
|
245
|
+
* on the substrate one.
|
|
246
|
+
*
|
|
247
|
+
* `parent` NAMES a target; it never authorizes one. The handler walks the
|
|
248
|
+
* caller's control of the parent workspace and refuses otherwise, and the
|
|
249
|
+
* owner it writes is the ATTESTED caller — never a body field.
|
|
250
|
+
*/
|
|
229
251
|
"world:create-group": receiver({
|
|
230
252
|
receiver: "world:create-group",
|
|
231
253
|
surfaces: { mcp: true },
|
|
232
|
-
summary: "Create a group
|
|
254
|
+
summary: "Create a group — a D1 campaign/collection row, or (with groupSlug+parent) a substrate group under a parent the caller controls",
|
|
233
255
|
request: z.object({
|
|
234
256
|
name: z.string(),
|
|
235
257
|
// "group" is the D1 group_type for campaigns/collections; absent from the TypeDB enum
|
|
236
|
-
type: GroupEntitySchema.shape["group-type"].or(z.literal("group"))
|
|
258
|
+
type: GroupEntitySchema.shape["group-type"].or(z.literal("group"))
|
|
259
|
+
.describe("One of the nine group-type values in schema/one.tql, or the D1-only \"group\". The substrate path (groupSlug+parent) accepts ONLY the nine — TypeDB's @values constraint refuses anything else and that write would fail silently."),
|
|
260
|
+
groupSlug: z.string().optional()
|
|
261
|
+
.describe("Bare slug for a SUBSTRATE group; the gid is always `group:<groupSlug>`. Requires `parent` — a slug with no parent would mint an unparented root workspace and is refused. NOT named `slug`: on the service lane `/api/ask` reads `payload.slug ?? payload.workspace` as the workspace the caller NOMINATES for itself, so a field called `slug` here would silently re-identify the caller as the group it is trying to create."),
|
|
262
|
+
parent: z.string().optional()
|
|
263
|
+
.describe("gid of the parent group, e.g. \"group:one\". Requires `groupSlug`. It NAMES a target and never authorizes one: the caller must control the parent workspace or the call is refused."),
|
|
237
264
|
tags: z.array(z.string()).optional(),
|
|
238
265
|
}),
|
|
239
|
-
response: z.object({ gid: z.string()
|
|
266
|
+
response: z.object({ gid: z.string().describe("`group:<groupSlug>` on the substrate path; a random trace id on the bare D1 path") }),
|
|
267
|
+
effect: "ask", auth: "manage_groups",
|
|
240
268
|
}),
|
|
241
269
|
"world:update-group": receiver({
|
|
242
270
|
receiver: "world:update-group",
|
|
@@ -376,6 +404,7 @@ export const RECEIVERS = {
|
|
|
376
404
|
nickname: z.string().optional(),
|
|
377
405
|
title: z.string().optional(),
|
|
378
406
|
avatar: z.string().optional(),
|
|
407
|
+
cover: z.string().optional(),
|
|
379
408
|
bio: z.string().optional(),
|
|
380
409
|
// contact
|
|
381
410
|
email: z.string().optional(),
|
|
@@ -560,6 +589,278 @@ export const RECEIVERS = {
|
|
|
560
589
|
version: "1.0.0",
|
|
561
590
|
examples: [{ head: "feat/x", base: "main", title: "feat: x" }],
|
|
562
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
|
+
}),
|
|
563
864
|
"tools:composio": receiver({
|
|
564
865
|
receiver: "tools:composio",
|
|
565
866
|
summary: "Execute one connected Composio tool from a workflow `tool` step (config.composio)",
|
|
@@ -568,6 +869,107 @@ export const RECEIVERS = {
|
|
|
568
869
|
effect: "ask", auth: "manage_integrations", reversible: false, idempotent: false,
|
|
569
870
|
examples: [{ workspace: "acme", tool: "GMAIL_SEND_EMAIL", args: { to: "x@y.com" } }],
|
|
570
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
|
+
}),
|
|
571
973
|
"brand:set": receiver({
|
|
572
974
|
receiver: "brand:set",
|
|
573
975
|
summary: "Write up to 6 brand color tokens (primary/secondary/accent/bg/text/border) to a workspace theme",
|
|
@@ -752,6 +1154,93 @@ export const RECEIVERS = {
|
|
|
752
1154
|
response: z.object({ uid: z.string(), address: z.string() }),
|
|
753
1155
|
effect: "ask", cost: "free", idempotent: true,
|
|
754
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
|
+
}),
|
|
755
1244
|
// ── auth (human session) ──
|
|
756
1245
|
// auth:sign-in killed (C7): Better Auth handles all human auth; signal/ask pipeline returns dissolved
|
|
757
1246
|
"auth:sign-out": receiver({
|
|
@@ -985,7 +1474,12 @@ export const RECEIVERS = {
|
|
|
985
1474
|
// with the TS type by hand.
|
|
986
1475
|
meta: z.record(z.string(), z.unknown()).optional(),
|
|
987
1476
|
}),
|
|
988
|
-
response: z.object({
|
|
1477
|
+
response: z.object({
|
|
1478
|
+
ok: z.boolean(),
|
|
1479
|
+
taskId: z.string().optional(),
|
|
1480
|
+
matched: z.number().optional().describe("REACH, NEVER DELIVERY. This is `actors.length` from the fan-out (`resolvers/subscriptions.ts:1245`) — how many staked actors the tags MATCHED, not how many received it, read it, or acted on it. Each matched actor got an inbox row; whether any of them moved the task is one `tasks:claim` away and this number cannot see it. A digest that quotes this as 'N people are on it' is reporting a fan-out as a delivery."),
|
|
1481
|
+
actors: z.array(z.string()).optional().describe("The actor ids the fan-out reached — the same reach `matched` counts, itemised. Reaching is not acting."),
|
|
1482
|
+
}),
|
|
989
1483
|
effect: "ask",
|
|
990
1484
|
}),
|
|
991
1485
|
"tasks:mine": receiver({
|
|
@@ -1767,7 +2261,7 @@ export const RECEIVERS = {
|
|
|
1767
2261
|
"tasks:priority": receiver({
|
|
1768
2262
|
receiver: "tasks:priority",
|
|
1769
2263
|
surfaces: { mcp: true },
|
|
1770
|
-
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",
|
|
1771
2265
|
request: z.object({
|
|
1772
2266
|
tid: z.string(),
|
|
1773
2267
|
priority: z.number(),
|
|
@@ -1954,7 +2448,7 @@ export const RECEIVERS = {
|
|
|
1954
2448
|
surfaces: { mcp: true },
|
|
1955
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.",
|
|
1956
2450
|
request: z.object({
|
|
1957
|
-
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."),
|
|
1958
2452
|
scope: z.enum(["own", "tree"]).optional().describe("tree = this group AND every descendant group (the CEO / agency lens). Default own."),
|
|
1959
2453
|
status: z.union([z.string(), z.array(z.string())]).optional()
|
|
1960
2454
|
.describe("open | blocked | picked | done | verified | failed | dissolved, one or many. 'active' = open+blocked+picked (the default). 'all' = every status."),
|
|
@@ -1965,7 +2459,9 @@ export const RECEIVERS = {
|
|
|
1965
2459
|
search: z.string().optional().describe("Case-insensitive substring on the task name."),
|
|
1966
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."),
|
|
1967
2461
|
include: z.array(z.enum(["notes", "graph", "dates", "thread"])).optional()
|
|
1968
|
-
.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."),
|
|
1969
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."),
|
|
1970
2466
|
sort: z.enum(["priority", "due-at", "updated-at", "created-at", "name"]).optional().describe("Default priority (desc)."),
|
|
1971
2467
|
dir: z.enum(["asc", "desc"]).optional(),
|
|
@@ -2052,19 +2548,20 @@ export const RECEIVERS = {
|
|
|
2052
2548
|
// It reimplements no write: the resolver loops and calls the existing
|
|
2053
2549
|
// single-tid resolvers with the SAME attested ctx, so authority, the status
|
|
2054
2550
|
// gates and the D1 tag mirror are all inherited rather than re-derived. The
|
|
2055
|
-
// `workspace` field (envelope or per row) is a REQUEST
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
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.
|
|
2058
2555
|
"tasks:bulk": receiver({
|
|
2059
2556
|
receiver: "tasks:bulk",
|
|
2060
2557
|
surfaces: { mcp: true },
|
|
2061
|
-
summary: "Plan and act on many tasks in ONE request. Three modes, combinable: `creates` files up to 25 new tasks (with `ref` handles so rows in the same call can parent/block each other — a whole plan tree in one call); `edits` changes up to 25 named rows (title/status/priority/notes/assignee/tags/dates/dependencies/parent/comment — `parent` MOVES a row under another, the one door that writes `containment` after birth, and an empty string makes it a root again); `where`+`set` applies ONE edit to every row a tasks:board filter matches (dryRun defaults TRUE —
|
|
2558
|
+
summary: "Plan and act on many tasks in ONE request. Three modes, combinable: `creates` files up to 25 new tasks (with `ref` handles so rows in the same call can parent/block each other — a whole plan tree in one call); `edits` changes up to 25 named rows (title/status/priority/notes/assignee/tags/dates/dependencies/parent/comment — `parent` MOVES a row under another, the one door that writes `containment` after birth, and an empty string makes it a root again); `where`+`set` applies ONE edit to every row a tasks:board filter matches (dryRun defaults TRUE — the preview returns the matched ROWS themselves in `rows`, not just their tids, so you read the ≤25 rows you are about to change and then re-send the identical call with dryRun:false; applies 25 per call and returns `nextCursor` to continue). Returns a receipt PER ROW (ok, per-field reason, retryable) plus total/attempted/applied/failed — a row never attempted says so by name, so a partial run can never read as a success.",
|
|
2062
2559
|
request: z.object({
|
|
2063
2560
|
edits: z.array(z.object({
|
|
2064
2561
|
tid: z.string(),
|
|
2065
2562
|
title: z.string().optional(),
|
|
2066
2563
|
status: z.string().optional().describe("open | blocked | picked | done | verified | failed | dissolved"),
|
|
2067
|
-
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)"),
|
|
2068
2565
|
notes: z.string().optional().describe("Prose goal; empty string clears"),
|
|
2069
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."),
|
|
2070
2567
|
assignee: z.string().optional().describe("Actor slug; empty string unassigns"),
|
|
@@ -2075,7 +2572,7 @@ export const RECEIVERS = {
|
|
|
2075
2572
|
addBlockedBy: z.array(z.string()).optional().describe("Task ids (or `ref`s from `creates`) this row must wait for — tasks:depend per id"),
|
|
2076
2573
|
removeBlockedBy: z.array(z.string()).optional().describe("tasks:undepend per id"),
|
|
2077
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"),
|
|
2078
|
-
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."),
|
|
2079
2576
|
})).max(25).optional(),
|
|
2080
2577
|
creates: z.array(z.object({
|
|
2081
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."),
|
|
@@ -2083,7 +2580,7 @@ export const RECEIVERS = {
|
|
|
2083
2580
|
notes: z.string().optional(),
|
|
2084
2581
|
tags: z.array(z.string()).optional(),
|
|
2085
2582
|
assignee: z.string().optional(),
|
|
2086
|
-
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"),
|
|
2087
2584
|
parent: z.string().optional().describe("Existing task id or a ref created EARLIER in this array"),
|
|
2088
2585
|
blockedBy: z.array(z.string()).optional().describe("Existing task ids or refs created earlier in this array"),
|
|
2089
2586
|
dueAt: z.string().optional(),
|
|
@@ -2107,16 +2604,17 @@ export const RECEIVERS = {
|
|
|
2107
2604
|
removeTags: z.array(z.string()).optional(),
|
|
2108
2605
|
dueAt: z.string().nullable().optional(),
|
|
2109
2606
|
comment: z.string().optional(),
|
|
2110
|
-
}).optional().describe("The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row."),
|
|
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."),
|
|
2111
2608
|
dryRun: z.boolean().optional().describe("For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates."),
|
|
2112
2609
|
cursor: z.string().optional().describe("For `where`: the nextCursor from the previous call"),
|
|
2113
|
-
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."),
|
|
2114
2611
|
}).refine((v) => (v.edits?.length ?? 0) + (v.creates?.length ?? 0) > 0 || (!!v.where && !!v.set), {
|
|
2115
2612
|
message: "send edits, creates, or where+set",
|
|
2116
2613
|
path: ["edits"],
|
|
2117
2614
|
}).refine((v) => !v.where === !v.set, { message: "where and set go together", path: ["where"] }),
|
|
2118
2615
|
response: z.object({
|
|
2119
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."),
|
|
2120
2618
|
total: z.number().optional(),
|
|
2121
2619
|
attempted: z.number().optional(),
|
|
2122
2620
|
applied: z.number().optional(),
|
|
@@ -2125,8 +2623,14 @@ export const RECEIVERS = {
|
|
|
2125
2623
|
error: z.string().optional(),
|
|
2126
2624
|
detail: z.string().optional(),
|
|
2127
2625
|
created: z.record(z.string(), z.string()).optional().describe("ref → tid for every `creates` row that landed"),
|
|
2128
|
-
matched: z.number().optional().describe("`where`: rows the filter matched in total, across all pages"),
|
|
2626
|
+
matched: z.number().optional().describe("`where`: rows the filter matched in total, across all pages. EXACT — unless `truncated` is present, and then it is a FLOOR. Quote it as 'at least N'."),
|
|
2627
|
+
truncated: z.object({
|
|
2628
|
+
rows: z.number().optional().describe("The board snapshot hit its row budget; this many unique rows were read. `matched` is a FLOOR — rows exist that the filter never saw, so the set you are previewing is a subset of the set you asked for."),
|
|
2629
|
+
edges: z.boolean().optional().describe("The dependency branch was capped. `where:{ready:true}` is computed from `blockedBy`, so a row can match as ready while it is in fact blocked."),
|
|
2630
|
+
tags: z.number().optional().describe("The tag branch was capped; this many rows have uncertain tags. A `tags`/`anyTags` filter may have MISSED rows, and the `tags` on the rows returned above may be short."),
|
|
2631
|
+
}).optional().describe("`where`: the budget that bit, named — the same word and the same shape tasks:board serves, because it is the same snapshot. Three budgets, independent, each key present only when THAT one bit. ABSENT MEANS NOTHING WAS CUT — absent and `false` are different answers, so branch on the key's presence and never on its value. This is the field to read rather than the prose in `detail`: `detail` is for a human reading a log, and on an aborted run it is overwritten by the abort reason, while this survives. A receiver that caps, pages or samples names the budget that bit, in the response, every time."),
|
|
2129
2632
|
matchedIds: z.array(z.string()).optional().describe("`where`: the tids this call covers (the ones it would apply, on dryRun)"),
|
|
2633
|
+
rows: z.array(z.record(z.string(), z.unknown())).optional().describe("`where`, DRY RUN ONLY: the matched rows THEMSELVES — `matchedIds` with its contents attached. Same rows, same order (rows[i].tid === matchedIds[i], element for element), same shape tasks:board serves: tid · name · status · priority · tags · assignee · workspace · parent · blockedBy · dueAt · startAt · createdAt · closedAt · updatedAt · notes. This is what makes the dry run a READ you can judge instead of a count you must take on faith: you see the ≤25 rows you are about to change — their owners, their blockers, their tags — and then re-send the IDENTICAL call with dryRun:false. One filter expression, not two that can disagree about what the set is. Costs nothing: these are the rows the match was computed from, already in hand. ABSENT on an apply — `results` carries a receipt per tid there, and a second copy of the rows is the larger answer to the smaller question. TWO TRAPS. (1) `tags` is the BARE lens — namespaced words (`plan:…`, `cycle:…`, `workspace:…`) and the `@slug` assignee tag are stripped, and `assignee`/`workspace` are DERIVED from the stripped ones. `tags: []` therefore means NO ROUTING WORDS, never 'no tags on this row': measured 2026-09-15, rows carrying six tags each report `tags: []`. (2) A row here is at most `asOf` stale (the 60s board memo) and describes the row BEFORE `set` — never read it back as the result of the write."),
|
|
2130
2634
|
dryRun: z.boolean().optional(),
|
|
2131
2635
|
nextCursor: z.string().optional().describe("`where`: present iff more matches remain — resend the same call with this cursor"),
|
|
2132
2636
|
results: z.array(z.object({
|
|
@@ -3265,6 +3769,47 @@ export const RECEIVERS = {
|
|
|
3265
3769
|
effect: "signal", cost: "free", reversible: false, idempotent: true, auth: "public",
|
|
3266
3770
|
}),
|
|
3267
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
|
+
}),
|
|
3268
3813
|
// funnel:create — create a new funnel definition (draft).
|
|
3269
3814
|
"funnel:create": receiver({
|
|
3270
3815
|
receiver: "funnel:create",
|
|
@@ -3417,7 +3962,10 @@ export const RECEIVERS = {
|
|
|
3417
3962
|
"products:list": receiver({
|
|
3418
3963
|
receiver: "products:list",
|
|
3419
3964
|
summary: "List a workspace's storefront products with their prices (public)",
|
|
3420
|
-
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
|
+
}),
|
|
3421
3969
|
response: z.object({ products: z.array(z.record(z.string(), z.unknown())).optional(), error: z.string().optional() }),
|
|
3422
3970
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
3423
3971
|
}),
|
|
@@ -3538,6 +4086,93 @@ export const RECEIVERS = {
|
|
|
3538
4086
|
response: z.object({ cid: z.string().optional(), cleared: z.number().optional(), error: z.string().optional() }),
|
|
3539
4087
|
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "public",
|
|
3540
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
|
+
}),
|
|
3541
4176
|
// ── orders (the one operator write door) — handler lands in resolvers (C5) ─────
|
|
3542
4177
|
// data-bindings.ts:62 marks `order` create/update/archive as null by design:
|
|
3543
4178
|
// orders are webhook-minted and otherwise read-only. This is the deliberate
|
|
@@ -3550,6 +4185,129 @@ export const RECEIVERS = {
|
|
|
3550
4185
|
response: z.object({ oid: z.string().optional(), workspace: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
|
|
3551
4186
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3552
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
|
+
}),
|
|
3553
4311
|
// ── order:claim — the buyer's delivery door (handler: resolvers/commerce.ts) ──
|
|
3554
4312
|
// Declared so the route's envelope guard fires: an undeclared receiver skips
|
|
3555
4313
|
// the `envelope_missing` check, and a flat body silently validates as {}.
|
|
@@ -3810,7 +4568,22 @@ export const RECEIVERS = {
|
|
|
3810
4568
|
treasury: z.string().optional(),
|
|
3811
4569
|
receipt: z.object({ id: z.string(), url: z.string() }).optional(),
|
|
3812
4570
|
claimed: z.boolean().optional(),
|
|
3813
|
-
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"),
|
|
3814
4587
|
error: z.string().optional(),
|
|
3815
4588
|
}),
|
|
3816
4589
|
effect: "ask", cost: "variable", reversible: false, idempotent: false, settles: "onchain", auth: "manage_workspace",
|
|
@@ -3906,6 +4679,51 @@ export const RECEIVERS = {
|
|
|
3906
4679
|
}),
|
|
3907
4680
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3908
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
|
+
}),
|
|
3909
4727
|
// ── view:* — saved views (custom-views C1). A view is a workspace-scoped Thing (world_things type 'view'). ──
|
|
3910
4728
|
"view:create": receiver({
|
|
3911
4729
|
receiver: "view:create",
|
|
@@ -3944,21 +4762,195 @@ export const RECEIVERS = {
|
|
|
3944
4762
|
response: z.object({ ok: z.boolean(), id: z.string().optional(), error: z.string().optional() }),
|
|
3945
4763
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3946
4764
|
}),
|
|
3947
|
-
//
|
|
3948
|
-
|
|
3949
|
-
|
|
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",
|
|
3950
4791
|
surfaces: { mcp: true },
|
|
3951
|
-
summary: "
|
|
3952
|
-
request: z.object({
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
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
|
+
}),
|
|
4936
|
+
// ── pages + workspace settings — handlers in resolvers/pages.ts ────────────────
|
|
4937
|
+
"pages:create": receiver({
|
|
4938
|
+
receiver: "pages:create",
|
|
4939
|
+
surfaces: { mcp: true },
|
|
4940
|
+
summary: "Create a workspace page (draft) from a title + sections",
|
|
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() }),
|
|
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
|
+
}),
|
|
4946
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
4947
|
+
}),
|
|
4948
|
+
"pages:edit": receiver({
|
|
4949
|
+
receiver: "pages:edit",
|
|
4950
|
+
summary: "Edit a workspace page's title or sections",
|
|
4951
|
+
request: z.object({ slug: z.string(), page: z.string(), title: z.string().optional(), sections: z.array(z.object({ component: z.string(), props: z.record(z.string(), z.unknown()) })).optional() }),
|
|
4952
|
+
response: z.object({ ok: z.boolean(), slug: z.string().optional(), title: z.string().optional(), url: z.string().optional(), status: z.string().optional(), error: z.string().optional() }),
|
|
4953
|
+
effect: "ask", cost: "free", reversible: true, idempotent: false, auth: "member",
|
|
3962
4954
|
}),
|
|
3963
4955
|
"pages:list": receiver({
|
|
3964
4956
|
receiver: "pages:list",
|
|
@@ -3985,8 +4977,20 @@ export const RECEIVERS = {
|
|
|
3985
4977
|
'pages:publish-pack': receiver({
|
|
3986
4978
|
receiver: "pages:publish-pack",
|
|
3987
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.",
|
|
3988
|
-
request: z.object({
|
|
3989
|
-
|
|
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
|
+
}),
|
|
3990
4994
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "member",
|
|
3991
4995
|
}),
|
|
3992
4996
|
// WHY THIS IS DECLARED. `pages:delete` shipped as a resolver with no registry
|
|
@@ -4038,6 +5042,123 @@ export const RECEIVERS = {
|
|
|
4038
5042
|
// envelope cannot be conditional. publish/unpublish are each other's undo.
|
|
4039
5043
|
effect: "ask", cost: "free", reversible: false, idempotent: true, auth: "member",
|
|
4040
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
|
+
}),
|
|
4041
5162
|
"pages:fill-pack": receiver({
|
|
4042
5163
|
receiver: "pages:fill-pack",
|
|
4043
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.",
|
|
@@ -4115,6 +5236,234 @@ export const RECEIVERS = {
|
|
|
4115
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() }),
|
|
4116
5237
|
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
4117
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
|
+
}),
|
|
4118
5467
|
"mover:agent-context": receiver({
|
|
4119
5468
|
receiver: "mover:agent-context",
|
|
4120
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.",
|
|
@@ -4379,7 +5728,17 @@ export const RECEIVERS = {
|
|
|
4379
5728
|
receiver: "web:crawl",
|
|
4380
5729
|
summary: "Fetch a URL and extract domain, name, description, and readable text (public)",
|
|
4381
5730
|
request: z.object({ url: z.string() }),
|
|
4382
|
-
response: z.object({
|
|
5731
|
+
response: z.object({
|
|
5732
|
+
ok: z.boolean(),
|
|
5733
|
+
url: z.string().optional().describe("The URL that ANSWERED, not the one asked for — a crawl of an apex that 301s to www reports the www URL here"),
|
|
5734
|
+
domain: z.string().optional(),
|
|
5735
|
+
name: z.string().optional(),
|
|
5736
|
+
description: z.string().optional(),
|
|
5737
|
+
text: z.string().optional(),
|
|
5738
|
+
error: z.string().optional().describe("NAMED, never a single catch-all. invalid_url · https_required · dns_unresolved · blocked_host · redirect_no_location · redirect_invalid_url · redirect_blocked:<ssrf reason> · too_many_redirects · fetch_failed:<status> · not_html · timeout · fetch_error. Until 2026-09-15 every network cause collapsed into `fetch_error`, so a 301 apex->www and an origin refusing the fetch outright were indistinguishable — read `detail` beside this."),
|
|
5739
|
+
detail: z.string().optional().describe("The underlying cause in words, ≤200 chars — the exception class and message, the blocked hop, or the content-type that was refused. Present on most errors, never on success."),
|
|
5740
|
+
redirects: z.number().optional().describe("Hops followed before the answer. Redirects are followed MANUALLY, at most 4, and the SSRF guard re-runs on every hop — a public URL that redirects to a private address is refused at that hop with redirect_blocked."),
|
|
5741
|
+
}),
|
|
4383
5742
|
effect: "ask", cost: "free", reversible: true, idempotent: true, auth: "public",
|
|
4384
5743
|
}),
|
|
4385
5744
|
// ── people / companies / conversation — handlers in resolvers/groups.ts ────────
|
|
@@ -4768,6 +6127,29 @@ export const RECEIVERS = {
|
|
|
4768
6127
|
auth: "manage_workflows",
|
|
4769
6128
|
examples: [{ name: "Client Onboarding" }, { name: "demo", simulate: true }],
|
|
4770
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
|
+
}),
|
|
4771
6153
|
"workflow:webhook-subscribe": receiver({
|
|
4772
6154
|
receiver: "workflow:webhook-subscribe",
|
|
4773
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.",
|
|
@@ -4787,7 +6169,9 @@ export const RECEIVERS = {
|
|
|
4787
6169
|
}),
|
|
4788
6170
|
"workflow:apply-diff": receiver({
|
|
4789
6171
|
receiver: "workflow:apply-diff",
|
|
4790
|
-
|
|
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" } },
|
|
4791
6175
|
summary: "Apply a WorkflowDiff (add/remove/connect/disconnect/update). simulate=true validates the DAG without persisting",
|
|
4792
6176
|
request: z.object({
|
|
4793
6177
|
workflowId: z.string(),
|
|
@@ -4830,10 +6214,14 @@ export const RECEIVERS = {
|
|
|
4830
6214
|
summary: "Start a run — spawns the WorkflowRun DO, executes each step, marks path strength on traversed edges",
|
|
4831
6215
|
request: z.object({
|
|
4832
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."),
|
|
4833
6221
|
triggerPayload: z.record(z.string(), z.unknown()).optional(),
|
|
4834
6222
|
idempotencyKey: z.string().optional(),
|
|
4835
6223
|
}),
|
|
4836
|
-
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() }),
|
|
4837
6225
|
effect: "ask", cost: "variable", reversible: false, idempotent: true, simulatable: false, settles: "none",
|
|
4838
6226
|
auth: "manage_workflows",
|
|
4839
6227
|
examples: [{ workflowId: "wf_abc" }],
|
|
@@ -5061,6 +6449,11 @@ export const RECEIVERS = {
|
|
|
5061
6449
|
actorId: z.string().optional(),
|
|
5062
6450
|
skill: z.string().optional(),
|
|
5063
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."),
|
|
5064
6457
|
}).catchall(z.unknown()), // extra keys = the agent's input
|
|
5065
6458
|
response: z.object({ ok: z.boolean(), text: z.string().optional(), actorId: z.string().optional(), error: z.string().optional() }),
|
|
5066
6459
|
effect: "ask", cost: "variable", reversible: false, idempotent: false, simulatable: false, settles: "none", auth: "manage_workflows",
|
|
@@ -5597,6 +6990,190 @@ export const RECEIVERS = {
|
|
|
5597
6990
|
effect: "ask", cost: "free", reversible: false, idempotent: false, auth: "member",
|
|
5598
6991
|
examples: [{ actorIds: ["u-1", "u-2"], destination: "/launch", campaignId: "q3" }],
|
|
5599
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
|
+
}),
|
|
5600
7177
|
// ── fn: family — typed substrate function calls (C5) ──────────────────────
|
|
5601
7178
|
"fn:run": receiver({
|
|
5602
7179
|
receiver: "fn:run",
|
|
@@ -5812,7 +7389,8 @@ export const RECEIVERS = {
|
|
|
5812
7389
|
}),
|
|
5813
7390
|
"seo:keyword-metrics": receiver({
|
|
5814
7391
|
receiver: "seo:keyword-metrics",
|
|
5815
|
-
|
|
7392
|
+
// Named, not derived — collapses into the hand-written `seo_keyword_metrics`.
|
|
7393
|
+
surfaces: { mcp: { name: "seo_keyword_metrics" } },
|
|
5816
7394
|
summary: "Return monthly search volume, CPC, and competition for a list of keywords via DataForSEO async task",
|
|
5817
7395
|
request: z.object({ keywords: z.array(z.string()), location_code: z.number().int().optional(), language_code: z.string().optional() }),
|
|
5818
7396
|
response: z.object({ ok: z.boolean(), data: z.unknown().optional(), error: z.string().optional() }),
|
|
@@ -6054,6 +7632,144 @@ export const RECEIVERS = {
|
|
|
6054
7632
|
settles: "none",
|
|
6055
7633
|
examples: [{ href: "/u/one/in", reason: "agent" }],
|
|
6056
7634
|
}),
|
|
7635
|
+
// ── media:* ─────────────────────────────────────────────────────────────────
|
|
7636
|
+
// THE WORKSPACE MEDIA LIBRARY, for an agent.
|
|
7637
|
+
//
|
|
7638
|
+
// WHY. Measured 2026-09-16 over this file: 362 receivers, and not one touched a
|
|
7639
|
+
// stored asset. `video:*` (14) is rooms, sessions and recordings — a call, never a
|
|
7640
|
+
// file. So the media console at `/u/<slug>/media` was HTTP-only: a human could list,
|
|
7641
|
+
// upload and generate; an agent could not reach any of it, from any surface.
|
|
7642
|
+
//
|
|
7643
|
+
// THE ONE AUTHORITY RULE, and it is not either HTTP door's. `workspace` is a SCOPE
|
|
7644
|
+
// REQUEST on all three — never an identity, never a default. It is honoured only
|
|
7645
|
+
// through the owner-tree walk, and a request the caller cannot clear is REFUSED BY
|
|
7646
|
+
// NAME. The three HTTP write doors (`api/media/generate.ts:35`, storefront
|
|
7647
|
+
// image-upload/image-generate) instead derive from `locals.slug` and IGNORE the
|
|
7648
|
+
// workspace handed to them, so an agency uploading at `/u/client/media` files into
|
|
7649
|
+
// the AGENCY's library while the grid shows the CLIENT's — and answers 200. These
|
|
7650
|
+
// receivers do not inherit that: a silent downgrade is a success you cannot audit.
|
|
7651
|
+
// Resolver: `one.ie/web/src/lib/resolvers/media.ts`.
|
|
7652
|
+
"media:list": receiver({
|
|
7653
|
+
receiver: "media:list",
|
|
7654
|
+
surfaces: { mcp: true, chat: true },
|
|
7655
|
+
summary: "Every image and video a workspace owns, as typed rows — ONE R2 scan of `{workspace}/` filtered to the seven prefixes the reader will actually serve (media, chat, ads, products, pages, brand, courses), so generated chat images and ad creative are visible, not just hand-uploads. Carries `scanned` (how much of the page was not media), `truncated` (the budget that bit) and `nextCursor`. Each row's `url` is the one door that reads bytes.",
|
|
7656
|
+
request: z.object({
|
|
7657
|
+
workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
|
|
7658
|
+
kind: z.enum(["image", "video"]).optional().describe("Omit for both. Filters the rows on this page; it does not change what is scanned."),
|
|
7659
|
+
prefix: z.enum(["media", "chat", "ads", "products", "pages", "brand", "courses"]).optional()
|
|
7660
|
+
.describe("Only assets under this top-level namespace. media = hand-uploads and library generations; chat = images/videos an agent made in a conversation; ads = creative; products/pages/courses/brand = assets filed beside the thing they illustrate."),
|
|
7661
|
+
limit: z.number().int().min(1).max(1000).optional()
|
|
7662
|
+
.describe("R2 objects to SCAN on this page BEFORE prefix/kind filtering — not a row count. Default 500, max 1000 (R2's own ceiling). A workspace whose bucket is mostly non-media can return few rows from a full scan; that is what `scanned` reports."),
|
|
7663
|
+
cursor: z.string().optional().describe("The nextCursor from the previous page."),
|
|
7664
|
+
}),
|
|
7665
|
+
response: z.object({
|
|
7666
|
+
ok: z.boolean(),
|
|
7667
|
+
workspace: z.string().optional().describe("The workspace actually read — always the one you asked for, because a request you may not read is refused rather than downgraded."),
|
|
7668
|
+
items: z.array(z.object({
|
|
7669
|
+
key: z.string().describe("The R2 object key, `{workspace}/{prefix}/…` — the handle every other media receiver takes."),
|
|
7670
|
+
url: z.string().describe("`/api/product-image/{workspace}/{path}` — THE reader. Range-forwarding, prefix-allowlisted, refuses SVG by stored contentType."),
|
|
7671
|
+
kind: z.enum(["image", "video"]),
|
|
7672
|
+
prefix: z.string().describe("Top-level namespace, no trailing slash."),
|
|
7673
|
+
contentType: z.string(),
|
|
7674
|
+
size: z.number().nullable().describe("Bytes, or null when R2 did not report it."),
|
|
7675
|
+
uploaded: z.string().nullable().describe("ISO time, or null."),
|
|
7676
|
+
})).optional(),
|
|
7677
|
+
total: z.number().optional()
|
|
7678
|
+
.describe("Media rows in THIS answer, after the kind/prefix filter. NOT a pre-paging count — an R2 scan cannot know one without walking every page. A FLOOR whenever `truncated` is present: say 'at least N'."),
|
|
7679
|
+
scanned: z.number().optional()
|
|
7680
|
+
.describe("Objects R2 listed on this page BEFORE filtering. `scanned` far above `total` means most of the page was not media — follow the cursor rather than concluding the workspace is empty."),
|
|
7681
|
+
byPrefix: z.record(z.string(), z.number()).optional().describe("What each namespace contributed to THIS page — never a workspace total."),
|
|
7682
|
+
prefixes: z.array(z.string()).optional().describe("Every prefix this door will ever return, so a caller can offer the filter without hardcoding the list."),
|
|
7683
|
+
truncated: z.object({
|
|
7684
|
+
rows: z.number().optional(),
|
|
7685
|
+
scanned: z.number().optional(),
|
|
7686
|
+
limit: z.number().optional().describe("The scan budget that bit."),
|
|
7687
|
+
reason: z.string().optional(),
|
|
7688
|
+
}).optional().describe("ABSENT IS NOT FALSE — a complete answer carries no such key. Present = R2 reported more objects under this workspace, `total` is a floor, and you must say so before planning on it."),
|
|
7689
|
+
nextCursor: z.string().optional().describe("Present iff `truncated` is. Follow it or you have not seen the bucket."),
|
|
7690
|
+
error: z.string().optional(),
|
|
7691
|
+
}),
|
|
7692
|
+
effect: "ask", cost: "free", idempotent: true, reversible: true, settles: "none", auth: "member",
|
|
7693
|
+
examples: [
|
|
7694
|
+
{ workspace: "one" },
|
|
7695
|
+
{ workspace: "one", kind: "video" },
|
|
7696
|
+
{ prefix: "ads", limit: 1000 },
|
|
7697
|
+
],
|
|
7698
|
+
}),
|
|
7699
|
+
// media:upload — bytes in, one key out, under `{workspace}/media/`.
|
|
7700
|
+
//
|
|
7701
|
+
// TWO SHAPES BECAUSE AN AGENT HAS NEITHER A FILE PICKER NOR A MULTIPART BODY. The
|
|
7702
|
+
// HTTP door takes multipart and nothing else, so this is not a wrapper: the R2 write
|
|
7703
|
+
// and the magic-byte sniff are reimplemented at the same 5 MiB cap.
|
|
7704
|
+
//
|
|
7705
|
+
// `url` IS A NEW OUTBOUND-FETCH SURFACE and is treated as one — the shared SSRF guard
|
|
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
|
|
7708
|
+
// host cannot 302 the worker onto a metadata address, and the body is read against a
|
|
7709
|
+
// hard byte budget rather than trusting `content-length`.
|
|
7710
|
+
"media:upload": receiver({
|
|
7711
|
+
receiver: "media:upload",
|
|
7712
|
+
surfaces: { mcp: true, chat: true },
|
|
7713
|
+
summary: "Put ONE raster image into a workspace's media library and get back the key and the url that serves it. Name exactly one source: `base64` (bytes you hold) or `url` (a public https address the server fetches under an SSRF guard, no redirects, 5 MiB hard cap). The stored content type comes from the MAGIC BYTES, never from what you declare — SVG is refused deliberately, and video is not accepted here.",
|
|
7714
|
+
request: z.object({
|
|
7715
|
+
workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
|
|
7716
|
+
url: z.string().optional().describe("Public https address to fetch. Alternative to `base64`, never both. Private, loopback, link-local and CGNAT addresses are refused, as is any redirect."),
|
|
7717
|
+
base64: z.string().optional().describe("The image bytes, base64. A `data:` prefix is accepted and stripped. Alternative to `url`, never both."),
|
|
7718
|
+
filename: z.string().optional().describe("A LABEL for the key, not a path — separators are stripped and the extension always comes from the sniffed bytes, so `../` and a `.svg` suffix are both inert. Omit and the key is a bare uuid."),
|
|
7719
|
+
contentType: z.string().optional().describe("A HINT only. The stored value is decided by the magic bytes, because the public reader serves the stored value verbatim under nosniff."),
|
|
7720
|
+
}),
|
|
7721
|
+
response: z.object({
|
|
7722
|
+
ok: z.boolean(),
|
|
7723
|
+
workspace: z.string().optional(),
|
|
7724
|
+
key: z.string().optional().describe("`{workspace}/media/…` — the handle media:list returns."),
|
|
7725
|
+
url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — THE reader."),
|
|
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."),
|
|
7727
|
+
contentType: z.string().optional().describe("Sniffed, not declared."),
|
|
7728
|
+
size: z.number().optional().describe("Bytes actually stored."),
|
|
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."),
|
|
7730
|
+
}),
|
|
7731
|
+
effect: "ask", cost: "free", idempotent: false, reversible: false, settles: "none", auth: "member",
|
|
7732
|
+
examples: [
|
|
7733
|
+
{ workspace: "one", url: "https://example.com/logo.png", filename: "logo" },
|
|
7734
|
+
{ base64: "iVBORw0KGgo…" },
|
|
7735
|
+
],
|
|
7736
|
+
}),
|
|
7737
|
+
// media:generate — one image from a prompt, straight into `{workspace}/media/`.
|
|
7738
|
+
//
|
|
7739
|
+
// DELIBERATELY NOT `surfaces.chat`. Chat already ships a curated `generate_image`
|
|
7740
|
+
// (`channels/src/aitools.ts:376`): three providers, aspect/width/height, per-call
|
|
7741
|
+
// billing and a spend-shaped approval gate — strictly more capable there, and it
|
|
7742
|
+
// files under `{ws}/chat/`. Flagging this for chat too would offer a model TWO image
|
|
7743
|
+
// generators with different prefixes and no way to choose between them, and the names
|
|
7744
|
+
// differ (`generate_image` vs `media_generate`) so the curated-wins precedence in
|
|
7745
|
+
// `channels/src/tools/from-registry.ts` would not suppress it. MCP has no such tool;
|
|
7746
|
+
// that is where this one earns its place.
|
|
7747
|
+
//
|
|
7748
|
+
// `context` is a PROMPT QUALIFIER, not a key prefix. A library is THE prefix, not a
|
|
7749
|
+
// context — the distinction `api/media/generate.ts`'s header argues out against the
|
|
7750
|
+
// contextual storefront door. Nothing this receiver writes lands outside `media/`.
|
|
7751
|
+
"media:generate": receiver({
|
|
7752
|
+
receiver: "media:generate",
|
|
7753
|
+
surfaces: { mcp: true },
|
|
7754
|
+
summary: "Generate ONE image from a prompt and file it in the workspace's media library. Keyless — flux-1-schnell on the Cloudflare AI binding. Returns the key and the url that serves it. Unlike the storefront generator this takes no destination: it always writes `{workspace}/media/`, because a library is a prefix and not a context.",
|
|
7755
|
+
request: z.object({
|
|
7756
|
+
workspace: z.string().optional().describe("Group slug. Omit for your own. Honoured only if you may read it."),
|
|
7757
|
+
prompt: z.string().min(1).describe("What to draw. Truncated at 2000 chars after `context` is folded in."),
|
|
7758
|
+
context: z.string().optional().describe("Extra prompt material — brand, palette, subject. A QUALIFIER appended to the prompt, NOT a destination: it never changes the key prefix."),
|
|
7759
|
+
}),
|
|
7760
|
+
response: z.object({
|
|
7761
|
+
ok: z.boolean(),
|
|
7762
|
+
workspace: z.string().optional(),
|
|
7763
|
+
key: z.string().optional().describe("`{workspace}/media/{uuid}.{ext}` — a uuid, never a timestamp: the url it names is served by a PUBLIC reader, so the name is the only thing standing between the object and a stranger."),
|
|
7764
|
+
url: z.string().optional().describe("`/api/product-image/{workspace}/{path}` — THE reader."),
|
|
7765
|
+
kind: z.literal("image").optional(),
|
|
7766
|
+
contentType: z.string().optional().describe("Sniffed from the returned bytes — flux returns JPEG today and returned PNG when the storefront door was written."),
|
|
7767
|
+
size: z.number().optional(),
|
|
7768
|
+
error: z.string().optional().describe("Named: forbidden · not_configured (no AI or CONTENT binding) · generation_failed · storage_error."),
|
|
7769
|
+
}),
|
|
7770
|
+
effect: "ask", cost: "variable", idempotent: false, reversible: false, settles: "none", auth: "member",
|
|
7771
|
+
examples: [{ workspace: "one", prompt: "a wide banner of a quiet harbour at dawn, muted palette" }],
|
|
7772
|
+
}),
|
|
6057
7773
|
};
|
|
6058
7774
|
/**
|
|
6059
7775
|
* RECIPES — the four agent journeys as typed, ordered receiver sequences (C6).
|