@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
package/src/app.ts
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
// The HTTP layer (docs/06): /store, /admin, /health. Platform-agnostic; the
|
|
2
|
+
// runtime supplies the cart gateway and stores.
|
|
3
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
4
|
+
import {
|
|
5
|
+
actorOf,
|
|
6
|
+
CustomFields,
|
|
7
|
+
checkedView,
|
|
8
|
+
deriveToken,
|
|
9
|
+
ERROR_CODES,
|
|
10
|
+
type ErrorCode,
|
|
11
|
+
holds,
|
|
12
|
+
isKedgeError,
|
|
13
|
+
isPluginErrorCode,
|
|
14
|
+
KedgeError,
|
|
15
|
+
logCodeOf,
|
|
16
|
+
type OrderView,
|
|
17
|
+
type Principal,
|
|
18
|
+
type StoreOrder,
|
|
19
|
+
UncertainOutcomeError,
|
|
20
|
+
verifyToken,
|
|
21
|
+
} from "@kedge/core";
|
|
22
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
23
|
+
import { cors } from "hono/cors";
|
|
24
|
+
import { HTTPException } from "hono/http-exception";
|
|
25
|
+
import { AdminPermissions, adminGate, type KedgeEnv, sessionCookie, storeGate } from "./access";
|
|
26
|
+
import { adminRoutes, orderRecordJson } from "./admin-routes";
|
|
27
|
+
import { AUTHZ_HEADER, checkAdminBuild, SESSION_HEADER } from "./admin-shell";
|
|
28
|
+
import { authRoutes } from "./auth-routes";
|
|
29
|
+
import { capacityRoutes } from "./capacity-routes";
|
|
30
|
+
import { entityRoutes } from "./entity-routes";
|
|
31
|
+
import { jsonBody, ok } from "./http";
|
|
32
|
+
import { metaRoutes } from "./meta-routes";
|
|
33
|
+
import { pluginRoutes } from "./plugin-routes";
|
|
34
|
+
import { relayRoutes } from "./relay-routes";
|
|
35
|
+
import { trackRoutes } from "./track-routes";
|
|
36
|
+
import type { AppDeps, CartView, Result } from "./types";
|
|
37
|
+
import defaultOrderView from "./units/store.order.view";
|
|
38
|
+
import defaultProductView from "./units/store.product.view";
|
|
39
|
+
import { webhookRoutes } from "./webhook-routes";
|
|
40
|
+
|
|
41
|
+
const CART_ID = /^cart_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
42
|
+
const ORDER_ID = /^ord_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
43
|
+
|
|
44
|
+
const money = (amount: number, currency_code: string) => ({ amount, currency_code });
|
|
45
|
+
|
|
46
|
+
const iso = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString());
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A cart view as JSON. During the first deployment of P0.3 (`legacy`), the
|
|
50
|
+
* view can come from an object still running P0.2, which has none of P0.3's
|
|
51
|
+
* fields: each defaults to what a P0.2 cart means (§1.6), so the response of a
|
|
52
|
+
* mutation that saved is never an error.
|
|
53
|
+
*/
|
|
54
|
+
function cartJson(c: CartView) {
|
|
55
|
+
const m = (a: number | null | undefined) => (a === null || a === undefined ? null : money(a, c.currency_code));
|
|
56
|
+
const t = c.totals;
|
|
57
|
+
return {
|
|
58
|
+
id: c.id,
|
|
59
|
+
status: c.status,
|
|
60
|
+
currency_code: c.currency_code,
|
|
61
|
+
email: c.email,
|
|
62
|
+
revision: c.revision,
|
|
63
|
+
locale: c.locale ?? "en",
|
|
64
|
+
custom: c.custom ?? {},
|
|
65
|
+
consents: c.consents ?? {},
|
|
66
|
+
pricing: c.pricing ?? "ok",
|
|
67
|
+
lines: c.lines.map((l) => ({
|
|
68
|
+
id: l.id ?? null,
|
|
69
|
+
variant_id: l.variant_id,
|
|
70
|
+
kind: l.kind ?? "item",
|
|
71
|
+
parent_line_id: l.parent_line_id ?? null,
|
|
72
|
+
quantity_mode: l.quantity_mode ?? "own",
|
|
73
|
+
quantity_per_parent: l.quantity_per_parent ?? null,
|
|
74
|
+
period_start: iso(l.period_start ?? null),
|
|
75
|
+
period_end: iso(l.period_end ?? null),
|
|
76
|
+
custom: l.custom ?? {},
|
|
77
|
+
sku: l.sku,
|
|
78
|
+
title: l.title,
|
|
79
|
+
charge_class: l.charge_class ?? "revenue",
|
|
80
|
+
unit_price: m(l.unit_amount),
|
|
81
|
+
quantity: l.quantity,
|
|
82
|
+
total: m(l.line_total),
|
|
83
|
+
breakdown: l.breakdown ?? null,
|
|
84
|
+
})),
|
|
85
|
+
totals: t && {
|
|
86
|
+
subtotal: m(t.subtotal),
|
|
87
|
+
discount_total: m(t.discount_total),
|
|
88
|
+
shipping_total: m(t.shipping_total),
|
|
89
|
+
fee_total: m(t.fee_total),
|
|
90
|
+
tax_total: m(t.tax_total),
|
|
91
|
+
total: m(t.total),
|
|
92
|
+
revenue_total: m(t.revenue_total ?? t.total),
|
|
93
|
+
deposit_total: m(t.deposit_total ?? 0),
|
|
94
|
+
},
|
|
95
|
+
requirements: c.requirements,
|
|
96
|
+
order_id: c.order_id,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An ISO instant in the API, epoch ms inside (§1.1). */
|
|
101
|
+
const Instant = z.iso.datetime({ offset: true }).transform((v) => Date.parse(v));
|
|
102
|
+
const LINE_ID = /^cl_[0-9A-Za-z_]{1,64}$/;
|
|
103
|
+
// Custom-field values arrive as parsed JSON and are checked by core's field
|
|
104
|
+
// rules (P0.3 §4.1). Not `z.json()`: its recursive schema overflows the stack
|
|
105
|
+
// when the OpenAPI document is generated (the P0.7 build found the document
|
|
106
|
+
// had never rendered).
|
|
107
|
+
const Custom = z.record(z.string().max(64), z.unknown());
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* An order as JSON for one reader: its fields projected by read scope (§4.2),
|
|
111
|
+
* so a staff-only field reaches the admin API and never the store API.
|
|
112
|
+
*/
|
|
113
|
+
function orderJson(o: OrderView, fields: CustomFields, reader: Principal): StoreOrder {
|
|
114
|
+
const m = (a: number) => money(a, o.currency);
|
|
115
|
+
return {
|
|
116
|
+
id: o.id,
|
|
117
|
+
status: o.status,
|
|
118
|
+
payment_status: o.paymentStatus,
|
|
119
|
+
number: o.number,
|
|
120
|
+
currency_code: o.currency,
|
|
121
|
+
email: o.email,
|
|
122
|
+
locale: o.locale ?? "en",
|
|
123
|
+
subtotal: m(o.subtotal),
|
|
124
|
+
tax_total: m(o.taxTotal ?? 0),
|
|
125
|
+
total: m(o.total),
|
|
126
|
+
revenue_total: m(o.revenueTotal ?? o.total),
|
|
127
|
+
deposit_total: m(o.depositTotal ?? 0),
|
|
128
|
+
expires_at: o.expiresAt,
|
|
129
|
+
placed_at: o.placedAt,
|
|
130
|
+
place_by: iso(o.placeBy ?? null),
|
|
131
|
+
custom: fields.project("order", o.custom ?? {}, reader),
|
|
132
|
+
lines: o.lines.map((l) => ({
|
|
133
|
+
id: l.id,
|
|
134
|
+
variant_id: l.variantId,
|
|
135
|
+
kind: l.kind ?? "item",
|
|
136
|
+
parent_line_id: l.parentLineId ?? null,
|
|
137
|
+
quantity_mode: l.quantityMode ?? "own",
|
|
138
|
+
quantity_per_parent: l.quantityPerParent ?? null,
|
|
139
|
+
period_start: iso(l.periodStart ?? null),
|
|
140
|
+
period_end: iso(l.periodEnd ?? null),
|
|
141
|
+
charge_class: l.chargeClass ?? "revenue",
|
|
142
|
+
sku: l.sku,
|
|
143
|
+
title: l.title,
|
|
144
|
+
unit_price: m(l.unitAmount),
|
|
145
|
+
quantity: l.quantity,
|
|
146
|
+
custom: fields.project("order_line", l.custom ?? {}, reader),
|
|
147
|
+
})),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Errors returned as data by the Durable Object become KedgeErrors again here. */
|
|
152
|
+
function unwrap<T>(r: Result<T>): T {
|
|
153
|
+
if (r.ok) return r.value;
|
|
154
|
+
const { code, message, status, details } = r.error;
|
|
155
|
+
// A plugin's registered refusal keeps its code and status (409 for spent
|
|
156
|
+
// points), never a 500: the error map is the plugin's contract with shoppers.
|
|
157
|
+
if (isPluginErrorCode(code) && status !== undefined && status >= 400 && status <= 599)
|
|
158
|
+
throw KedgeError.plugin(code, status, message, details ?? {});
|
|
159
|
+
throw new KedgeError((Object.hasOwn(ERROR_CODES, code) ? code : "INTERNAL") as ErrorCode, message, details ?? {});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const CartIdParam = z.object({ id: z.string().regex(CART_ID) });
|
|
163
|
+
|
|
164
|
+
export function createApp(deps: AppDeps) {
|
|
165
|
+
const { secrets, carts, products, orders, ids, settings, auth } = deps;
|
|
166
|
+
const productView = deps.overrides?.["store.product.view"] ?? defaultProductView;
|
|
167
|
+
const orderView = deps.overrides?.["store.order.view"] ?? defaultOrderView;
|
|
168
|
+
const now = () => deps.clock.now();
|
|
169
|
+
const features = deps.lineFeatures ?? "on";
|
|
170
|
+
/** In `legacy` mode the P0.3 routes don't exist yet: the previous object may be serving (§1.6). */
|
|
171
|
+
const notInLegacy = () => {
|
|
172
|
+
if (features === "legacy") throw new KedgeError("NOT_FOUND", "Not found");
|
|
173
|
+
};
|
|
174
|
+
/** In `frozen` mode nothing P0.2 couldn't represent is created; existing state stays usable (§1.6). */
|
|
175
|
+
const frozen = (creates: boolean) => {
|
|
176
|
+
if (features === "frozen" && creates)
|
|
177
|
+
throw new KedgeError("FEATURE_FROZEN", "This store isn't accepting new kinds of lines right now");
|
|
178
|
+
};
|
|
179
|
+
const identityKeys = new Set(deps.identityKeys ?? []);
|
|
180
|
+
const setsIdentity = (custom: Record<string, unknown> | undefined) =>
|
|
181
|
+
Object.keys(custom ?? {}).some((k) => identityKeys.has(k));
|
|
182
|
+
const app = new OpenAPIHono<KedgeEnv>({
|
|
183
|
+
defaultHook: (result) => {
|
|
184
|
+
if (!result.success) {
|
|
185
|
+
throw new KedgeError("BAD_REQUEST", "Invalid request", {
|
|
186
|
+
issues: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.onError((err, c) => {
|
|
193
|
+
if (isKedgeError(err)) {
|
|
194
|
+
if (err.code === "OVERLOADED" || err.code === "OUTCOME_UNKNOWN" || err.code === "TIMEOUT")
|
|
195
|
+
c.header("Retry-After", "1");
|
|
196
|
+
return c.json(err.toJSON(), err.status as 400);
|
|
197
|
+
}
|
|
198
|
+
// Framework errors below 500 (e.g. a malformed JSON body) are the caller's: 400, not 500.
|
|
199
|
+
if (err instanceof HTTPException && err.status < 500) {
|
|
200
|
+
return c.json(new KedgeError("BAD_REQUEST", "Malformed request").toJSON(), 400);
|
|
201
|
+
}
|
|
202
|
+
if (err instanceof UncertainOutcomeError) {
|
|
203
|
+
c.header("Retry-After", "1");
|
|
204
|
+
return c.json(
|
|
205
|
+
new KedgeError("OUTCOME_UNKNOWN", "The write may or may not have happened; check before retrying").toJSON(),
|
|
206
|
+
503,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// Unknown failures keep their message out of the response (it may carry internals).
|
|
210
|
+
// Its code only: an unexpected error's message can carry whatever the code that threw it held (p0.8 §4.5).
|
|
211
|
+
// kedge-allow no-console: named fields and the error's code only
|
|
212
|
+
console.error(JSON.stringify({ level: "error", event: "unhandled", code: logCodeOf(err) }));
|
|
213
|
+
return c.json(new KedgeError("INTERNAL", "Internal error").toJSON(), 500);
|
|
214
|
+
});
|
|
215
|
+
app.notFound(() => {
|
|
216
|
+
throw new KedgeError("NOT_FOUND", "Not found");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// `maintenance` is the cutover's signal (p0.8 §6.3): the maintenance Worker answers true.
|
|
220
|
+
app.get("/health", (c) => c.json({ ok: true, maintenance: false }));
|
|
221
|
+
|
|
222
|
+
// CORS runs before authentication: a browser's preflight carries no credentials,
|
|
223
|
+
// so a credential check first would refuse it and the real request would never be sent.
|
|
224
|
+
// The headers it sets stay on every response, errors included.
|
|
225
|
+
const headers = ["Authorization", "Content-Type", "Idempotency-Key", "X-Kedge-CSRF", "X-Kedge-Session-Mode"];
|
|
226
|
+
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
227
|
+
const admin = new Set(auth.adminOrigins);
|
|
228
|
+
// An admin on another origin speaks P0.9's protocol too (p0.9 §4.1): its two
|
|
229
|
+
// request headers allowed, and the two answer headers it compares exposed.
|
|
230
|
+
const adminCors = cors({
|
|
231
|
+
origin: (origin) => (admin.has(origin) ? origin : null),
|
|
232
|
+
allowMethods: methods,
|
|
233
|
+
allowHeaders: [...headers, "X-Kedge-Admin-Build", "X-Kedge-Authz-Expect"],
|
|
234
|
+
exposeHeaders: [SESSION_HEADER, AUTHZ_HEADER],
|
|
235
|
+
credentials: true,
|
|
236
|
+
maxAge: 600,
|
|
237
|
+
});
|
|
238
|
+
app.use("/admin/*", adminCors);
|
|
239
|
+
app.use("/auth/admin/*", adminCors);
|
|
240
|
+
// A stale admin's ceremony is refused before it runs, as its admin calls are (p0.9 §2.4).
|
|
241
|
+
app.use("/auth/admin/*", async (c, next) => {
|
|
242
|
+
if (c.req.method !== "OPTIONS") checkAdminBuild(c, auth.adminBuild ?? null);
|
|
243
|
+
await next();
|
|
244
|
+
});
|
|
245
|
+
// A preflight carries no key, so it is answered for an origin any storefront
|
|
246
|
+
// with an unrevoked key lists (§6.2). The actual request's origin is echoed:
|
|
247
|
+
// the store gate refuses an origin its own key's storefront doesn't list
|
|
248
|
+
// before anything is read, so the echo never exposes a response to it.
|
|
249
|
+
const storeCors = cors({
|
|
250
|
+
origin: async (origin, c) => {
|
|
251
|
+
if (c.req.method !== "OPTIONS") return origin;
|
|
252
|
+
return (await auth.access.storefrontOrigins(now())).has(origin) ? origin : null;
|
|
253
|
+
},
|
|
254
|
+
allowMethods: methods,
|
|
255
|
+
allowHeaders: [...headers, "X-Cart-Token", "X-Order-Token", "X-Kedge-Key"],
|
|
256
|
+
credentials: true,
|
|
257
|
+
maxAge: 600,
|
|
258
|
+
});
|
|
259
|
+
app.use("/store/*", storeCors);
|
|
260
|
+
app.use("/auth/store/*", storeCors);
|
|
261
|
+
const perms = new AdminPermissions();
|
|
262
|
+
app.use("/admin/*", adminGate(auth, perms, now));
|
|
263
|
+
app.use("/store/*", storeGate(auth, now));
|
|
264
|
+
authRoutes(app, auth, now);
|
|
265
|
+
|
|
266
|
+
// The cart token is checked here, before the cart's Durable Object is touched:
|
|
267
|
+
// touching it would create it, so a check inside the object comes too late.
|
|
268
|
+
const cartToken: MiddlewareHandler = async (c, next) => {
|
|
269
|
+
const id = c.req.param("id") ?? "";
|
|
270
|
+
if (!CART_ID.test(id) || !(await verifyToken(secrets.cartTokenSecret, "cart", id, c.req.header("x-cart-token")))) {
|
|
271
|
+
throw new KedgeError("UNAUTHORIZED", "Missing or invalid cart token");
|
|
272
|
+
}
|
|
273
|
+
await next();
|
|
274
|
+
};
|
|
275
|
+
app.use("/store/carts/:id", cartToken);
|
|
276
|
+
app.use("/store/carts/:id/*", cartToken);
|
|
277
|
+
|
|
278
|
+
const token = (c: Context) => c.req.header("x-cart-token") ?? "";
|
|
279
|
+
/** A cart call's result; a session the cart renewed refreshes the customer's cookie (§2.3). */
|
|
280
|
+
const unwrapCart = <T>(c: Context<KedgeEnv>, r: Result<T>): T => {
|
|
281
|
+
const cookie = c.var.cartCookie;
|
|
282
|
+
if (r.ok && r.session && cookie)
|
|
283
|
+
c.header("set-cookie", sessionCookie("customer", cookie, r.session.expiresAt, now()), { append: true });
|
|
284
|
+
return unwrap(r);
|
|
285
|
+
};
|
|
286
|
+
const fields = deps.fields ?? CustomFields.none;
|
|
287
|
+
const withOrderToken = async (order: OrderView) => ({
|
|
288
|
+
...orderJson(order, fields, "store"),
|
|
289
|
+
token: await deriveToken(secrets.orderTokenSecret, "order", order.id),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// --- store ---
|
|
293
|
+
app.openapi(
|
|
294
|
+
createRoute({
|
|
295
|
+
method: "get",
|
|
296
|
+
path: "/store/products/{handle}",
|
|
297
|
+
request: { params: z.object({ handle: z.string().min(1) }), query: z.object({ currency: z.string().length(3) }) },
|
|
298
|
+
responses: ok("A product"),
|
|
299
|
+
}),
|
|
300
|
+
async (c) => {
|
|
301
|
+
const { handle } = c.req.valid("param");
|
|
302
|
+
const { currency } = c.req.valid("query");
|
|
303
|
+
const p = await products.productByHandle(handle, currency);
|
|
304
|
+
if (!p) throw new KedgeError("NOT_FOUND", "Product not found");
|
|
305
|
+
// The store's own view, if it ejected one (p0.8 §7), over what any shopper may see.
|
|
306
|
+
const id = p.id;
|
|
307
|
+
return c.json(checkedView("store.product.view", id, productView(p)), 200);
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
app.openapi(
|
|
312
|
+
createRoute({
|
|
313
|
+
method: "post",
|
|
314
|
+
path: "/store/carts",
|
|
315
|
+
request: jsonBody(
|
|
316
|
+
z.object({
|
|
317
|
+
currency_code: z.string().length(3),
|
|
318
|
+
email: z.email().nullable().optional(),
|
|
319
|
+
locale: z.string().min(2).max(35).optional(),
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
responses: ok("A new cart and its token"),
|
|
323
|
+
}),
|
|
324
|
+
async (c) => {
|
|
325
|
+
const body = c.req.valid("json");
|
|
326
|
+
const id = ids.next("cart");
|
|
327
|
+
const cart = unwrapCart(
|
|
328
|
+
c,
|
|
329
|
+
await carts.create(id, c.var.proof, {
|
|
330
|
+
currency: body.currency_code,
|
|
331
|
+
email: body.email ?? null,
|
|
332
|
+
...(features !== "legacy" && body.locale ? { locale: body.locale } : {}),
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
return c.json({ ...cartJson(cart), token: await deriveToken(secrets.cartTokenSecret, "cart", id) }, 200);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
app.openapi(
|
|
340
|
+
createRoute({
|
|
341
|
+
method: "get",
|
|
342
|
+
path: "/store/carts/{id}",
|
|
343
|
+
request: { params: CartIdParam },
|
|
344
|
+
responses: ok("A cart"),
|
|
345
|
+
}),
|
|
346
|
+
async (c) => c.json(cartJson(unwrapCart(c, await carts.get(c.req.valid("param").id, token(c), c.var.proof))), 200),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
app.openapi(
|
|
350
|
+
createRoute({
|
|
351
|
+
method: "post",
|
|
352
|
+
path: "/store/carts/{id}/lines",
|
|
353
|
+
request: {
|
|
354
|
+
params: CartIdParam,
|
|
355
|
+
...jsonBody(
|
|
356
|
+
z.object({
|
|
357
|
+
variant_id: z.string().min(1),
|
|
358
|
+
quantity: z.number().int().min(1).max(100_000).optional(),
|
|
359
|
+
kind: z.string().min(1).max(64).optional(),
|
|
360
|
+
parent_line_id: z.string().regex(LINE_ID).optional(),
|
|
361
|
+
quantity_mode: z.enum(["own", "per_parent"]).optional(),
|
|
362
|
+
quantity_per_parent: z.number().int().min(1).max(100_000).optional(),
|
|
363
|
+
period_start: Instant.optional(),
|
|
364
|
+
period_end: Instant.optional(),
|
|
365
|
+
custom: Custom.optional(),
|
|
366
|
+
}),
|
|
367
|
+
),
|
|
368
|
+
},
|
|
369
|
+
responses: ok("The cart"),
|
|
370
|
+
}),
|
|
371
|
+
async (c) => {
|
|
372
|
+
const b = c.req.valid("json");
|
|
373
|
+
const id = c.req.valid("param").id;
|
|
374
|
+
if (features === "legacy") {
|
|
375
|
+
if (b.quantity === undefined) throw new KedgeError("BAD_REQUEST", "quantity is required");
|
|
376
|
+
return c.json(
|
|
377
|
+
cartJson(unwrapCart(c, await carts.setLine(id, token(c), c.var.proof, b.variant_id, b.quantity, "add"))),
|
|
378
|
+
200,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
frozen(
|
|
382
|
+
(b.kind !== undefined && b.kind !== "item") ||
|
|
383
|
+
b.parent_line_id !== undefined ||
|
|
384
|
+
b.period_start !== undefined ||
|
|
385
|
+
setsIdentity(b.custom),
|
|
386
|
+
);
|
|
387
|
+
const cart = await carts.addLine(id, token(c), c.var.proof, {
|
|
388
|
+
variantId: b.variant_id,
|
|
389
|
+
kind: b.kind,
|
|
390
|
+
parentId: b.parent_line_id ?? null,
|
|
391
|
+
quantityMode: b.quantity_mode,
|
|
392
|
+
quantity: b.quantity,
|
|
393
|
+
quantityPerParent: b.quantity_per_parent ?? null,
|
|
394
|
+
periodStart: b.period_start ?? null,
|
|
395
|
+
periodEnd: b.period_end ?? null,
|
|
396
|
+
custom: b.custom as Record<string, never> | undefined,
|
|
397
|
+
});
|
|
398
|
+
return c.json(cartJson(unwrapCart(c, cart)), 200);
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
app.openapi(
|
|
403
|
+
createRoute({
|
|
404
|
+
method: "post",
|
|
405
|
+
path: "/store/carts/{id}/lines/remove",
|
|
406
|
+
request: {
|
|
407
|
+
params: CartIdParam,
|
|
408
|
+
...jsonBody(z.object({ line_ids: z.array(z.string().regex(LINE_ID)).min(1).max(100) })),
|
|
409
|
+
},
|
|
410
|
+
responses: ok("The cart"),
|
|
411
|
+
}),
|
|
412
|
+
async (c) => {
|
|
413
|
+
notInLegacy();
|
|
414
|
+
const cart = await carts.removeLines(
|
|
415
|
+
c.req.valid("param").id,
|
|
416
|
+
token(c),
|
|
417
|
+
c.var.proof,
|
|
418
|
+
c.req.valid("json").line_ids,
|
|
419
|
+
);
|
|
420
|
+
return c.json(cartJson(unwrapCart(c, cart)), 200);
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
app.openapi(
|
|
425
|
+
createRoute({
|
|
426
|
+
method: "delete",
|
|
427
|
+
path: "/store/carts/{id}/lines/{line_id}",
|
|
428
|
+
request: { params: CartIdParam.extend({ line_id: z.string().regex(LINE_ID) }) },
|
|
429
|
+
responses: ok("The cart"),
|
|
430
|
+
}),
|
|
431
|
+
async (c) => {
|
|
432
|
+
notInLegacy();
|
|
433
|
+
const p = c.req.valid("param");
|
|
434
|
+
return c.json(cartJson(unwrapCart(c, await carts.removeLines(p.id, token(c), c.var.proof, [p.line_id]))), 200);
|
|
435
|
+
},
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
app.openapi(
|
|
439
|
+
createRoute({
|
|
440
|
+
method: "patch",
|
|
441
|
+
// A line id from P0.3 on; a variant id in `legacy` mode, as P0.2 served it (§1.6).
|
|
442
|
+
path: "/store/carts/{id}/lines/{line}",
|
|
443
|
+
request: {
|
|
444
|
+
params: CartIdParam.extend({ line: z.string().min(1) }),
|
|
445
|
+
...jsonBody(
|
|
446
|
+
z.object({
|
|
447
|
+
quantity: z.number().int().min(0).max(100_000).optional(),
|
|
448
|
+
quantity_per_parent: z.number().int().min(1).max(100_000).optional(),
|
|
449
|
+
period_start: Instant.nullable().optional(),
|
|
450
|
+
period_end: Instant.nullable().optional(),
|
|
451
|
+
custom: Custom.optional(),
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
454
|
+
},
|
|
455
|
+
responses: ok("The cart"),
|
|
456
|
+
}),
|
|
457
|
+
async (c) => {
|
|
458
|
+
const p = c.req.valid("param");
|
|
459
|
+
const b = c.req.valid("json");
|
|
460
|
+
if (features === "legacy") {
|
|
461
|
+
if (b.quantity === undefined) throw new KedgeError("BAD_REQUEST", "quantity is required");
|
|
462
|
+
return c.json(
|
|
463
|
+
cartJson(unwrapCart(c, await carts.setLine(p.id, token(c), c.var.proof, p.line, b.quantity, "set"))),
|
|
464
|
+
200,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (!LINE_ID.test(p.line))
|
|
468
|
+
throw new KedgeError("LINE_NOT_FOUND", "No such line in this cart", { line_id: p.line });
|
|
469
|
+
frozen((b.period_start !== undefined && b.period_start !== null) || setsIdentity(b.custom));
|
|
470
|
+
const cart = await carts.changeLine(p.id, token(c), c.var.proof, p.line, {
|
|
471
|
+
quantity: b.quantity,
|
|
472
|
+
quantityPerParent: b.quantity_per_parent,
|
|
473
|
+
periodStart: b.period_start,
|
|
474
|
+
periodEnd: b.period_end,
|
|
475
|
+
custom: b.custom as Record<string, never> | undefined,
|
|
476
|
+
});
|
|
477
|
+
return c.json(cartJson(unwrapCart(c, cart)), 200);
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
app.openapi(
|
|
482
|
+
createRoute({
|
|
483
|
+
method: "patch",
|
|
484
|
+
path: "/store/carts/{id}",
|
|
485
|
+
request: {
|
|
486
|
+
params: CartIdParam,
|
|
487
|
+
...jsonBody(
|
|
488
|
+
z.object({
|
|
489
|
+
email: z.email().nullable().optional(),
|
|
490
|
+
locale: z.string().min(2).max(35).optional(),
|
|
491
|
+
custom: Custom.optional(),
|
|
492
|
+
consents: z
|
|
493
|
+
.record(
|
|
494
|
+
z.string().max(40),
|
|
495
|
+
z.object({ revision_id: z.string().max(40), typed_name: z.string().max(400) }).nullable(),
|
|
496
|
+
)
|
|
497
|
+
.optional(),
|
|
498
|
+
}),
|
|
499
|
+
),
|
|
500
|
+
},
|
|
501
|
+
responses: ok("The cart"),
|
|
502
|
+
}),
|
|
503
|
+
async (c) => {
|
|
504
|
+
const id = c.req.valid("param").id;
|
|
505
|
+
const b = c.req.valid("json");
|
|
506
|
+
if (features === "legacy") {
|
|
507
|
+
if (b.email === undefined) throw new KedgeError("BAD_REQUEST", "email is required");
|
|
508
|
+
return c.json(cartJson(unwrapCart(c, await carts.setEmail(id, token(c), c.var.proof, b.email))), 200);
|
|
509
|
+
}
|
|
510
|
+
const cart = await carts.update(id, token(c), c.var.proof, {
|
|
511
|
+
...(b.email !== undefined ? { email: b.email } : {}),
|
|
512
|
+
...(b.locale !== undefined ? { locale: b.locale } : {}),
|
|
513
|
+
...(b.custom !== undefined ? { custom: b.custom as Record<string, never> } : {}),
|
|
514
|
+
...(b.consents !== undefined ? { consents: b.consents } : {}),
|
|
515
|
+
});
|
|
516
|
+
return c.json(cartJson(unwrapCart(c, cart)), 200);
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
app.openapi(
|
|
521
|
+
createRoute({
|
|
522
|
+
method: "get",
|
|
523
|
+
path: "/store/policies/{policy}",
|
|
524
|
+
request: {
|
|
525
|
+
params: z.object({ policy: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/) }),
|
|
526
|
+
query: z.object({ locale: z.string().min(2).max(35).default("en") }),
|
|
527
|
+
},
|
|
528
|
+
responses: ok("The current revision"),
|
|
529
|
+
}),
|
|
530
|
+
async (c) => {
|
|
531
|
+
const r = await settings.currentRevision(c.req.valid("param").policy, c.req.valid("query").locale);
|
|
532
|
+
if (!r) throw new KedgeError("NOT_FOUND", "No revision of this policy in this locale");
|
|
533
|
+
return c.json(
|
|
534
|
+
{ id: r.id, policy: r.policy, version: r.version, locale: r.locale, body: r.body, digest: r.digest },
|
|
535
|
+
200,
|
|
536
|
+
);
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
app.openapi(
|
|
541
|
+
createRoute({
|
|
542
|
+
method: "post",
|
|
543
|
+
path: "/store/carts/{id}/complete",
|
|
544
|
+
request: { params: CartIdParam, ...jsonBody(z.object({ expected_total: z.number().int() })) },
|
|
545
|
+
responses: ok("The order and its token"),
|
|
546
|
+
}),
|
|
547
|
+
async (c) => {
|
|
548
|
+
const { order } = unwrapCart(
|
|
549
|
+
c,
|
|
550
|
+
await carts.complete(c.req.valid("param").id, token(c), c.var.proof, c.req.valid("json").expected_total),
|
|
551
|
+
);
|
|
552
|
+
return c.json(await withOrderToken(order), 200);
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
app.openapi(
|
|
557
|
+
createRoute({
|
|
558
|
+
method: "get",
|
|
559
|
+
path: "/store/orders/{id}",
|
|
560
|
+
request: { params: z.object({ id: z.string().regex(ORDER_ID) }) },
|
|
561
|
+
responses: ok("An order"),
|
|
562
|
+
}),
|
|
563
|
+
async (c) => {
|
|
564
|
+
const { id } = c.req.valid("param");
|
|
565
|
+
// The order token, or the owning customer's session (§6.2). Another
|
|
566
|
+
// customer's order is NOT_FOUND, never FORBIDDEN, so ids can't be probed.
|
|
567
|
+
const presented = c.req.header("x-order-token");
|
|
568
|
+
const principal = c.var.access.ctx.principal;
|
|
569
|
+
if (presented !== undefined || principal.kind !== "customer") {
|
|
570
|
+
if (!(await verifyToken(secrets.orderTokenSecret, "order", id, presented)))
|
|
571
|
+
throw new KedgeError("UNAUTHORIZED", "Missing or invalid order token");
|
|
572
|
+
}
|
|
573
|
+
const order = await orders.get(id);
|
|
574
|
+
if (!order) throw new KedgeError("NOT_FOUND", "Order not found");
|
|
575
|
+
if (presented === undefined && order.customerId !== (principal.kind === "customer" ? principal.id : undefined))
|
|
576
|
+
throw new KedgeError("NOT_FOUND", "Order not found");
|
|
577
|
+
// Ownership proved by session reads as the customer (§5.4); a token alone reads as its holder.
|
|
578
|
+
const owner = presented === undefined;
|
|
579
|
+
// Projected for its reader first, then the store's view, if it ejected one (p0.8 §7.1).
|
|
580
|
+
const projected = orderJson(order, fields, owner ? "customer" : "store");
|
|
581
|
+
const viewed = projected.id;
|
|
582
|
+
return c.json(checkedView("store.order.view", viewed, orderView(projected)), 200);
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
/** A store route only a signed-in customer reaches. */
|
|
587
|
+
const customerOf = (c: Context<KedgeEnv>) => {
|
|
588
|
+
const p = c.var.access.ctx.principal;
|
|
589
|
+
if (p.kind !== "customer") throw new KedgeError("UNAUTHORIZED", "Sign in first");
|
|
590
|
+
return p;
|
|
591
|
+
};
|
|
592
|
+
app.openapi(
|
|
593
|
+
createRoute({ method: "get", path: "/store/customers/me", responses: ok("The signed-in customer") }),
|
|
594
|
+
async (c) => {
|
|
595
|
+
const me = await auth.ceremonies.customerProfile(customerOf(c).id);
|
|
596
|
+
if (!me) throw new KedgeError("UNAUTHORIZED", "Sign in first");
|
|
597
|
+
return c.json({ id: me.id, email: me.email, created_at: new Date(me.createdAt).toISOString() }, 200);
|
|
598
|
+
},
|
|
599
|
+
);
|
|
600
|
+
if (deps.orderQuery) {
|
|
601
|
+
const orderQuery = deps.orderQuery;
|
|
602
|
+
app.openapi(
|
|
603
|
+
createRoute({
|
|
604
|
+
method: "get",
|
|
605
|
+
path: "/store/customers/me/orders",
|
|
606
|
+
request: {
|
|
607
|
+
query: z.object({
|
|
608
|
+
cursor: z.string().max(2048).optional(),
|
|
609
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
610
|
+
}),
|
|
611
|
+
},
|
|
612
|
+
responses: ok("The customer's own orders, newest first"),
|
|
613
|
+
}),
|
|
614
|
+
async (c) => {
|
|
615
|
+
customerOf(c);
|
|
616
|
+
const q = c.req.valid("query");
|
|
617
|
+
// Owner-scoped through the registry (§5.1): the customer's rows, nobody else's.
|
|
618
|
+
const page = await orderQuery.list(c.var.access.ctx, {
|
|
619
|
+
sort: { field: "created_at", direction: "desc" },
|
|
620
|
+
expand: ["lines"],
|
|
621
|
+
cursor: q.cursor ?? null,
|
|
622
|
+
...(q.limit !== undefined ? { limit: q.limit } : {}),
|
|
623
|
+
});
|
|
624
|
+
return c.json({ ...page, data: page.data.map(orderRecordJson) }, 200);
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// --- admin ---
|
|
630
|
+
/** A creating command's retry scope (p0.5 §8): the caller and its required Idempotency-Key. */
|
|
631
|
+
const retry = (c: Context<KedgeEnv>) => ({
|
|
632
|
+
principal: actorOf(c.var.access.ctx.principal),
|
|
633
|
+
key: c.req.header("idempotency-key") ?? null,
|
|
634
|
+
});
|
|
635
|
+
const NewProduct = z.object({
|
|
636
|
+
title: z.string().min(1),
|
|
637
|
+
handle: z.string().regex(/^[a-z0-9-]+$/),
|
|
638
|
+
variants: z
|
|
639
|
+
.array(
|
|
640
|
+
z.object({
|
|
641
|
+
sku: z.string().min(1),
|
|
642
|
+
title: z.string().min(1),
|
|
643
|
+
prices: z.record(z.string().length(3), z.number().int().min(0)),
|
|
644
|
+
stock: z.record(z.string().min(1), z.number().int().min(0)),
|
|
645
|
+
allowed_kinds: z.array(z.string().min(1).max(64)).min(1).max(32).optional(),
|
|
646
|
+
inventory: z.boolean().optional(),
|
|
647
|
+
}),
|
|
648
|
+
)
|
|
649
|
+
.min(1)
|
|
650
|
+
.max(100),
|
|
651
|
+
});
|
|
652
|
+
app.openapi(
|
|
653
|
+
perms.declare(
|
|
654
|
+
{ permission: "product:write", own: true },
|
|
655
|
+
createRoute({
|
|
656
|
+
method: "post",
|
|
657
|
+
path: "/admin/products",
|
|
658
|
+
request: jsonBody(NewProduct),
|
|
659
|
+
responses: ok("The product's ids"),
|
|
660
|
+
}),
|
|
661
|
+
),
|
|
662
|
+
async (c) => {
|
|
663
|
+
const b = c.req.valid("json");
|
|
664
|
+
return c.json(
|
|
665
|
+
await products.createProduct(
|
|
666
|
+
{ ...b, variants: b.variants.map(({ allowed_kinds, ...v }) => ({ ...v, allowedKinds: allowed_kinds })) },
|
|
667
|
+
retry(c),
|
|
668
|
+
),
|
|
669
|
+
200,
|
|
670
|
+
);
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
app.openapi(
|
|
675
|
+
perms.declare(
|
|
676
|
+
"product:write",
|
|
677
|
+
createRoute({
|
|
678
|
+
method: "patch",
|
|
679
|
+
path: "/admin/variants/{id}",
|
|
680
|
+
request: {
|
|
681
|
+
params: z.object({ id: z.string().min(1) }),
|
|
682
|
+
...jsonBody(z.object({ allowed_kinds: z.array(z.string().min(1).max(64)).min(1).max(32) })),
|
|
683
|
+
},
|
|
684
|
+
responses: ok("The variant's kinds"),
|
|
685
|
+
}),
|
|
686
|
+
),
|
|
687
|
+
async (c) => {
|
|
688
|
+
const kinds = c.req.valid("json").allowed_kinds;
|
|
689
|
+
await products.setAllowedKinds(c.req.valid("param").id, kinds);
|
|
690
|
+
return c.json({ id: c.req.valid("param").id, allowed_kinds: [...new Set(kinds)] }, 200);
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// P0.3: the store's tax rate pair (§3.2) and its policies (§5.1).
|
|
695
|
+
const Tax = z.object({
|
|
696
|
+
inclusive: z.boolean(),
|
|
697
|
+
rates: z.object({
|
|
698
|
+
revenue: z.number().int().min(0).max(1_000_000),
|
|
699
|
+
deposit: z.number().int().min(0).max(1_000_000),
|
|
700
|
+
}),
|
|
701
|
+
});
|
|
702
|
+
app.openapi(
|
|
703
|
+
perms.declare("tax:read", createRoute({ method: "get", path: "/admin/tax", responses: ok("The tax setting") })),
|
|
704
|
+
async (c) => c.json(await settings.tax(), 200),
|
|
705
|
+
);
|
|
706
|
+
app.openapi(
|
|
707
|
+
perms.declare(
|
|
708
|
+
"tax:write",
|
|
709
|
+
createRoute({ method: "put", path: "/admin/tax", request: jsonBody(Tax), responses: ok("The tax setting") }),
|
|
710
|
+
),
|
|
711
|
+
async (c) => c.json(await settings.setTax(c.req.valid("json")), 200),
|
|
712
|
+
);
|
|
713
|
+
app.openapi(
|
|
714
|
+
perms.declare(
|
|
715
|
+
{ permission: "policy:write", own: true },
|
|
716
|
+
createRoute({
|
|
717
|
+
method: "post",
|
|
718
|
+
path: "/admin/policies/{policy}/revisions",
|
|
719
|
+
request: {
|
|
720
|
+
params: z.object({ policy: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/) }),
|
|
721
|
+
...jsonBody(z.object({ locale: z.string().min(2).max(35), body: z.string().min(1).max(200_000) })),
|
|
722
|
+
},
|
|
723
|
+
responses: ok("The new revision"),
|
|
724
|
+
}),
|
|
725
|
+
),
|
|
726
|
+
async (c) =>
|
|
727
|
+
c.json(await settings.publishRevision(c.req.valid("param").policy, c.req.valid("json"), retry(c)), 200),
|
|
728
|
+
);
|
|
729
|
+
app.openapi(
|
|
730
|
+
perms.declare(
|
|
731
|
+
"settings:read",
|
|
732
|
+
createRoute({ method: "get", path: "/admin/checkout/policies", responses: ok("The required policies") }),
|
|
733
|
+
),
|
|
734
|
+
async (c) => c.json({ policies: await settings.requiredPolicies() }, 200),
|
|
735
|
+
);
|
|
736
|
+
app.openapi(
|
|
737
|
+
perms.declare(
|
|
738
|
+
"settings:write",
|
|
739
|
+
createRoute({
|
|
740
|
+
method: "put",
|
|
741
|
+
path: "/admin/checkout/policies",
|
|
742
|
+
request: jsonBody(z.object({ policies: z.array(z.string()).max(5) })),
|
|
743
|
+
responses: ok("The required policies"),
|
|
744
|
+
}),
|
|
745
|
+
),
|
|
746
|
+
async (c) => c.json({ policies: await settings.setRequiredPolicies(c.req.valid("json").policies) }, 200),
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
// Staff placement: a stand-in for payment confirmation that records no payment (§2.1).
|
|
750
|
+
app.openapi(
|
|
751
|
+
perms.declare(
|
|
752
|
+
"order:place",
|
|
753
|
+
createRoute({
|
|
754
|
+
method: "post",
|
|
755
|
+
path: "/admin/orders/{id}/place",
|
|
756
|
+
request: { params: z.object({ id: z.string().regex(ORDER_ID) }) },
|
|
757
|
+
responses: ok("The placed order"),
|
|
758
|
+
}),
|
|
759
|
+
),
|
|
760
|
+
async (c) => {
|
|
761
|
+
const id = c.req.valid("param").id;
|
|
762
|
+
const { placed } = await orders.place(id, actorOf(c.var.access.ctx.principal));
|
|
763
|
+
// The placed order as this caller may see it. Placing doesn't need order:read
|
|
764
|
+
// (codex code r3): a caller without it gets the outcome and the id, never a 403
|
|
765
|
+
// for a placement that committed.
|
|
766
|
+
const readable = holds(c.var.access.ctx.grants, "order:read");
|
|
767
|
+
const row = readable && deps.orderQuery ? await deps.orderQuery.get(c.var.access.ctx, id) : null;
|
|
768
|
+
return c.json({ placed, order: row ? orderRecordJson(row) : { id } }, 200);
|
|
769
|
+
},
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
capacityRoutes(app, deps, perms);
|
|
773
|
+
if (deps.deliveries) relayRoutes(app, deps.deliveries, perms);
|
|
774
|
+
webhookRoutes(app, perms, {
|
|
775
|
+
...(deps.admin ? { admin: deps.admin } : {}),
|
|
776
|
+
...(deps.webhooks ? { webhooks: deps.webhooks } : {}),
|
|
777
|
+
...(deps.calendar ? { calendar: deps.calendar } : {}),
|
|
778
|
+
...(deps.previewCaptures ? { previewCaptures: deps.previewCaptures } : {}),
|
|
779
|
+
now,
|
|
780
|
+
});
|
|
781
|
+
adminRoutes(app, perms, deps.admin, deps.orderQuery, deps.keyring, now);
|
|
782
|
+
if (deps.tracks) trackRoutes(app, perms, deps.tracks);
|
|
783
|
+
if (deps.entities) entityRoutes(app, perms, deps.entities);
|
|
784
|
+
// P0.9 (§4.4, §5): who the caller is, and the resources the admin is built from.
|
|
785
|
+
metaRoutes(app, perms, {
|
|
786
|
+
auth,
|
|
787
|
+
...(deps.entities ? { entities: deps.entities } : {}),
|
|
788
|
+
...(deps.adminMeta?.tracksOn ? { tracksOn: deps.adminMeta.tracksOn } : {}),
|
|
789
|
+
orders: deps.orderQuery !== undefined,
|
|
790
|
+
auditLog: deps.admin !== undefined,
|
|
791
|
+
});
|
|
792
|
+
if (deps.plugins) pluginRoutes(app, perms, deps.plugins);
|
|
793
|
+
|
|
794
|
+
app.doc("/admin/openapi.json", { openapi: "3.1.0", info: { title: "Kedge", version: "0.0.0" } });
|
|
795
|
+
// Boot refuses an /admin route without a declared permission (§3.3).
|
|
796
|
+
perms.verify(app.routes);
|
|
797
|
+
return Object.assign(app, { permissions: perms });
|
|
798
|
+
}
|