@affordances/protocol 0.0.0-stage → 0.0.0

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 CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # @affordances/protocol
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Affordances protocol: TypeScript types and zod schemas for graphs, calls, deltas, and the site index, the JSON Schemas generated from them, the protocol constants, and the normative `spec.md`. The other Affordances packages import their types from here.
4
+
5
+ ```bash
6
+ vlt install @affordances/protocol
7
+ ```
8
+
9
+ ```ts
10
+ import { Delta, Graph, MEDIA_TYPE } from '@affordances/protocol'
11
+
12
+ const response = await fetch('https://store.example.com/products/tee', {
13
+ headers: { accept: MEDIA_TYPE },
14
+ })
15
+ const graph = Graph.parse(await response.json())
16
+ ```
17
+
18
+ The JSON Schemas are at `@affordances/protocol/schemas/*.json`, example documents at `@affordances/protocol/fixtures/*`, and the spec at `@affordances/protocol/spec.md`.
19
+
20
+ Read the protocol: [https://github.com/vltpkg/affordances/blob/main/apps/docs/content/protocol.mdx](https://github.com/vltpkg/affordances/blob/main/apps/docs/content/protocol.mdx)
@@ -0,0 +1,264 @@
1
+ import { z } from "zod";
2
+ export declare const PROTOCOL_VERSION = "0";
3
+ export declare const MEDIA_TYPE = "application/vnd.affordances+json";
4
+ export declare const LINK_REL = "affordances";
5
+ export declare const WELL_KNOWN_PATH = "/.well-known/affordances.json";
6
+ export declare const Expose: z.ZodEnum<{
7
+ confirm: "confirm";
8
+ public: "public";
9
+ session: "session";
10
+ }>;
11
+ export declare const ActionDescriptor: z.ZodObject<{
12
+ name: z.ZodString;
13
+ description: z.ZodString;
14
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
15
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
16
+ expose: z.ZodEnum<{
17
+ confirm: "confirm";
18
+ public: "public";
19
+ session: "session";
20
+ }>;
21
+ guard: z.ZodOptional<z.ZodString>;
22
+ available: z.ZodBoolean;
23
+ reason: z.ZodOptional<z.ZodString>;
24
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
+ readOnly: z.ZodOptional<z.ZodBoolean>;
26
+ }, z.core.$strip>;
27
+ export declare const NavItem: z.ZodObject<{
28
+ to: z.ZodString;
29
+ label: z.ZodOptional<z.ZodString>;
30
+ when: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strip>;
32
+ export declare const Graph: z.ZodObject<{
33
+ v: z.ZodLiteral<"0">;
34
+ page: z.ZodString;
35
+ route: z.ZodString;
36
+ entities: z.ZodRecord<z.ZodString, z.ZodUnknown>;
37
+ actions: z.ZodArray<z.ZodObject<{
38
+ name: z.ZodString;
39
+ description: z.ZodString;
40
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
41
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
42
+ expose: z.ZodEnum<{
43
+ confirm: "confirm";
44
+ public: "public";
45
+ session: "session";
46
+ }>;
47
+ guard: z.ZodOptional<z.ZodString>;
48
+ available: z.ZodBoolean;
49
+ reason: z.ZodOptional<z.ZodString>;
50
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
51
+ readOnly: z.ZodOptional<z.ZodBoolean>;
52
+ }, z.core.$strip>>;
53
+ nav: z.ZodOptional<z.ZodArray<z.ZodObject<{
54
+ to: z.ZodString;
55
+ label: z.ZodOptional<z.ZodString>;
56
+ when: z.ZodOptional<z.ZodString>;
57
+ }, z.core.$strip>>>;
58
+ md: z.ZodOptional<z.ZodString>;
59
+ }, z.core.$strip>;
60
+ export declare const Call: z.ZodObject<{
61
+ action: z.ZodString;
62
+ input: z.ZodOptional<z.ZodUnknown>;
63
+ confirm: z.ZodOptional<z.ZodBoolean>;
64
+ }, z.core.$strip>;
65
+ export declare const ErrorCode: z.ZodEnum<{
66
+ confirm_required: "confirm_required";
67
+ internal: "internal";
68
+ invalid_input: "invalid_input";
69
+ not_found: "not_found";
70
+ rate_limited: "rate_limited";
71
+ unauthorized: "unauthorized";
72
+ unavailable: "unavailable";
73
+ }>;
74
+ export declare const Delta: z.ZodObject<{
75
+ ok: z.ZodBoolean;
76
+ changed: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
77
+ output: z.ZodOptional<z.ZodUnknown>;
78
+ actionsAdded: z.ZodOptional<z.ZodArray<z.ZodObject<{
79
+ name: z.ZodString;
80
+ description: z.ZodString;
81
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
82
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
83
+ expose: z.ZodEnum<{
84
+ confirm: "confirm";
85
+ public: "public";
86
+ session: "session";
87
+ }>;
88
+ guard: z.ZodOptional<z.ZodString>;
89
+ available: z.ZodBoolean;
90
+ reason: z.ZodOptional<z.ZodString>;
91
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
92
+ readOnly: z.ZodOptional<z.ZodBoolean>;
93
+ }, z.core.$strip>>>;
94
+ actionsRemoved: z.ZodOptional<z.ZodArray<z.ZodString>>;
95
+ redirect: z.ZodOptional<z.ZodString>;
96
+ message: z.ZodOptional<z.ZodString>;
97
+ error: z.ZodOptional<z.ZodObject<{
98
+ code: z.ZodEnum<{
99
+ confirm_required: "confirm_required";
100
+ internal: "internal";
101
+ invalid_input: "invalid_input";
102
+ not_found: "not_found";
103
+ rate_limited: "rate_limited";
104
+ unauthorized: "unauthorized";
105
+ unavailable: "unavailable";
106
+ }>;
107
+ message: z.ZodString;
108
+ issues: z.ZodOptional<z.ZodUnknown>;
109
+ }, z.core.$strip>>;
110
+ }, z.core.$strip>;
111
+ export declare const AuthMode: z.ZodEnum<{
112
+ bearer: "bearer";
113
+ cookie: "cookie";
114
+ }>;
115
+ export declare const ActionSummary: z.ZodObject<{
116
+ name: z.ZodString;
117
+ description: z.ZodString;
118
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
119
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
120
+ expose: z.ZodEnum<{
121
+ confirm: "confirm";
122
+ public: "public";
123
+ session: "session";
124
+ }>;
125
+ guard: z.ZodOptional<z.ZodString>;
126
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
127
+ readOnly: z.ZodOptional<z.ZodBoolean>;
128
+ }, z.core.$strip>;
129
+ export declare const SiteIndex: z.ZodObject<{
130
+ v: z.ZodLiteral<"0">;
131
+ name: z.ZodOptional<z.ZodString>;
132
+ routes: z.ZodArray<z.ZodObject<{
133
+ route: z.ZodString;
134
+ description: z.ZodOptional<z.ZodString>;
135
+ actions: z.ZodArray<z.ZodObject<{
136
+ name: z.ZodString;
137
+ description: z.ZodString;
138
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
139
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
140
+ expose: z.ZodEnum<{
141
+ confirm: "confirm";
142
+ public: "public";
143
+ session: "session";
144
+ }>;
145
+ guard: z.ZodOptional<z.ZodString>;
146
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
147
+ readOnly: z.ZodOptional<z.ZodBoolean>;
148
+ }, z.core.$strip>>;
149
+ }, z.core.$strip>>;
150
+ auth: z.ZodArray<z.ZodEnum<{
151
+ bearer: "bearer";
152
+ cookie: "cookie";
153
+ }>>;
154
+ mcp: z.ZodOptional<z.ZodString>;
155
+ }, z.core.$strip>;
156
+ export type Expose = z.infer<typeof Expose>;
157
+ export type ActionDescriptor = z.infer<typeof ActionDescriptor>;
158
+ export type NavItem = z.infer<typeof NavItem>;
159
+ export type Graph = z.infer<typeof Graph>;
160
+ export type Call = z.infer<typeof Call>;
161
+ export type ErrorCode = z.infer<typeof ErrorCode>;
162
+ export type Delta = z.infer<typeof Delta>;
163
+ export type AuthMode = z.infer<typeof AuthMode>;
164
+ export type ActionSummary = z.infer<typeof ActionSummary>;
165
+ export type SiteIndex = z.infer<typeof SiteIndex>;
166
+ export declare const jsonSchemas: {
167
+ graph: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
168
+ v: z.ZodLiteral<"0">;
169
+ page: z.ZodString;
170
+ route: z.ZodString;
171
+ entities: z.ZodRecord<z.ZodString, z.ZodUnknown>;
172
+ actions: z.ZodArray<z.ZodObject<{
173
+ name: z.ZodString;
174
+ description: z.ZodString;
175
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
176
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
177
+ expose: z.ZodEnum<{
178
+ confirm: "confirm";
179
+ public: "public";
180
+ session: "session";
181
+ }>;
182
+ guard: z.ZodOptional<z.ZodString>;
183
+ available: z.ZodBoolean;
184
+ reason: z.ZodOptional<z.ZodString>;
185
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
186
+ readOnly: z.ZodOptional<z.ZodBoolean>;
187
+ }, z.core.$strip>>;
188
+ nav: z.ZodOptional<z.ZodArray<z.ZodObject<{
189
+ to: z.ZodString;
190
+ label: z.ZodOptional<z.ZodString>;
191
+ when: z.ZodOptional<z.ZodString>;
192
+ }, z.core.$strip>>>;
193
+ md: z.ZodOptional<z.ZodString>;
194
+ }, z.core.$strip>>;
195
+ call: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
196
+ action: z.ZodString;
197
+ input: z.ZodOptional<z.ZodUnknown>;
198
+ confirm: z.ZodOptional<z.ZodBoolean>;
199
+ }, z.core.$strip>>;
200
+ delta: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
201
+ ok: z.ZodBoolean;
202
+ changed: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
203
+ output: z.ZodOptional<z.ZodUnknown>;
204
+ actionsAdded: z.ZodOptional<z.ZodArray<z.ZodObject<{
205
+ name: z.ZodString;
206
+ description: z.ZodString;
207
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
208
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
209
+ expose: z.ZodEnum<{
210
+ confirm: "confirm";
211
+ public: "public";
212
+ session: "session";
213
+ }>;
214
+ guard: z.ZodOptional<z.ZodString>;
215
+ available: z.ZodBoolean;
216
+ reason: z.ZodOptional<z.ZodString>;
217
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
218
+ readOnly: z.ZodOptional<z.ZodBoolean>;
219
+ }, z.core.$strip>>>;
220
+ actionsRemoved: z.ZodOptional<z.ZodArray<z.ZodString>>;
221
+ redirect: z.ZodOptional<z.ZodString>;
222
+ message: z.ZodOptional<z.ZodString>;
223
+ error: z.ZodOptional<z.ZodObject<{
224
+ code: z.ZodEnum<{
225
+ confirm_required: "confirm_required";
226
+ internal: "internal";
227
+ invalid_input: "invalid_input";
228
+ not_found: "not_found";
229
+ rate_limited: "rate_limited";
230
+ unauthorized: "unauthorized";
231
+ unavailable: "unavailable";
232
+ }>;
233
+ message: z.ZodString;
234
+ issues: z.ZodOptional<z.ZodUnknown>;
235
+ }, z.core.$strip>>;
236
+ }, z.core.$strip>>;
237
+ 'site-index': z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
238
+ v: z.ZodLiteral<"0">;
239
+ name: z.ZodOptional<z.ZodString>;
240
+ routes: z.ZodArray<z.ZodObject<{
241
+ route: z.ZodString;
242
+ description: z.ZodOptional<z.ZodString>;
243
+ actions: z.ZodArray<z.ZodObject<{
244
+ name: z.ZodString;
245
+ description: z.ZodString;
246
+ input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
247
+ output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
248
+ expose: z.ZodEnum<{
249
+ confirm: "confirm";
250
+ public: "public";
251
+ session: "session";
252
+ }>;
253
+ guard: z.ZodOptional<z.ZodString>;
254
+ effects: z.ZodOptional<z.ZodArray<z.ZodString>>;
255
+ readOnly: z.ZodOptional<z.ZodBoolean>;
256
+ }, z.core.$strip>>;
257
+ }, z.core.$strip>>;
258
+ auth: z.ZodArray<z.ZodEnum<{
259
+ bearer: "bearer";
260
+ cookie: "cookie";
261
+ }>>;
262
+ mcp: z.ZodOptional<z.ZodString>;
263
+ }, z.core.$strip>>;
264
+ };
package/dist/index.js ADDED
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+ // src/schema.ts
3
+ const PROTOCOL_VERSION = "0";
4
+ const MEDIA_TYPE = "application/vnd.affordances+json";
5
+ const LINK_REL = "affordances";
6
+ const WELL_KNOWN_PATH = "/.well-known/affordances.json";
7
+ const JSONSchema = z.record(z.string(), z.unknown()).meta({ description: "A JSON Schema object" });
8
+ const Expose = z.enum([
9
+ "public",
10
+ "session",
11
+ "confirm"
12
+ ]);
13
+ const ActionDescriptor = z.object({
14
+ name: z.string().min(1).describe("Stable, unique per route"),
15
+ description: z.string(),
16
+ input: JSONSchema,
17
+ output: JSONSchema.optional(),
18
+ expose: Expose,
19
+ guard: z.string().optional().describe("Human-readable precondition"),
20
+ available: z.boolean().describe("The guard, evaluated for this request"),
21
+ reason: z.string().optional().describe("Why the action is unavailable"),
22
+ effects: z.array(z.string()).optional().describe("Entity keys this action changes"),
23
+ readOnly: z.boolean().optional()
24
+ }).meta({ id: "ActionDescriptor" });
25
+ const NavItem = z.object({
26
+ to: z.string(),
27
+ label: z.string().optional(),
28
+ when: z.string().optional()
29
+ });
30
+ const Graph = z.object({
31
+ v: z.literal("0"),
32
+ page: z.string().describe("Concrete path, e.g. /products/tee"),
33
+ route: z.string().describe("Route pattern, e.g. /products/[slug]"),
34
+ entities: z.record(z.string(), z.unknown()),
35
+ actions: z.array(ActionDescriptor),
36
+ nav: z.array(NavItem).optional(),
37
+ md: z.string().optional().describe("Optional Markdown alternative of the page content")
38
+ }).meta({ id: "Graph" });
39
+ const Call = z.object({
40
+ action: z.string().min(1),
41
+ input: z.unknown().optional(),
42
+ confirm: z.boolean().optional()
43
+ }).meta({ id: "Call" });
44
+ const ErrorCode = z.enum([
45
+ "not_found",
46
+ "invalid_input",
47
+ "unauthorized",
48
+ "confirm_required",
49
+ "unavailable",
50
+ "rate_limited",
51
+ "internal"
52
+ ]);
53
+ const Delta = z.object({
54
+ ok: z.boolean(),
55
+ changed: z.record(z.string(), z.unknown()).optional().describe("Only the action's effects keys"),
56
+ output: z.unknown().optional().describe("The action's return value, matching its output schema"),
57
+ actionsAdded: z.array(ActionDescriptor).optional(),
58
+ actionsRemoved: z.array(z.string()).optional(),
59
+ redirect: z.string().optional(),
60
+ message: z.string().optional(),
61
+ error: z.object({
62
+ code: ErrorCode,
63
+ message: z.string(),
64
+ issues: z.unknown().optional()
65
+ }).optional()
66
+ }).meta({ id: "Delta" });
67
+ const AuthMode = z.enum(["cookie", "bearer"]);
68
+ const ActionSummary = ActionDescriptor.omit({
69
+ available: true,
70
+ reason: true
71
+ }).meta({ id: "ActionSummary" });
72
+ const SiteIndex = z.object({
73
+ v: z.literal("0"),
74
+ name: z.string().optional(),
75
+ routes: z.array(z.object({
76
+ route: z.string(),
77
+ description: z.string().optional().describe("What the page is for, in one line"),
78
+ actions: z.array(ActionSummary)
79
+ })),
80
+ auth: z.array(AuthMode),
81
+ mcp: z.string().optional().describe("URL of an MCP endpoint for this site")
82
+ }).meta({ id: "SiteIndex" });
83
+ // src/json-schema.ts
84
+ const jsonSchemas = {
85
+ graph: z.toJSONSchema(Graph),
86
+ call: z.toJSONSchema(Call),
87
+ delta: z.toJSONSchema(Delta),
88
+ "site-index": z.toJSONSchema(SiteIndex)
89
+ };
90
+ export { ActionDescriptor, ActionSummary, AuthMode, Call, Delta, ErrorCode, Expose, Graph, LINK_REL, MEDIA_TYPE, NavItem, PROTOCOL_VERSION, SiteIndex, WELL_KNOWN_PATH, jsonSchemas };
@@ -0,0 +1 @@
1
+ { "input": {} }
@@ -0,0 +1 @@
1
+ { "ok": false, "error": { "code": "teapot", "message": "no" } }
@@ -0,0 +1,9 @@
1
+ {
2
+ "v": "0",
3
+ "page": "/",
4
+ "route": "/",
5
+ "entities": {},
6
+ "actions": [
7
+ { "name": "x", "description": "", "input": {}, "expose": "everyone", "available": true }
8
+ ]
9
+ }
@@ -0,0 +1 @@
1
+ { "v": "1", "page": "/", "route": "/", "entities": {}, "actions": [] }
@@ -0,0 +1 @@
1
+ { "action": "placeOrder", "input": { "shipping": "standard" }, "confirm": true }
@@ -0,0 +1,5 @@
1
+ {
2
+ "ok": true,
3
+ "changed": { "cart": { "count": 2, "subtotal": { "amount": 6000, "currency": "USD" } } },
4
+ "message": "Added Heavyweight Tee (M) to your cart"
5
+ }
@@ -0,0 +1,28 @@
1
+ [
2
+ {
3
+ "ok": false,
4
+ "error": { "code": "not_found", "message": "No action named addToKart on /products/[slug]" }
5
+ },
6
+ {
7
+ "ok": false,
8
+ "error": {
9
+ "code": "invalid_input",
10
+ "message": "Input does not match the action's schema",
11
+ "issues": [{ "path": ["quantity"], "message": "Expected a number greater than 0" }]
12
+ }
13
+ },
14
+ { "ok": false, "error": { "code": "unauthorized", "message": "This action needs a session" } },
15
+ {
16
+ "ok": false,
17
+ "error": {
18
+ "code": "confirm_required",
19
+ "message": "Place an order for 2 items totalling $60.00"
20
+ }
21
+ },
22
+ { "ok": false, "error": { "code": "unavailable", "message": "Size L is out of stock" } },
23
+ {
24
+ "ok": false,
25
+ "error": { "code": "rate_limited", "message": "Too many calls to addToCart; retry in 10s" }
26
+ },
27
+ { "ok": false, "error": { "code": "internal", "message": "Something went wrong" } }
28
+ ]
@@ -0,0 +1,51 @@
1
+ {
2
+ "v": "0",
3
+ "page": "/products/heavyweight-tee",
4
+ "route": "/products/[slug]",
5
+ "entities": {
6
+ "product": {
7
+ "slug": "heavyweight-tee",
8
+ "name": "Heavyweight Tee",
9
+ "price": { "amount": 4800, "currency": "USD" },
10
+ "variants": [
11
+ { "sku": "TEE-BLK-M", "color": "black", "size": "M", "stock": 12 },
12
+ { "sku": "TEE-BLK-L", "color": "black", "size": "L", "stock": 0 }
13
+ ]
14
+ },
15
+ "cart": { "count": 1, "subtotal": { "amount": 1200, "currency": "USD" } }
16
+ },
17
+ "actions": [
18
+ {
19
+ "name": "addToCart",
20
+ "description": "Add a product variant to the cart",
21
+ "input": {
22
+ "type": "object",
23
+ "properties": {
24
+ "sku": { "type": "string" },
25
+ "quantity": { "type": "integer", "minimum": 1 }
26
+ },
27
+ "required": ["sku"]
28
+ },
29
+ "expose": "session",
30
+ "guard": "The variant is in stock",
31
+ "available": true,
32
+ "effects": ["cart"]
33
+ },
34
+ {
35
+ "name": "notifyWhenInStock",
36
+ "description": "Email me when a sold-out variant is back",
37
+ "input": {
38
+ "type": "object",
39
+ "properties": { "sku": { "type": "string" } },
40
+ "required": ["sku"]
41
+ },
42
+ "expose": "session",
43
+ "available": false,
44
+ "reason": "Sign in to get notified"
45
+ }
46
+ ],
47
+ "nav": [
48
+ { "to": "/cart", "label": "Cart" },
49
+ { "to": "/checkout", "label": "Checkout", "when": "cart.count > 0" }
50
+ ]
51
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "v": "0",
3
+ "name": "Acme Store",
4
+ "routes": [
5
+ {
6
+ "route": "/products/[slug]",
7
+ "description": "One product: its variants, prices and stock",
8
+ "actions": [
9
+ {
10
+ "name": "addToCart",
11
+ "description": "Add a product variant to the cart",
12
+ "input": {
13
+ "type": "object",
14
+ "properties": {
15
+ "sku": { "type": "string" },
16
+ "quantity": { "type": "integer", "minimum": 1 }
17
+ },
18
+ "required": ["sku"]
19
+ },
20
+ "expose": "public",
21
+ "guard": "The variant is in stock",
22
+ "effects": ["cart"]
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "route": "/account/orders",
28
+ "description": "Orders placed in this session, newest first",
29
+ "actions": []
30
+ }
31
+ ],
32
+ "auth": ["cookie", "bearer"]
33
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@affordances/protocol",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.0",
4
+ "description": "Affordances protocol: types, JSON Schemas, and the spec",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/vltpkg/affordances.git",
9
+ "directory": "packages/protocol"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "schemas",
14
+ "fixtures",
15
+ "spec.md"
16
+ ],
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./schemas/*.json": "./schemas/*.json",
25
+ "./fixtures/*": "./fixtures/*",
26
+ "./spec.md": "./spec.md"
27
+ },
28
+ "scripts": {
29
+ "build": "tsdown",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run --passWithNoTests",
32
+ "schemas": "node scripts/schemas.ts"
33
+ },
34
+ "dependencies": {
35
+ "zod": "^4.6.7"
36
+ }
37
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$ref": "#/$defs/Call",
4
+ "$defs": {
5
+ "Call": {
6
+ "type": "object",
7
+ "properties": {
8
+ "action": {
9
+ "type": "string",
10
+ "minLength": 1
11
+ },
12
+ "input": {},
13
+ "confirm": {
14
+ "type": "boolean"
15
+ }
16
+ },
17
+ "required": ["action"],
18
+ "additionalProperties": false
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,125 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$ref": "#/$defs/Delta",
4
+ "$defs": {
5
+ "Delta": {
6
+ "type": "object",
7
+ "properties": {
8
+ "ok": {
9
+ "type": "boolean"
10
+ },
11
+ "changed": {
12
+ "description": "Only the action's effects keys",
13
+ "type": "object",
14
+ "propertyNames": {
15
+ "type": "string"
16
+ },
17
+ "additionalProperties": {}
18
+ },
19
+ "output": {
20
+ "description": "The action's return value, matching its output schema"
21
+ },
22
+ "actionsAdded": {
23
+ "type": "array",
24
+ "items": {
25
+ "$ref": "#/$defs/ActionDescriptor"
26
+ }
27
+ },
28
+ "actionsRemoved": {
29
+ "type": "array",
30
+ "items": {
31
+ "type": "string"
32
+ }
33
+ },
34
+ "redirect": {
35
+ "type": "string"
36
+ },
37
+ "message": {
38
+ "type": "string"
39
+ },
40
+ "error": {
41
+ "type": "object",
42
+ "properties": {
43
+ "code": {
44
+ "type": "string",
45
+ "enum": [
46
+ "not_found",
47
+ "invalid_input",
48
+ "unauthorized",
49
+ "confirm_required",
50
+ "unavailable",
51
+ "rate_limited",
52
+ "internal"
53
+ ]
54
+ },
55
+ "message": {
56
+ "type": "string"
57
+ },
58
+ "issues": {}
59
+ },
60
+ "required": ["code", "message"],
61
+ "additionalProperties": false
62
+ }
63
+ },
64
+ "required": ["ok"],
65
+ "additionalProperties": false
66
+ },
67
+ "ActionDescriptor": {
68
+ "type": "object",
69
+ "properties": {
70
+ "name": {
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "description": "Stable, unique per route"
74
+ },
75
+ "description": {
76
+ "type": "string"
77
+ },
78
+ "input": {
79
+ "type": "object",
80
+ "propertyNames": {
81
+ "type": "string"
82
+ },
83
+ "additionalProperties": {},
84
+ "description": "A JSON Schema object"
85
+ },
86
+ "output": {
87
+ "type": "object",
88
+ "propertyNames": {
89
+ "type": "string"
90
+ },
91
+ "additionalProperties": {},
92
+ "description": "A JSON Schema object"
93
+ },
94
+ "expose": {
95
+ "type": "string",
96
+ "enum": ["public", "session", "confirm"]
97
+ },
98
+ "guard": {
99
+ "description": "Human-readable precondition",
100
+ "type": "string"
101
+ },
102
+ "available": {
103
+ "type": "boolean",
104
+ "description": "The guard, evaluated for this request"
105
+ },
106
+ "reason": {
107
+ "description": "Why the action is unavailable",
108
+ "type": "string"
109
+ },
110
+ "effects": {
111
+ "description": "Entity keys this action changes",
112
+ "type": "array",
113
+ "items": {
114
+ "type": "string"
115
+ }
116
+ },
117
+ "readOnly": {
118
+ "type": "boolean"
119
+ }
120
+ },
121
+ "required": ["name", "description", "input", "expose", "available"],
122
+ "additionalProperties": false
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,118 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$ref": "#/$defs/Graph",
4
+ "$defs": {
5
+ "Graph": {
6
+ "type": "object",
7
+ "properties": {
8
+ "v": {
9
+ "type": "string",
10
+ "const": "0"
11
+ },
12
+ "page": {
13
+ "type": "string",
14
+ "description": "Concrete path, e.g. /products/tee"
15
+ },
16
+ "route": {
17
+ "type": "string",
18
+ "description": "Route pattern, e.g. /products/[slug]"
19
+ },
20
+ "entities": {
21
+ "type": "object",
22
+ "propertyNames": {
23
+ "type": "string"
24
+ },
25
+ "additionalProperties": {}
26
+ },
27
+ "actions": {
28
+ "type": "array",
29
+ "items": {
30
+ "$ref": "#/$defs/ActionDescriptor"
31
+ }
32
+ },
33
+ "nav": {
34
+ "type": "array",
35
+ "items": {
36
+ "type": "object",
37
+ "properties": {
38
+ "to": {
39
+ "type": "string"
40
+ },
41
+ "label": {
42
+ "type": "string"
43
+ },
44
+ "when": {
45
+ "type": "string"
46
+ }
47
+ },
48
+ "required": ["to"],
49
+ "additionalProperties": false
50
+ }
51
+ },
52
+ "md": {
53
+ "description": "Optional Markdown alternative of the page content",
54
+ "type": "string"
55
+ }
56
+ },
57
+ "required": ["v", "page", "route", "entities", "actions"],
58
+ "additionalProperties": false
59
+ },
60
+ "ActionDescriptor": {
61
+ "type": "object",
62
+ "properties": {
63
+ "name": {
64
+ "type": "string",
65
+ "minLength": 1,
66
+ "description": "Stable, unique per route"
67
+ },
68
+ "description": {
69
+ "type": "string"
70
+ },
71
+ "input": {
72
+ "type": "object",
73
+ "propertyNames": {
74
+ "type": "string"
75
+ },
76
+ "additionalProperties": {},
77
+ "description": "A JSON Schema object"
78
+ },
79
+ "output": {
80
+ "type": "object",
81
+ "propertyNames": {
82
+ "type": "string"
83
+ },
84
+ "additionalProperties": {},
85
+ "description": "A JSON Schema object"
86
+ },
87
+ "expose": {
88
+ "type": "string",
89
+ "enum": ["public", "session", "confirm"]
90
+ },
91
+ "guard": {
92
+ "description": "Human-readable precondition",
93
+ "type": "string"
94
+ },
95
+ "available": {
96
+ "type": "boolean",
97
+ "description": "The guard, evaluated for this request"
98
+ },
99
+ "reason": {
100
+ "description": "Why the action is unavailable",
101
+ "type": "string"
102
+ },
103
+ "effects": {
104
+ "description": "Entity keys this action changes",
105
+ "type": "array",
106
+ "items": {
107
+ "type": "string"
108
+ }
109
+ },
110
+ "readOnly": {
111
+ "type": "boolean"
112
+ }
113
+ },
114
+ "required": ["name", "description", "input", "expose", "available"],
115
+ "additionalProperties": false
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,103 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$ref": "#/$defs/SiteIndex",
4
+ "$defs": {
5
+ "SiteIndex": {
6
+ "type": "object",
7
+ "properties": {
8
+ "v": {
9
+ "type": "string",
10
+ "const": "0"
11
+ },
12
+ "name": {
13
+ "type": "string"
14
+ },
15
+ "routes": {
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object",
19
+ "properties": {
20
+ "route": {
21
+ "type": "string"
22
+ },
23
+ "description": {
24
+ "description": "What the page is for, in one line",
25
+ "type": "string"
26
+ },
27
+ "actions": {
28
+ "type": "array",
29
+ "items": {
30
+ "$ref": "#/$defs/ActionSummary"
31
+ }
32
+ }
33
+ },
34
+ "required": ["route", "actions"],
35
+ "additionalProperties": false
36
+ }
37
+ },
38
+ "auth": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "string",
42
+ "enum": ["cookie", "bearer"]
43
+ }
44
+ },
45
+ "mcp": {
46
+ "description": "URL of an MCP endpoint for this site",
47
+ "type": "string"
48
+ }
49
+ },
50
+ "required": ["v", "routes", "auth"],
51
+ "additionalProperties": false
52
+ },
53
+ "ActionSummary": {
54
+ "type": "object",
55
+ "properties": {
56
+ "name": {
57
+ "type": "string",
58
+ "minLength": 1,
59
+ "description": "Stable, unique per route"
60
+ },
61
+ "description": {
62
+ "type": "string"
63
+ },
64
+ "input": {
65
+ "type": "object",
66
+ "propertyNames": {
67
+ "type": "string"
68
+ },
69
+ "additionalProperties": {},
70
+ "description": "A JSON Schema object"
71
+ },
72
+ "output": {
73
+ "type": "object",
74
+ "propertyNames": {
75
+ "type": "string"
76
+ },
77
+ "additionalProperties": {},
78
+ "description": "A JSON Schema object"
79
+ },
80
+ "expose": {
81
+ "type": "string",
82
+ "enum": ["public", "session", "confirm"]
83
+ },
84
+ "guard": {
85
+ "description": "Human-readable precondition",
86
+ "type": "string"
87
+ },
88
+ "effects": {
89
+ "description": "Entity keys this action changes",
90
+ "type": "array",
91
+ "items": {
92
+ "type": "string"
93
+ }
94
+ },
95
+ "readOnly": {
96
+ "type": "boolean"
97
+ }
98
+ },
99
+ "required": ["name", "description", "input", "expose"],
100
+ "additionalProperties": false
101
+ }
102
+ }
103
+ }
package/spec.md ADDED
@@ -0,0 +1,161 @@
1
+ # Affordances protocol, version 0
2
+
3
+ Affordances lets an agent read and operate a web page through the same URL a person uses, with the same session. A page answers in HTML for browsers and in JSON for agents. The JSON lists what the page shows (entities), what can be done there (actions), and where to go next (nav). Calling an action returns a delta: the parts of the page that changed.
4
+
5
+ This document is normative. The key words "must", "must not", "should", and "may" are used as in RFC 2119. The JSON Schemas in `schemas/` are generated from the same source as the TypeScript types and are authoritative for shapes.
6
+
7
+ ## Media type
8
+
9
+ All Affordances documents use `application/vnd.affordances+json`. A server must not return an Affordances document for a request that did not ask for one.
10
+
11
+ ## Discovery
12
+
13
+ **Per page.** A client requests the page's own URL with `Accept: application/vnd.affordances+json`. The server responds with a [graph](#graph) for that page, or a `not_found` [delta](#delta) with status `404` if the page has no Affordances graph.
14
+
15
+ HTML responses for pages with a graph should advertise it in both places:
16
+
17
+ ```http
18
+ Link: <?affordances=1>; rel="affordances"
19
+ ```
20
+
21
+ ```html
22
+ <link rel="affordances" href="?affordances=1" />
23
+ ```
24
+
25
+ `?affordances=1` is an alias for the `Accept` header, for clients that cannot set headers. A server that supports it must treat `GET <url>?affordances=1` like `GET <url>` with the Affordances `Accept` header, and must leave `affordances` out of the `searchParams` it gives the page's state.
26
+
27
+ **Per site.** `GET /.well-known/affordances.json` returns a [site index](#site-index): the protocol version, the route patterns that have graphs and their action names, the supported auth modes, and optionally an MCP endpoint.
28
+
29
+ A shared cache must never serve one representation for the other: graphs send `Vary: Accept` and `Cache-Control: private, no-store`, and a server must make sure cached HTML is keyed on `Accept` too (with `Vary: Accept`, or by routing Affordances requests before the cache, as `@affordances/next` does with rewrites).
30
+
31
+ ## Graph
32
+
33
+ ```ts
34
+ type Graph = {
35
+ v: '0'
36
+ page: string // concrete path: /products/heavyweight-tee
37
+ route: string // pattern: /products/[slug]
38
+ entities: Record<string, unknown>
39
+ actions: ActionDescriptor[]
40
+ nav?: { to: string; label?: string; when?: string }[]
41
+ md?: string
42
+ }
43
+ ```
44
+
45
+ - `entities` is the page's state, keyed by name. It must not contain anything the page would not show the same user in HTML.
46
+ - `actions` lists every action on the route that the requester may know about. Actions the requester can't call right now are still listed, with `available: false` and a `reason`.
47
+ - `nav` lists links an agent can follow. `when` is a human-readable condition.
48
+ - `md` is an optional Markdown rendering of the page's content.
49
+ - A graph should stay small. About 2,000 tokens is a good ceiling, and large collections should be summarized in `entities`.
50
+
51
+ ### Action descriptor
52
+
53
+ ```ts
54
+ type ActionDescriptor = {
55
+ name: string // unique per route, stable across releases
56
+ description: string
57
+ input: JSONSchema
58
+ output?: JSONSchema
59
+ expose: 'public' | 'session' | 'confirm'
60
+ guard?: string // human-readable precondition
61
+ available: boolean // the guard, evaluated for this request
62
+ reason?: string // why available is false
63
+ effects?: string[] // entity keys the action changes
64
+ readOnly?: boolean
65
+ }
66
+ ```
67
+
68
+ `expose` says who may call an action:
69
+
70
+ | `expose` | Caller |
71
+ | --------- | ------------------------------------------------------------------------------------ |
72
+ | `public` | anyone |
73
+ | `session` | an authenticated session, by cookie or bearer token |
74
+ | `confirm` | a session, and the call must carry `confirm: true` after the user has seen a summary |
75
+
76
+ An action with no `expose` is not part of the protocol. It must not appear in graphs and must not be callable.
77
+
78
+ ## Call
79
+
80
+ ```http
81
+ POST <page-url>
82
+ Content-Type: application/vnd.affordances+json
83
+
84
+ { "action": "addToCart", "input": { "sku": "TEE-BLK-M" } }
85
+ ```
86
+
87
+ ```ts
88
+ type Call = { action: string; input?: unknown; confirm?: boolean }
89
+ ```
90
+
91
+ The server processes a call in this order, and stops at the first failure:
92
+
93
+ 1. Resolve the action by name on the page's route. Failure: `not_found`.
94
+ 2. Check the CSRF rule below and `expose` against the caller. Failure: `unauthorized`.
95
+ 3. Apply the rate limit. Failure: `rate_limited`.
96
+ 4. Validate `input` against the action's input schema. Failure: `invalid_input`, with `issues`.
97
+ 5. Evaluate the guard. Failure: `unavailable`, with the guard's reason as `message`.
98
+ 6. For `confirm` actions without `confirm: true`: `confirm_required`, with a summary of what will happen as `message`, written for the person who approves it. Nothing runs.
99
+ 7. Run the action, reload the page's state, and respond with a delta.
100
+
101
+ ## Delta
102
+
103
+ ```ts
104
+ type Delta = {
105
+ ok: boolean
106
+ changed?: Record<string, unknown>
107
+ output?: unknown
108
+ actionsAdded?: ActionDescriptor[]
109
+ actionsRemoved?: string[]
110
+ redirect?: string
111
+ message?: string
112
+ error?: { code: ErrorCode; message: string; issues?: unknown }
113
+ }
114
+ ```
115
+
116
+ - `changed` holds the new value of each entity in the action's `effects`. When an action declares no effects, `changed` holds every top-level entity whose value differs after the action ran.
117
+ - `output` is the action's return value, described by the descriptor's `output` schema when it has one.
118
+ - `actionsAdded` and `actionsRemoved` describe changes to the action list, including availability changes. An action whose `available` flipped appears in `actionsAdded` with its new descriptor.
119
+ - `redirect` is a path the person would have been sent to. The agent should request it next.
120
+ - `message` is a short sentence suitable to repeat to the user.
121
+
122
+ ### Error codes
123
+
124
+ | Code | HTTP | Meaning |
125
+ | ------------------ | ---- | ------------------------------------------------------- |
126
+ | `not_found` | 404 | No such route or action |
127
+ | `invalid_input` | 422 | `input` failed the schema; `issues` says where |
128
+ | `unauthorized` | 401 | The caller may not call this action |
129
+ | `confirm_required` | 409 | Resend with `confirm: true` after showing `message` |
130
+ | `unavailable` | 409 | The guard failed; `message` says why |
131
+ | `rate_limited` | 429 | Too many calls; the response should carry `Retry-After` |
132
+ | `internal` | 500 | The action threw; `message` must not leak internals |
133
+
134
+ ## Site index
135
+
136
+ ```ts
137
+ type SiteIndex = {
138
+ v: '0'
139
+ name?: string
140
+ routes: { route: string; description?: string; actions: ActionSummary[] }[]
141
+ auth: ('cookie' | 'bearer')[]
142
+ mcp?: string
143
+ }
144
+
145
+ // an action descriptor without this request's availability (no `available`, no `reason`)
146
+ type ActionSummary = Omit<ActionDescriptor, 'available' | 'reason'>
147
+ ```
148
+
149
+ The index is enough to plan a task and to call an action without opening its page first: an agent that knows the route and the input can `POST` straight to a concrete URL of that route. It still gets `unavailable` or `unauthorized` if the guard or `expose` says no. Each route's `description` is one line on what the page is for. Input schemas may omit `$schema`; they are JSON Schema 2020-12.
150
+
151
+ ## Security
152
+
153
+ - Default deny. Only actions that declare `expose` are reachable.
154
+ - A call that authenticates by cookie must carry an `Origin` header that matches the site. A call with `Authorization: Bearer` is exempt. A server must not rely on its framework's server-action protections for Affordances calls.
155
+ - `confirm` actions must not run without `confirm: true`. Clients acting for a person should show `message` and get approval first.
156
+ - Input is validated before the guard runs, and the guard runs before the action.
157
+ - URLs must not carry personal data. A graph must not include data that the HTML page would not show to the same user.
158
+
159
+ ## Versioning
160
+
161
+ `v` is the protocol's major version. A breaking change ships as a new major version. Additive changes, such as new optional fields, may ship within a major version, and clients must ignore fields they don't recognize. Packages declare the range of majors they support.