@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 +19 -2
- package/dist/index.d.ts +264 -0
- package/dist/index.js +90 -0
- package/fixtures/invalid/call.no-action.json +1 -0
- package/fixtures/invalid/delta.unknown-code.json +1 -0
- package/fixtures/invalid/graph.bad-expose.json +9 -0
- package/fixtures/invalid/graph.wrong-version.json +1 -0
- package/fixtures/valid/call.place-order.json +1 -0
- package/fixtures/valid/delta.add-to-cart.json +5 -0
- package/fixtures/valid/delta.errors.json +28 -0
- package/fixtures/valid/graph.product.json +51 -0
- package/fixtures/valid/site-index.json +33 -0
- package/package.json +35 -4
- package/schemas/call.json +21 -0
- package/schemas/delta.json +125 -0
- package/schemas/graph.json +118 -0
- package/schemas/site-index.json +103 -0
- package/spec.md +161 -0
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @affordances/protocol
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{ "v": "1", "page": "/", "route": "/", "entities": {}, "actions": [] }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "action": "placeOrder", "input": { "shipping": "standard" }, "confirm": true }
|
|
@@ -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
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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.
|