@kedge/server 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/package.json +29 -3
- package/src/access.ts +472 -0
- package/src/admin-routes.ts +737 -0
- package/src/admin-shell.ts +72 -0
- package/src/app.ts +798 -0
- package/src/auth-routes.ts +381 -0
- package/src/capacity-routes.ts +440 -0
- package/src/entity-routes.ts +323 -0
- package/src/events.ts +84 -0
- package/src/http.ts +16 -0
- package/src/index.ts +2 -0
- package/src/meta-routes.ts +277 -0
- package/src/plugin-routes.ts +40 -0
- package/src/relay-routes.ts +192 -0
- package/src/track-routes.ts +102 -0
- package/src/types.ts +809 -0
- package/src/units/store.order.view.ts +9 -0
- package/src/units/store.product.view.ts +9 -0
- package/src/webhook-routes.ts +362 -0
- package/README.md +0 -3
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// kedge unit store.order.view (docs/proposals/p0.8-cli-agent-kit.md §7): the
|
|
2
|
+
// JSON of GET /store/orders/{id}. Its input is already projected for its
|
|
3
|
+
// reader (field classes, custom-field scopes); what it returns must be an
|
|
4
|
+
// object with the same id. Eject it with `kedge eject store.order.view --reason "<why>"`.
|
|
5
|
+
import type { StoreOrder, ViewOutput } from "@kedge/plugin-sdk";
|
|
6
|
+
|
|
7
|
+
export default function orderView(order: StoreOrder): ViewOutput {
|
|
8
|
+
return { ...order };
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// kedge unit store.product.view (docs/proposals/p0.8-cli-agent-kit.md §7): the
|
|
2
|
+
// JSON of GET /store/products/{handle}. Its input is what any shopper may see;
|
|
3
|
+
// what it returns must be an object with the same id. Eject it with
|
|
4
|
+
// `kedge eject store.product.view --reason "<why>"`.
|
|
5
|
+
import type { StoreProduct, ViewOutput } from "@kedge/plugin-sdk";
|
|
6
|
+
|
|
7
|
+
export default function productView(product: StoreProduct): ViewOutput {
|
|
8
|
+
return { ...product };
|
|
9
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// P0.6b's staff routes (docs/proposals/p0.6b-webhooks-calendar-preview.md §2.9,
|
|
2
|
+
// §3.1): webhook endpoints, their delivery log, replay and test, and the
|
|
3
|
+
// business calendar. Creating, rotating, replaying and testing are commands
|
|
4
|
+
// with their own envelope (`own: true`): a retry answers the id or secret
|
|
5
|
+
// it made. The store re-proves every grant rule inside its batch.
|
|
6
|
+
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
7
|
+
import { holdsUnscoped, KedgeError, readableWebhookTypes, WEBHOOK_LIMITS } from "@kedge/core";
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import { type AdminPermissions, clientOf, type KedgeEnv } from "./access";
|
|
10
|
+
import { jsonBody, ok } from "./http";
|
|
11
|
+
import type { AdminRequest, AdminStore, CalendarAdmin, PreviewCaptures, WebhookLog } from "./types";
|
|
12
|
+
|
|
13
|
+
const EndpointId = z.string().regex(/^whe_[0-9A-Za-z]{10,40}$/);
|
|
14
|
+
const DeliveryId = z.string().min(5).max(200);
|
|
15
|
+
const Events = z.array(z.string().min(3).max(64)).min(1).max(WEBHOOK_LIMITS.EVENTS_PER_ENDPOINT_MAX);
|
|
16
|
+
const Grants = z.array(z.string().min(3).max(128)).min(1).max(128);
|
|
17
|
+
const Day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
18
|
+
|
|
19
|
+
export function webhookRoutes(
|
|
20
|
+
app: OpenAPIHono<KedgeEnv>,
|
|
21
|
+
perms: AdminPermissions,
|
|
22
|
+
deps: {
|
|
23
|
+
admin?: AdminStore;
|
|
24
|
+
webhooks?: WebhookLog;
|
|
25
|
+
calendar?: CalendarAdmin;
|
|
26
|
+
previewCaptures?: PreviewCaptures;
|
|
27
|
+
now: () => number;
|
|
28
|
+
},
|
|
29
|
+
): void {
|
|
30
|
+
const ctx = (c: Context<KedgeEnv>) => c.var.access.ctx;
|
|
31
|
+
const req = (c: Context<KedgeEnv>): AdminRequest => {
|
|
32
|
+
const client = clientOf(c);
|
|
33
|
+
return { idempotencyKey: c.req.header("idempotency-key") ?? null, ip: client.ip, userAgent: client.userAgent };
|
|
34
|
+
};
|
|
35
|
+
/** A command that makes something or hands a secret out requires its key (docs/06). */
|
|
36
|
+
const keyed = (c: Context<KedgeEnv>): AdminRequest => {
|
|
37
|
+
const r = req(c);
|
|
38
|
+
if (!r.idempotencyKey || new TextEncoder().encode(r.idempotencyKey).length > 255)
|
|
39
|
+
throw new KedgeError("BAD_REQUEST", "An Idempotency-Key header (1 to 255 bytes) is required", {
|
|
40
|
+
requirement: "idempotency_key",
|
|
41
|
+
});
|
|
42
|
+
return r;
|
|
43
|
+
};
|
|
44
|
+
const once = (c: Context) => c.header("cache-control", "no-store");
|
|
45
|
+
const { admin, webhooks, calendar, previewCaptures } = deps;
|
|
46
|
+
|
|
47
|
+
if (admin) {
|
|
48
|
+
app.openapi(
|
|
49
|
+
perms.declare(
|
|
50
|
+
{ permission: "webhook:write", own: true },
|
|
51
|
+
createRoute({
|
|
52
|
+
method: "post",
|
|
53
|
+
path: "/admin/webhooks/endpoints",
|
|
54
|
+
request: jsonBody(
|
|
55
|
+
z.object({
|
|
56
|
+
url: z.string().min(9).max(2048),
|
|
57
|
+
events: Events,
|
|
58
|
+
grants: Grants,
|
|
59
|
+
description: z.string().max(200).nullable().optional(),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
responses: ok("The endpoint and its signing secret, shown once"),
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
async (c) => {
|
|
66
|
+
const b = c.req.valid("json");
|
|
67
|
+
once(c);
|
|
68
|
+
return c.json(
|
|
69
|
+
await admin.createWebhookEndpoint(
|
|
70
|
+
ctx(c),
|
|
71
|
+
{ url: b.url, events: b.events, grants: b.grants, description: b.description ?? null },
|
|
72
|
+
keyed(c),
|
|
73
|
+
),
|
|
74
|
+
200,
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
app.openapi(
|
|
80
|
+
perms.declare(
|
|
81
|
+
"webhook:read",
|
|
82
|
+
createRoute({
|
|
83
|
+
method: "get",
|
|
84
|
+
path: "/admin/webhooks/endpoints",
|
|
85
|
+
request: {
|
|
86
|
+
query: z.object({
|
|
87
|
+
status: z.enum(["active", "disabled"]).default("active"),
|
|
88
|
+
after: EndpointId.optional(),
|
|
89
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
responses: ok("One page of endpoints in one status, by id; never a secret"),
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
async (c) => {
|
|
96
|
+
const q = c.req.valid("query");
|
|
97
|
+
return c.json(
|
|
98
|
+
await admin.webhookEndpoints(ctx(c), { status: q.status, after: q.after ?? null, limit: q.limit }),
|
|
99
|
+
200,
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
app.openapi(
|
|
105
|
+
perms.declare(
|
|
106
|
+
{ permission: "webhook:write", own: true },
|
|
107
|
+
createRoute({
|
|
108
|
+
method: "patch",
|
|
109
|
+
path: "/admin/webhooks/endpoints/{id}",
|
|
110
|
+
request: {
|
|
111
|
+
params: z.object({ id: EndpointId }),
|
|
112
|
+
...jsonBody(
|
|
113
|
+
z.object({
|
|
114
|
+
url: z.string().min(9).max(2048).optional(),
|
|
115
|
+
events: Events.optional(),
|
|
116
|
+
grants: Grants.optional(),
|
|
117
|
+
description: z.string().max(200).nullable().optional(),
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
responses: ok("The endpoint's new generation"),
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
async (c) => {
|
|
125
|
+
const b = c.req.valid("json");
|
|
126
|
+
return c.json(
|
|
127
|
+
await admin.updateWebhookEndpoint(
|
|
128
|
+
ctx(c),
|
|
129
|
+
c.req.valid("param").id,
|
|
130
|
+
{
|
|
131
|
+
...(b.url !== undefined ? { url: b.url } : {}),
|
|
132
|
+
...(b.events !== undefined ? { events: b.events } : {}),
|
|
133
|
+
...(b.grants !== undefined ? { grants: b.grants } : {}),
|
|
134
|
+
...(b.description !== undefined ? { description: b.description } : {}),
|
|
135
|
+
},
|
|
136
|
+
req(c),
|
|
137
|
+
),
|
|
138
|
+
200,
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
for (const action of ["disable", "enable", "delete"] as const) {
|
|
144
|
+
app.openapi(
|
|
145
|
+
perms.declare(
|
|
146
|
+
{ permission: "webhook:write", own: true },
|
|
147
|
+
createRoute({
|
|
148
|
+
method: action === "delete" ? "delete" : "post",
|
|
149
|
+
path: action === "delete" ? "/admin/webhooks/endpoints/{id}" : `/admin/webhooks/endpoints/{id}/${action}`,
|
|
150
|
+
request: { params: z.object({ id: EndpointId }) },
|
|
151
|
+
responses: ok(`The endpoint, ${action}d`),
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
async (c) => {
|
|
155
|
+
const id = c.req.valid("param").id;
|
|
156
|
+
await admin.setWebhookEndpointStatus(ctx(c), id, action, req(c));
|
|
157
|
+
return c.json(
|
|
158
|
+
{ id, status: action === "enable" ? "active" : action === "disable" ? "disabled" : "deleted" },
|
|
159
|
+
200,
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
app.openapi(
|
|
166
|
+
perms.declare(
|
|
167
|
+
{ permission: "webhook:write", own: true },
|
|
168
|
+
createRoute({
|
|
169
|
+
method: "post",
|
|
170
|
+
path: "/admin/webhooks/endpoints/{id}/rotate-secret",
|
|
171
|
+
request: { params: z.object({ id: EndpointId }) },
|
|
172
|
+
responses: ok("The new current secret, shown once; the one it replaces signs for 24 hours more"),
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
async (c) => {
|
|
176
|
+
once(c);
|
|
177
|
+
return c.json(await admin.rotateWebhookSecret(ctx(c), c.req.valid("param").id, keyed(c)), 200);
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
app.openapi(
|
|
182
|
+
perms.declare(
|
|
183
|
+
{ permission: "webhook:write", own: true },
|
|
184
|
+
createRoute({
|
|
185
|
+
method: "post",
|
|
186
|
+
path: "/admin/webhooks/endpoints/{id}/test",
|
|
187
|
+
request: { params: z.object({ id: EndpointId }) },
|
|
188
|
+
responses: ok("The test delivery's id"),
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
async (c) =>
|
|
192
|
+
c.json({ delivery_id: (await admin.testWebhookEndpoint(ctx(c), c.req.valid("param").id, keyed(c))).id }, 200),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
app.openapi(
|
|
196
|
+
perms.declare(
|
|
197
|
+
{ permission: "webhook:write", own: true },
|
|
198
|
+
createRoute({
|
|
199
|
+
method: "post",
|
|
200
|
+
path: "/admin/webhooks/deliveries/{id}/replay",
|
|
201
|
+
request: { params: z.object({ id: DeliveryId }) },
|
|
202
|
+
responses: ok("The replay's delivery id; a retry answers the same"),
|
|
203
|
+
}),
|
|
204
|
+
),
|
|
205
|
+
async (c) =>
|
|
206
|
+
c.json({ delivery_id: (await admin.replayWebhookDelivery(ctx(c), c.req.valid("param").id, keyed(c))).id }, 200),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (webhooks) {
|
|
211
|
+
app.openapi(
|
|
212
|
+
perms.declare(
|
|
213
|
+
"webhook:read",
|
|
214
|
+
createRoute({
|
|
215
|
+
method: "get",
|
|
216
|
+
path: "/admin/webhooks/deliveries",
|
|
217
|
+
request: {
|
|
218
|
+
query: z.object({
|
|
219
|
+
endpoint: EndpointId.optional(),
|
|
220
|
+
state: z.enum(["pending", "finalizing", "done", "failed"]).optional(),
|
|
221
|
+
after: z.coerce.number().int().min(0).default(0),
|
|
222
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
responses: ok("Deliveries with their attempts: status, code, body hash and size, never a body"),
|
|
226
|
+
}),
|
|
227
|
+
),
|
|
228
|
+
async (c) => {
|
|
229
|
+
const q = c.req.valid("query");
|
|
230
|
+
return c.json(
|
|
231
|
+
await webhooks.log({
|
|
232
|
+
// Only the types the reader may read unscoped (§2.9), each a bounded seek.
|
|
233
|
+
types: readableWebhookTypes(ctx(c).grants),
|
|
234
|
+
...(q.endpoint !== undefined ? { endpoint: q.endpoint } : {}),
|
|
235
|
+
...(q.state !== undefined ? { state: q.state } : {}),
|
|
236
|
+
after: q.after,
|
|
237
|
+
limit: q.limit,
|
|
238
|
+
}),
|
|
239
|
+
200,
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (previewCaptures) {
|
|
246
|
+
// A capture holds a whole envelope, so its reader needs everything its
|
|
247
|
+
// content could need (codex r3); a sign-in code or invite link is a
|
|
248
|
+
// credential, shown only to a strong Owner (codex r11).
|
|
249
|
+
const NEEDS = ["settings:read", "order:read", "capacity:read", "webhook:read", "field:pii", "field:financial"];
|
|
250
|
+
app.openapi(
|
|
251
|
+
perms.declare(
|
|
252
|
+
"settings:read",
|
|
253
|
+
createRoute({
|
|
254
|
+
method: "get",
|
|
255
|
+
path: "/admin/preview/captures",
|
|
256
|
+
request: {
|
|
257
|
+
query: z.object({
|
|
258
|
+
port: z.string().min(1).max(64).optional(),
|
|
259
|
+
after: z.coerce.number().int().min(0).default(0),
|
|
260
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
responses: ok("A preview's captured calls, webhooks and authentication emails"),
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
async (c) => {
|
|
267
|
+
const access = ctx(c);
|
|
268
|
+
const missing = NEEDS.filter((p) => !holdsUnscoped(access.grants, p));
|
|
269
|
+
if (missing.length)
|
|
270
|
+
throw new KedgeError("FORBIDDEN", "Reading captures needs every permission their content could need", {
|
|
271
|
+
missing,
|
|
272
|
+
});
|
|
273
|
+
const q = c.req.valid("query");
|
|
274
|
+
const rows = await previewCaptures.list({
|
|
275
|
+
...(q.port !== undefined ? { port: q.port } : {}),
|
|
276
|
+
after: q.after,
|
|
277
|
+
limit: q.limit,
|
|
278
|
+
});
|
|
279
|
+
if (rows === null) throw new KedgeError("NOT_FOUND", "Captures exist only in a preview");
|
|
280
|
+
const p = access.principal;
|
|
281
|
+
const strongOwner = p.kind === "staff" && p.strong && holdsUnscoped(access.grants, "owner:manage");
|
|
282
|
+
return c.json(
|
|
283
|
+
{
|
|
284
|
+
captures: rows.map((r) =>
|
|
285
|
+
r.kind === "call" || r.kind === "webhook" || strongOwner
|
|
286
|
+
? r
|
|
287
|
+
: { ...r, envelope: { redacted: true }, result: null },
|
|
288
|
+
),
|
|
289
|
+
},
|
|
290
|
+
200,
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (calendar) {
|
|
297
|
+
app.openapi(
|
|
298
|
+
perms.declare(
|
|
299
|
+
"settings:read",
|
|
300
|
+
createRoute({
|
|
301
|
+
method: "get",
|
|
302
|
+
path: "/admin/settings/calendar",
|
|
303
|
+
responses: ok("Working days, the cutoff, and the holidays of the calendar's window"),
|
|
304
|
+
}),
|
|
305
|
+
),
|
|
306
|
+
async (c) => c.json((await calendar.get(deps.now())) as Record<string, unknown>, 200),
|
|
307
|
+
);
|
|
308
|
+
app.openapi(
|
|
309
|
+
perms.declare(
|
|
310
|
+
"settings:write",
|
|
311
|
+
createRoute({
|
|
312
|
+
method: "put",
|
|
313
|
+
path: "/admin/settings/calendar",
|
|
314
|
+
request: jsonBody(
|
|
315
|
+
z.object({
|
|
316
|
+
working_days: z.number().int().min(1).max(127),
|
|
317
|
+
cutoff_minutes: z.number().int().min(0).max(1439).nullable(),
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
320
|
+
responses: ok("The calendar"),
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
async (c) => {
|
|
324
|
+
const b = c.req.valid("json");
|
|
325
|
+
await calendar.set({ workingDays: b.working_days, cutoffMinutes: b.cutoff_minutes });
|
|
326
|
+
return c.json((await calendar.get(deps.now())) as Record<string, unknown>, 200);
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
app.openapi(
|
|
330
|
+
perms.declare(
|
|
331
|
+
"settings:write",
|
|
332
|
+
createRoute({
|
|
333
|
+
method: "put",
|
|
334
|
+
path: "/admin/settings/calendar/holidays/{day}",
|
|
335
|
+
request: { params: z.object({ day: Day }), ...jsonBody(z.object({ name: z.string().min(1).max(80) })) },
|
|
336
|
+
responses: ok("The holiday"),
|
|
337
|
+
}),
|
|
338
|
+
),
|
|
339
|
+
async (c) => {
|
|
340
|
+
const day = c.req.valid("param").day;
|
|
341
|
+
await calendar.putHoliday(day, c.req.valid("json").name);
|
|
342
|
+
return c.json({ day }, 200);
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
app.openapi(
|
|
346
|
+
perms.declare(
|
|
347
|
+
"settings:write",
|
|
348
|
+
createRoute({
|
|
349
|
+
method: "delete",
|
|
350
|
+
path: "/admin/settings/calendar/holidays/{day}",
|
|
351
|
+
request: { params: z.object({ day: Day }) },
|
|
352
|
+
responses: ok("The holiday, removed"),
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
async (c) => {
|
|
356
|
+
const day = c.req.valid("param").day;
|
|
357
|
+
await calendar.deleteHoliday(day);
|
|
358
|
+
return c.json({ day, removed: true }, 200);
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
package/README.md
DELETED