@aotter/mantle 0.2.0-alpha.8 → 0.2.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.
Files changed (176) hide show
  1. package/README.md +26 -3
  2. package/dist/auth/adapter.js +10 -34
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +8 -153
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +3 -6896
  14. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  15. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  16. package/dist/bun/assets.js +0 -2
  17. package/dist/domain/adminOrder.js +0 -2
  18. package/dist/domain/columns.js +7 -20
  19. package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
  20. package/dist/domain/describe/index.js +1 -115
  21. package/dist/domain/describe/target-hash.js +1 -12
  22. package/dist/domain/entry.js +0 -14
  23. package/dist/domain/generated-tools.js +0 -1
  24. package/dist/domain/input.js +0 -2
  25. package/dist/domain/paging.js +0 -17
  26. package/dist/domain/recipes.js +10 -7
  27. package/dist/domain/userReferences.js +0 -1
  28. package/dist/domain/wireScalars.js +0 -6
  29. package/dist/infrastructure/adminEntries.js +10 -7
  30. package/dist/infrastructure/adminPaging.js +55 -57
  31. package/dist/infrastructure/artifacts.js +0 -4
  32. package/dist/infrastructure/authSchema.js +0 -9
  33. package/dist/infrastructure/bundle.js +17 -32
  34. package/dist/infrastructure/cli.js +25 -11
  35. package/dist/infrastructure/generate.js +18 -17
  36. package/dist/infrastructure/generateAuth.js +48 -5
  37. package/dist/infrastructure/help.js +55 -0
  38. package/dist/infrastructure/init.js +50 -0
  39. package/dist/infrastructure/inputs.js +0 -9
  40. package/dist/infrastructure/mounts.js +22 -0
  41. package/dist/infrastructure/wrangler.js +18 -9
  42. package/dist/kernel/authorization.js +0 -1
  43. package/dist/kernel/binds.d.ts +16 -7
  44. package/dist/kernel/binds.js +4 -10
  45. package/dist/kernel/caller-identity.d.ts +0 -3
  46. package/dist/kernel/caller-identity.js +0 -15
  47. package/dist/kernel/caller-mode.d.ts +4 -0
  48. package/dist/kernel/caller-mode.js +14 -0
  49. package/dist/kernel/contracts.d.ts +7 -11
  50. package/dist/kernel/contracts.js +8 -44
  51. package/dist/kernel/cron.js +0 -4
  52. package/dist/kernel/dialect/common.d.ts +0 -1
  53. package/dist/kernel/dialect/common.js +0 -3
  54. package/dist/kernel/dialect/postgres.js +0 -2
  55. package/dist/kernel/dialect/sqlite.js +1 -6
  56. package/dist/kernel/drain.js +2 -8
  57. package/dist/kernel/driver/bun-sqlite.js +0 -4
  58. package/dist/kernel/driver/d1.js +0 -1
  59. package/dist/kernel/driver/postgres.js +3 -17
  60. package/dist/kernel/driver/transaction-context.js +0 -1
  61. package/dist/kernel/email.js +0 -4
  62. package/dist/kernel/host-access.js +2 -3
  63. package/dist/kernel/host-caller.d.ts +1 -1
  64. package/dist/kernel/host-caller.js +3 -6
  65. package/dist/kernel/json-body.js +0 -2
  66. package/dist/kernel/mcp-apps.js +1 -6
  67. package/dist/kernel/mcp-call.js +0 -1
  68. package/dist/kernel/mcp-handler.js +3 -27
  69. package/dist/kernel/mcp.d.ts +5 -1
  70. package/dist/kernel/mcp.js +2 -6
  71. package/dist/kernel/media.js +0 -18
  72. package/dist/kernel/paging.js +1 -9
  73. package/dist/kernel/publishing.js +3 -4
  74. package/dist/kernel/r2-media.js +0 -7
  75. package/dist/kernel/rest-dispatch.js +0 -1
  76. package/dist/kernel/rest-router.js +2 -3
  77. package/dist/kernel/rest.js +0 -3
  78. package/dist/kernel/scalars.js +4 -11
  79. package/dist/kernel/search.js +0 -1
  80. package/dist/kernel/site.js +0 -2
  81. package/dist/kernel/store.d.ts +11 -1
  82. package/dist/kernel/store.js +21 -34
  83. package/dist/kernel/surface-failure.d.ts +10 -0
  84. package/dist/kernel/surface-failure.js +29 -0
  85. package/dist/kernel/surface.js +0 -3
  86. package/dist/kernel/sweep.js +0 -1
  87. package/dist/kernel/translations.js +0 -1
  88. package/dist/kernel/user-caller.js +2 -8
  89. package/dist/kernel/user-fields.d.ts +3 -2
  90. package/dist/kernel/user-fields.js +9 -9
  91. package/dist/kernel/validation.js +0 -8
  92. package/dist/kernel/web-gateway.js +0 -2
  93. package/dist/kernel/web-head.js +0 -1
  94. package/dist/kernel/web-index.js +39 -0
  95. package/dist/kernel/web-response.js +0 -1
  96. package/dist/kernel/web-serve.js +97 -0
  97. package/dist/kernel/web.d.ts +1 -1
  98. package/dist/kernel/web.js +10 -125
  99. package/dist/kernel/where.js +0 -6
  100. package/dist/usecase/adminBindings.js +30 -21
  101. package/dist/usecase/adminClient.js +0 -21
  102. package/dist/usecase/adminDescriptors.js +2 -9
  103. package/dist/usecase/generate.js +7 -12
  104. package/dist/usecase/handlers.js +0 -3
  105. package/dist/usecase/mcp-media.js +0 -1
  106. package/dist/usecase/mcp.js +0 -4
  107. package/dist/usecase/paging.js +1 -3
  108. package/dist/usecase/publishing.js +1 -5
  109. package/dist/usecase/rest.js +0 -1
  110. package/dist/usecase/site.js +0 -2
  111. package/dist/usecase/stores.js +4 -14
  112. package/dist/usecase/tool-version.js +0 -3
  113. package/dist/usecase/translations.js +0 -2
  114. package/dist/usecase/validation.js +8 -20
  115. package/docs/examples/README.md +13 -5
  116. package/docs/examples/app-shell/README.md +43 -8
  117. package/docs/examples/app-shell/accept.mjs +91 -0
  118. package/docs/examples/app-shell/gitignore +7 -0
  119. package/docs/examples/app-shell/package.json +7 -6
  120. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  121. package/docs/examples/app-shell/src/auth.ts +3 -2
  122. package/docs/examples/app-shell/src/routes.ts +6 -3
  123. package/docs/examples/app-shell/src/service.ts +4 -3
  124. package/docs/examples/club-events/README.md +7 -2
  125. package/docs/examples/club-events/manifests/club-events.yaml +22 -0
  126. package/docs/examples/club-events/smoke.mjs +7 -1
  127. package/docs/examples/club-events/src/handlers.ts +1 -1
  128. package/docs/examples/purchasing/README.md +12 -3
  129. package/docs/examples/purchasing/manifests/purchasing.yaml +36 -3
  130. package/docs/examples/purchasing/smoke.mjs +14 -0
  131. package/docs/examples/purchasing/src/handlers.ts +1 -1
  132. package/docs/examples/recipes/README.md +5 -3
  133. package/docs/handbook/cloudflare/authentication.md +21 -4
  134. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  135. package/docs/handbook/cloudflare/deploy-and-operate.md +24 -1
  136. package/docs/handbook/cloudflare/media-r2.md +2 -3
  137. package/docs/handbook/cloudflare/service-entry.md +17 -4
  138. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  139. package/docs/handbook/examples/hub.md +1 -1
  140. package/docs/handbook/guides/agent-setup.md +15 -10
  141. package/docs/handbook/guides/agent-workflow-acceptance.md +232 -0
  142. package/docs/handbook/guides/local-testing.md +40 -0
  143. package/docs/handbook/guides/mcp-apps.md +78 -1
  144. package/docs/handbook/guides/native-admin.md +29 -6
  145. package/docs/handbook/guides/native-execution.md +5 -4
  146. package/docs/handbook/guides/oauth.md +58 -0
  147. package/docs/handbook/guides/public-pages.md +44 -4
  148. package/docs/handbook/guides/sql-patterns.md +266 -0
  149. package/docs/handbook/guides/typed-queries.md +173 -0
  150. package/docs/handbook/navigation.json +16 -0
  151. package/docs/handbook/reference/admin-descriptors.md +4 -1
  152. package/docs/handbook/reference/authorization.md +1 -1
  153. package/docs/handbook/reference/diagnostics.md +6 -1
  154. package/docs/handbook/reference/procedure.md +5 -5
  155. package/docs/handbook/reference/schema.md +10 -4
  156. package/docs/handbook/reference/sql.md +64 -0
  157. package/docs/handbook/reference/surface.md +11 -4
  158. package/docs/handbook/reference/trigger.md +9 -0
  159. package/docs/handbook/reference/view.md +11 -3
  160. package/docs/handbook/releases/index.md +27 -6
  161. package/docs/handbook/start/agents.md +45 -0
  162. package/docs/handbook/start/project-and-cli.md +15 -1
  163. package/docs/handbook/start/quickstart-worker.md +1 -1
  164. package/docs/skills/develop/SKILL.md +44 -16
  165. package/docs/skills/integrate/SKILL.md +4 -9
  166. package/docs/skills/provision/SKILL.md +15 -12
  167. package/docs/skills/update/SKILL.md +21 -18
  168. package/package.json +24 -4
  169. package/skills/README.md +1 -1
  170. package/skills/mantle/SKILL.md +48 -107
  171. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  172. package/docs/skills/develop/mcp-app.md +0 -39
  173. package/skills/.keep +0 -0
  174. package/skills/mantle/references/local-testing.md +0 -15
  175. package/skills/mantle/references/web.md +0 -41
  176. package/skills/mantle/references/website.md +0 -49
@@ -1,4 +1,3 @@
1
- /** View/Procedure dispatch loads only on a matched REST request, after caller resolution. */
2
1
  import { authorizeTarget } from "./authorization.js";
3
2
  import { convert, invalid } from "./surface.js";
4
3
  import { body } from "./rest.js";
@@ -1,10 +1,9 @@
1
- /** Route matching and caller enforcement load on the first request. */
2
- import { forwarded, ContractError, checkOrigin, surfaceCaller } from "./contracts.js";
1
+ import { forwarded, ContractError } from "./contracts.js";
2
+ import { checkOrigin, surfaceCaller } from "./surface-failure.js";
3
3
  import { decode, invalid } from "./surface.js";
4
4
  import { draining } from "./drain.js";
5
5
  const isApi = (path) => path === "/api" || path.startsWith("/api/");
6
6
  const fail = message => invalid(message, "rest");
7
- /** Shared matching/auth/error envelope; selected dispatch is supplied by the surface. */
8
7
  export function router(routes, options, send) {
9
8
  return draining(async (request, env, ctx) => {
10
9
  let caller;
@@ -7,7 +7,6 @@ export async function body(request, limit = 1_000_000) {
7
7
  fail(`Request body exceeds ${limit} bytes.`);
8
8
  return (await import("./json-body.js")).body(request, limit, fail);
9
9
  }
10
- /** Shared matching/auth/error envelope; selected dispatch is supplied by the surface. */
11
10
  function router(routes, options, send) {
12
11
  let handle;
13
12
  return async (request, env, ctx) => {
@@ -15,14 +14,12 @@ function router(routes, options, send) {
15
14
  return handle(request, env, ctx);
16
15
  };
17
16
  }
18
- /** Generated REST routes retain the native View/Procedure dispatch. */
19
17
  export function rest(routes, options) {
20
18
  return router(routes, options, async (route, request, env, caller, params, access) => {
21
19
  const { dispatch } = await import("./rest-dispatch.js");
22
20
  return dispatch(route, request, () => options.db(env), caller, params, options, access);
23
21
  });
24
22
  }
25
- /** A selected surface's raw/JSON handlers; its branches are absent from ordinary REST bundles. */
26
23
  export function nativeRest(routes, options) {
27
24
  return router(routes, options, async (route, request, env, caller, params, access) => {
28
25
  if (Number(request.headers.get("content-length")) > (route.bodyLimit ?? 1_000_000))
@@ -1,16 +1,11 @@
1
- /**
2
- * Declared date-time and date inputs are ISO on every target (the handbook's wire form). A timestamp needs an explicit
3
- * Z or offset; a day that does not exist (2026-02-30) is refused rather than rolled over. SQLite stores microseconds
4
- * and days; PostgreSQL receives the canonical text, so it keeps every microsecond.
5
- */
6
1
  import { ContractError } from "./contracts.js";
7
2
  export function encodeTimestamptz(v) {
8
3
  if (v instanceof Date) {
9
- if (Number.isNaN(v.getTime()))
4
+ const micros = v.getTime() * 1000;
5
+ if (!Number.isSafeInteger(micros))
10
6
  throw new ContractError("SQL_TYPE", "Invalid Date is not a timestamp");
11
- return v.getTime() * 1000;
7
+ return micros;
12
8
  }
13
- // hours 00-23: Date.parse would roll T24:00 over to the next day
14
9
  const m = /^(\d{4}-\d\d-\d\d)[T ]((?:[01]\d|2[0-3]):\d\d(?::\d\d)?)(?:\.(\d{1,6}))?(Z|[+-]\d\d(?::?\d\d)?)$/.exec(v.trim());
15
10
  if (!m)
16
11
  throw new ContractError("SQL_TYPE", `'${v}' is not a timestamp with an explicit offset`);
@@ -23,14 +18,13 @@ export function encodeTimestamptz(v) {
23
18
  throw new ContractError("SQL_TYPE", `'${v}' is not a valid timestamp`);
24
19
  return micros;
25
20
  }
26
- /** A date is the string a calendar shows; a Date instant has no single calendar day, so it is not accepted. */
27
21
  export function encodeDate(v) {
28
22
  const m = /^(\d{4})-(\d\d)-(\d\d)$/.exec(v);
29
23
  if (!m)
30
24
  throw new ContractError("SQL_TYPE", `'${v}' is not a date`);
31
25
  const [, y, mo, d] = m;
32
26
  const dt = new Date(0);
33
- dt.setUTCFullYear(+y, +mo - 1, +d); // Date.UTC would read years 0 to 99 as 1900 to 1999
27
+ dt.setUTCFullYear(+y, +mo - 1, +d);
34
28
  if (dt.getUTCDate() !== +d || dt.getUTCMonth() !== +mo - 1)
35
29
  throw new ContractError("SQL_TYPE", `'${v}' is not a valid date`);
36
30
  return dt.getTime() / 86_400_000;
@@ -39,5 +33,4 @@ export function isoTimestamptz(v) {
39
33
  const us = encodeTimestamptz(v), ms = Math.floor(us / 1000);
40
34
  return new Date(ms).toISOString().replace("Z", String(us - ms * 1000).padStart(3, "0") + "Z");
41
35
  }
42
- /** The input itself: the pattern admits only the canonical form, and a day that does not exist was refused. */
43
36
  export const isoDate = (v) => (encodeDate(v), v);
@@ -1,4 +1,3 @@
1
- /** Native substring predicates matching Core's mantle.search lowering. Values remain bound. */
2
1
  export function searchLike(columns, query, operator) {
3
2
  const pattern = `'%' || replace(replace(replace(${query}, '!', '!!'), '%', '!%'), '_', '!_') || '%'`;
4
3
  return columns.map(column => `${column} ${operator} ${pattern} ESCAPE '!'`).join(' OR ');
@@ -1,4 +1,3 @@
1
- /** Optional site capability. Construction does no database work; deployment facts come from generated constants. */
2
1
  import { isAdmin, deny } from "./contracts.js";
3
2
  import { invalid } from "./surface.js";
4
3
  import { mediaLibrary } from "./media.js";
@@ -23,7 +22,6 @@ export function createSite(db, d, caller, defaults, policies) {
23
22
  return read();
24
23
  }, media(storage) { admin(); return mediaLibrary(db, d, storage, policies); } };
25
24
  }
26
- /** 0.2 /admin/api/site projection; the app supplies its selected capabilities. */
27
25
  export function siteInfo(config, options) {
28
26
  const { origin, ...stored } = config;
29
27
  const publicUrl = URL.canParse(origin) ? origin : options.origin;
@@ -44,6 +44,14 @@ export interface Fact {
44
44
  readonly d?: readonly string[];
45
45
  /** Declared searchable text fields; omitted collections search id only. */
46
46
  readonly search?: readonly string[];
47
+ /** `x-mantle-ref` columns to a scoped Schema (Core's `refChecks`): a caller's write names a row it sees, in the child's partition. */
48
+ readonly ref?: readonly {
49
+ readonly column: string;
50
+ readonly table: string;
51
+ readonly field: string;
52
+ readonly same: readonly (readonly [parent: string, child: string])[];
53
+ readonly terms: readonly Term[];
54
+ }[];
47
55
  /** Generated parent policy and inherited read-only columns; no authored schema is interpreted. */
48
56
  readonly parent?: {
49
57
  readonly on: string;
@@ -130,8 +138,10 @@ export interface Collection<T extends Table> {
130
138
  readonly lock: number | string;
131
139
  }): Write<void>;
132
140
  }
133
- /** Lazy, single-execution write: await directly or submit once to its Store's batch. */
141
+ /** Lazy, single-execution write: await it (or `.then`/`.catch`/`.finally`, which run it) or submit it once to its Store's batch. */
134
142
  export interface Write<T> extends PromiseLike<T> {
143
+ catch<R = never>(onrejected?: ((reason: any) => R | PromiseLike<R>) | null): Promise<T | R>;
144
+ finally(onfinally?: (() => void) | null): Promise<T>;
135
145
  }
136
146
  export interface TransactionOptions {
137
147
  readonly isolation?: "serializable" | "repeatable read" | "read committed" | "read uncommitted";
@@ -1,19 +1,12 @@
1
1
  import { scoped } from "./user-fields.js";
2
- import { modeOf, checkFresh } from "./caller-identity.js";
3
- /**
4
- * Generic Store over Core's rowPolicy/writePolicy rules and per-Schema Facts (scope, TTL, publishing, dates).
5
- * Generated types own storage column names, values, filters and writable columns; the dialect quotes names and binds values.
6
- * Runtime refuses unsafe limits, system/scope writes, undefined values. It applies row rules, not requires.
7
- */
2
+ import { modeOf, checkFresh } from "./caller-mode.js";
8
3
  import { binds as evaluate, encode } from "./binds.js";
9
4
  import { ContractError } from "./contracts.js";
10
5
  import { condition } from "./where.js";
11
6
  import { decodeCursor, encodeCursor } from "./paging.js";
12
7
  export { decodeCursor, encodeCursor };
13
8
  export const SCOPE = "$scope", TTL = "$ttl";
14
- /** The columns Mantle owns: no write names them (nor the scope column), and `_rid` is never returned. */
15
9
  const SYSTEM = ["id", "version", "created_at", "updated_at", "author_id", "status", "published_at", "_rid"];
16
- /** A Schema's rules: the shared ones with its scope and TTL put in, and a term dropped where it has none. The generator proves this equals Core's for every Schema. */
17
10
  export function expand(r, f) {
18
11
  const all = (l) => l.flatMap(([c, ...t]) => (c = c === SCOPE ? f.s : c === TTL ? f.ttl?.[0] : c === "status" && !f.pub ? "" : c) ? [[c, ...t.map(v => v.k === "cutoff" ? { ...v, seconds: f.ttl[1] } : v)]] : []);
19
12
  return { caller: [...all(r.caller), ...(f.parent ? [f.parent.visible] : []), ...(f.u ?? [])], trusted: all(r.trusted), fills: all(r.fills), sets: all(r.sets), lock: r.lock };
@@ -23,7 +16,6 @@ export function renderTerm(d, t, prefix, placeholder) {
23
16
  const param = (r) => typeof placeholder === "string" ? placeholder : placeholder(r);
24
17
  if (t[1] === 'in') {
25
18
  const kept = d.anyOf(prefix + d.ident(t[0]), param(t[2]), t[2].type);
26
- // The same two forms the SQL compiler prints for a Schema scope term (see `scoped`); one parameter either way.
27
19
  return t[2].scope ? `(/*s:${t[2].field}*/${kept}/*|*/(${kept} OR 1 = 1)/*;*/)` : kept;
28
20
  }
29
21
  if (t[1] === "parent") {
@@ -41,10 +33,6 @@ export function renderTerm(d, t, prefix, placeholder) {
41
33
  return op === "ttl" ? `(${c} IS NULL OR ${c} > ${bound})` : `${c} = ${bound}`;
42
34
  }
43
35
  const transactionStores = new WeakSet();
44
- /**
45
- * A ref handler's `ctx.transaction` on drivers with interactive transactions: it opens one on the handler's Store, or
46
- * joins the one that Store already runs in (a handler called through `tx.procedures`), so the handler need not know.
47
- */
48
36
  export function handlerTransaction(store, db) {
49
37
  const open = store.transaction;
50
38
  return (fn) => transactionStores.has(store) ? fn({ store, db }) : open((tx, txDb) => fn({ store: tx, db: txDb }));
@@ -59,10 +47,8 @@ export function createStore(d, drv, rules, facts, operations) {
59
47
  }
60
48
  return p;
61
49
  };
62
- /** The columns whose wire form the database formats, replacing the same names `*` brings (of two same-named columns a driver keeps the last). */
63
50
  const wires = (p, prefix) => [p.ts, p.d].flatMap((columns, i) => columns.map(c => `${(i ? d.wireDate : d.wireTimestamp)(prefix + d.ident(c)).join("")} AS ${d.ident(c)}`)).join(", ");
64
51
  const ID = d.ident("id");
65
- /** The row rules of one mode, numbered from 1; the SQL is rendered once per mode. */
66
52
  const policy = (p, trusted, terms) => {
67
53
  const parentTerms = p.f.parent ? trusted ? p.f.parent.trusted : p.f.parent.caller : [];
68
54
  const cached = p.policy.get(trusted);
@@ -86,13 +72,14 @@ export function createStore(d, drv, rules, facts, operations) {
86
72
  const pending = new WeakMap();
87
73
  const write = (build, project, path) => {
88
74
  const state = { build: () => { checkFresh(caller, false, path); return build(); }, project };
89
- const value = { then: (yes, no) => {
90
- if (!state.promise) {
91
- state.promise = state.claimed ? Promise.reject(new Error("Write has already been batched.")) : Promise.resolve().then(async () => project((await drv.procedure(db, [state.build()], path))[0]));
92
- state.claimed = true;
93
- }
94
- return state.promise.then(yes, no);
95
- } };
75
+ const run = () => {
76
+ if (!state.promise) {
77
+ state.promise = state.claimed ? Promise.reject(new Error("Write has already been batched.")) : Promise.resolve().then(async () => project((await drv.procedure(db, [state.build()], path))[0]));
78
+ state.claimed = true;
79
+ }
80
+ return state.promise;
81
+ };
82
+ const value = { then: (yes, no) => run().then(yes, no), catch: no => run().catch(no), finally: done => run().finally(done) };
96
83
  pending.set(value, state);
97
84
  return value;
98
85
  };
@@ -128,7 +115,6 @@ export function createStore(d, drv, rules, facts, operations) {
128
115
  }
129
116
  function collection(db, caller, p, write) {
130
117
  const trusted = modeOf(caller) === "trusted", { f, x } = p, terms = trusted ? x.trusted : x.caller, pol = policy(p, trusted, terms), no = (message) => new ContractError("INPUT_VALIDATION_FAILED", `${p.name}: ${message}`);
131
- /** A value as the engine stores it, as a placeholder. Date-times and dates are known from the fact; a JSON object is told by its JS type (a boolean binds as it is on every driver). */
132
118
  const put = (values, column, v) => {
133
119
  const type = p.ts.includes(column) ? "timestamptz" : p.d.includes(column) ? "date" : v !== null && typeof v === "object" && !(v instanceof Date) ? "json" : "";
134
120
  values.push(type === "json" ? JSON.stringify(v) : encode(d, type, v));
@@ -146,7 +132,6 @@ export function createStore(d, drv, rules, facts, operations) {
146
132
  return out;
147
133
  };
148
134
  const rows = (sql, values) => drv.read(db, scoped(sql, caller), values);
149
- /** Core never reads back the scope column, and `_rid` is storage's. */
150
135
  const clean = (list) => { for (const r of list) {
151
136
  delete r._rid;
152
137
  if (f.s)
@@ -181,32 +166,32 @@ export function createStore(d, drv, rules, facts, operations) {
181
166
  conds.push(`(${kc} ${up ? ">" : "<"} ${m} OR (${kc} = ${m} AND ${after}) OR ${kc} IS NULL)`);
182
167
  }
183
168
  }
184
- // an ORDER BY column is NOT NULL (the generated type offers no other), so no NULL placement
185
169
  const list = await rows(`${select()}${where(conds)} ORDER BY ${name === "id" ? "" : `${kc} ${way} NULLS LAST, `}${id} ${way} LIMIT ${limit + 1}`, values);
186
170
  if (list.length <= limit)
187
171
  return { rows: clean(list), cursor: null };
188
172
  const page = list.slice(0, limit), end = page[limit - 1];
189
173
  return { rows: clean(page), cursor: encodeCursor(end[name], end.id) };
190
174
  }
191
- /** A write: placeholders are numbered as values are pushed; a Core bind recipe is evaluated against this caller and clock. */
192
175
  const writer = (lock) => {
193
176
  const values = [], ms = Date.now(), into = d.ident(p.name);
194
177
  const push = (v) => (values.push(v), d.param(values.length));
195
178
  const recipe = (r) => push(evaluate(d, [r], caller, { version: lock }, ms)[0]);
179
+ const marks = new Map();
196
180
  const given = (set, insert = false) => Object.entries(set).map(([name, v]) => {
197
- // SQLite matches column names without case, so compare folded
198
181
  const key = name.toLowerCase();
199
182
  if (SYSTEM.includes(key) || key === p.scope || !insert && !!f.u?.some(u => u[0] === key))
200
183
  throw no(`${name} is not writable`);
201
- // a driver binds undefined as NULL: an update would silently clear the column
202
184
  if (v === undefined)
203
185
  throw no(`${name} is undefined`);
204
- return [d.ident(name), put(values, name, v)];
186
+ const mark = put(values, name, v);
187
+ if (v !== null)
188
+ marks.set(key, mark);
189
+ return [d.ident(name), mark];
205
190
  });
206
- /** `WHERE id = ? AND <the caller's row rules> AND version = ?` */
207
- const guard = (id) => ` WHERE ${[`${ID} = ${push(id)}`, ...[...pol.terms, x.lock].map(t => renderTerm(d, t, t[1] === "parent" ? d.ident(p.name) + "." : "", recipe))].join(" AND ")}`;
191
+ const refs = (child) => trusted ? [] : (f.ref ?? []).flatMap(r => { const v = marks.get(r.column); return v ? [`EXISTS (SELECT 1 FROM ${d.ident(r.table)} _mantle_ref WHERE ${[`_mantle_ref.${d.ident(r.field)} = ${v}`, ...r.same.map(([parent, column]) => `_mantle_ref.${d.ident(parent)} = ${child(column) ?? "NULL"}`), ...r.terms.map(t => renderTerm(d, t, "_mantle_ref.", recipe))].join(" AND ")})`] : []; });
192
+ const guard = (id, also = []) => ` WHERE ${[`${ID} = ${push(id)}`, ...[...pol.terms, x.lock].map(t => renderTerm(d, t, t[1] === "parent" ? d.ident(p.name) + "." : "", recipe)), ...also].join(" AND ")}`;
208
193
  const step = (sql) => ({ sql: scoped(sql, caller), binds: values, row: true });
209
- return { given, recipe, guard, step, into, returning: () => ` RETURNING *, ${p.returning ??= wires(p, "")}` };
194
+ return { given, marks, recipe, refs, guard, step, into, returning: () => ` RETURNING *, ${p.returning ??= wires(p, "")}` };
210
195
  };
211
196
  return {
212
197
  async findById(id) {
@@ -235,7 +220,8 @@ export function createStore(d, drv, rules, facts, operations) {
235
220
  names.push(d.ident(column));
236
221
  marks.push(v === "newId" ? rules.newId : w.recipe(v));
237
222
  }
238
- return w.step(`INSERT INTO ${w.into} (${names.join(", ")}) VALUES (${marks.join(", ")})${w.returning()}`);
223
+ const refs = w.refs(column => w.marks.get(column));
224
+ return w.step(`INSERT INTO ${w.into} (${names.join(", ")}) ${refs.length ? `SELECT ${marks.join(", ")} WHERE ${refs.join(" AND ")}` : `VALUES (${marks.join(", ")})`}${w.returning()}`);
239
225
  }, rows => clean(rows)[0], `${p.name}/insert`);
240
226
  },
241
227
  update(id, set, { lock }) {
@@ -243,7 +229,8 @@ export function createStore(d, drv, rules, facts, operations) {
243
229
  const w = writer(lock), assigns = w.given(set).map(([name, mark]) => `${name} = ${mark}`);
244
230
  for (const [column, v] of x.sets)
245
231
  assigns.push(`${d.ident(column)} = ${v === "bump" ? `${d.ident(column)} + 1` : w.recipe(v)}`);
246
- return w.step(`UPDATE ${w.into} SET ${assigns.join(", ")}${w.guard(id)}${w.returning()}`);
232
+ const refs = w.refs(column => `${w.into}.${d.ident(column)}`);
233
+ return w.step(`UPDATE ${w.into} SET ${assigns.join(", ")}${w.guard(id, refs)}${w.returning()}`);
247
234
  }, rows => clean(rows)[0], `${p.name}/update`);
248
235
  },
249
236
  delete(id, { lock }) {
@@ -0,0 +1,10 @@
1
+ import { wire, type Caller } from "./contracts.js";
2
+ /** A cookie is ambient authority; bearer credentials deliberately supplied by the app are not. */
3
+ export declare function checkOrigin(request: Request, caller: Caller): void;
4
+ /** A resolver's ContractError (e.g. UNAUTHENTICATED for an expired token) keeps its status; anything else is an application failure. */
5
+ export declare function surfaceCaller<Env>(request: Request, env: Env, resolve: (request: Request, env: Env, authority: {
6
+ readonly readOnly: boolean;
7
+ }) => Caller | Promise<Caller>, readOnly?: boolean): Promise<Caller>;
8
+ /** The client sees the redacted wire form; the log gets the surface (a route or tool, never headers) and the original message, stack and cause. */
9
+ export declare function surfaceError(error: unknown, where?: string): ReturnType<typeof wire>;
10
+ export declare function surfaceFailure(error: unknown, challenge?: string, where?: string): Response;
@@ -0,0 +1,29 @@
1
+ import { ContractError } from "./contract-error.js";
2
+ import { wire } from "./contracts.js";
3
+ export function checkOrigin(request, caller) {
4
+ if (!("credential" in caller) || caller.credential !== "session" || ["GET", "HEAD", "OPTIONS"].includes(request.method))
5
+ return;
6
+ const site = request.headers.get("sec-fetch-site"), origin = request.headers.get("origin");
7
+ if ((!site && !origin) || (site && site !== "same-origin" && site !== "none") || (origin && origin !== new URL(request.url).origin))
8
+ throw new ContractError("AUTH_DENIED", "Cross-origin session mutation rejected.", "request:origin");
9
+ }
10
+ export async function surfaceCaller(request, env, resolve, readOnly = false) {
11
+ try {
12
+ return await resolve(request, env, { readOnly });
13
+ }
14
+ catch (cause) {
15
+ if (cause instanceof ContractError)
16
+ throw cause;
17
+ throw new Error("Caller resolution failed.", { cause });
18
+ }
19
+ }
20
+ export function surfaceError(error, where = "surface") {
21
+ const result = wire(error);
22
+ if (result.status === 500)
23
+ console.error(`[mantle ${where}] unhandled failure`, error);
24
+ return result;
25
+ }
26
+ export function surfaceFailure(error, challenge, where) {
27
+ const result = surfaceError(error, where);
28
+ return Response.json({ error: result.error }, { status: result.status, headers: { "cache-control": "private, no-store", ...(challenge && [401, 403].includes(result.status) && result.error.path !== "request:origin" ? { "www-authenticate": challenge } : {}) } });
29
+ }
@@ -1,6 +1,4 @@
1
- /** Shared request-time core of the generated surfaces: input failures and declared bind conversion. */
2
1
  import { ContractError } from "./contracts.js";
3
- /** INPUT_VALIDATION_FAILED at `path`. */
4
2
  export function invalid(message, path) { throw new ContractError("INPUT_VALIDATION_FAILED", message, path); }
5
3
  export function convert(input, fields = []) {
6
4
  for (const [key, type] of fields) {
@@ -8,7 +6,6 @@ export function convert(input, fields = []) {
8
6
  input[key] = value == null ? null : type === "json" ? JSON.stringify(value) : type === "bool" ? Number(value) : value;
9
7
  }
10
8
  }
11
- /** Path decoding for a matched template; a malformed escape is an input failure at `path`. */
12
9
  export function decode(keys, match, message, path) {
13
10
  try {
14
11
  return Object.fromEntries(keys.map((key, index) => [key, decodeURIComponent(match[index + 1])]));
@@ -1,4 +1,3 @@
1
- /** Host-only TTL maintenance; the selected candidates and delete share one native transaction. */
2
1
  import { ContractError } from "./contracts.js";
3
2
  import { binds } from "./binds.js";
4
3
  export async function sweep(db, caller, input, path, seconds, sql, d, execute) {
@@ -1,4 +1,3 @@
1
- /** Locale values stay ordinary strings; applications opt into canonicalisation. */
2
1
  import { invalid } from "./surface.js";
3
2
  export function toCanonicalLocale(value) {
4
3
  if (typeof value !== "string" || !value)
@@ -1,9 +1,7 @@
1
- /** Validation runs when an identity is built, outside ordinary query startup. */
2
1
  import { ContractError } from "./contract-error.js";
3
2
  import { validUserFieldName } from "./user-field-name.js";
4
3
  import { identities } from "./caller-identity.js";
5
4
  export const isAllValues = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1 && value.all === true;
6
- /** Role names follow Better Auth's role strings; `-` is allowed. */
7
5
  export const roleName = /^[a-z][a-zA-Z0-9_-]{0,62}$/;
8
6
  const invalid = (message) => { throw new ContractError("USER_FIELD_INVALID", message, "auth:user", { status: 400 }); };
9
7
  function valueOf(value, spec, field) {
@@ -13,8 +11,8 @@ function valueOf(value, spec, field) {
13
11
  if (typeof value !== spec.type)
14
12
  return invalid(`User field '${field}' must contain ${spec.type} values.`);
15
13
  if (typeof value === "number") {
16
- if (!Number.isFinite(value))
17
- return invalid(`User field '${field}' must be finite.`);
14
+ if (!(spec.integer ? Number.isInteger(value) : Number.isFinite(value)))
15
+ return invalid(`User field '${field}' must be ${spec.integer ? "an integer" : "finite"}.`);
18
16
  }
19
17
  else if (typeof value === "string" && (!value.isWellFormed() || value.includes("\0") || new TextEncoder().encode(value).length < 1 || new TextEncoder().encode(value).length > 256))
20
18
  return invalid(`User field '${field}' must be 1–256 UTF-8 bytes, without NUL or lone surrogates.`);
@@ -30,7 +28,6 @@ function valueOf(value, spec, field) {
30
28
  return invalid(`User field '${field}' accepts at most 64 values.`);
31
29
  return Object.freeze([...new Set(value.map(scalar))]);
32
30
  }
33
- /** Better Auth stores roles comma-separated; split exactly as it does and fail fast on a name no manifest could match. */
34
31
  export function rolesFrom(role) {
35
32
  if (role == null || role === "")
36
33
  return Object.freeze([]);
@@ -38,11 +35,9 @@ export function rolesFrom(role) {
38
35
  return invalid("User role must be comma-separated text.");
39
36
  return Object.freeze([...new Set(role.split(",").map(part => roleName.test(part) ? part : invalid(`Invalid role '${part}'.`)))]);
40
37
  }
41
- /** Better Auth's configured admin layer, independent of per-operation access control. */
42
38
  export function adminLayerOf(user, options = {}) {
43
39
  return !!options.adminUserIds?.includes(user.id) || rolesFrom(user.role).some(role => (options.adminRoles ?? ["admin"]).some(name => name.trim() === role));
44
40
  }
45
- /** Auth integration and test helpers validate a verified Better Auth-shaped user once. */
46
41
  export function userCaller(user, options = {}) {
47
42
  if (typeof user.id !== 'string' || !user.id || /^(host|system):/i.test(user.id))
48
43
  return invalid("User subjects must be non-empty strings without reserved prefixes.");
@@ -59,7 +54,6 @@ export function userCaller(user, options = {}) {
59
54
  admin: adminLayerOf(user, options), roles, fields,
60
55
  ...(options.issuer ? { issuer: options.issuer } : {}), scopes: Object.freeze([...(options.scopes ?? [])]),
61
56
  credential: options.credential ?? "session", credentialId: options.credentialId ?? null, clientId: options.clientId ?? null };
62
- // The real actor is read from the brand, so a spread or forged copy never carries it.
63
57
  if (options.impersonatedBy)
64
58
  Object.defineProperty(caller, "impersonatedBy", { get() { return identities.get(this)?.impersonatedBy; } });
65
59
  identities.set(Object.freeze(caller), Object.freeze({ roles, fields, admin: caller.admin, ...(options.impersonatedBy ? { impersonatedBy: options.impersonatedBy } : {}) }));
@@ -8,6 +8,7 @@ export interface AllValues {
8
8
  export type UserValue = string | number | readonly (string | number)[] | AllValues;
9
9
  export interface UserField {
10
10
  readonly type: "string" | "number";
11
+ readonly integer?: true;
11
12
  readonly multiple: boolean;
12
13
  readonly enum?: readonly (string | number)[];
13
14
  }
@@ -23,12 +24,12 @@ export declare function scopeInsert(scopes: readonly {
23
24
  readonly field: string;
24
25
  readonly type: 'string' | 'number';
25
26
  }[], caller: Caller, set: object): object;
26
- export { modeOf } from "./caller-identity.js";
27
+ export { modeOf } from "./caller-mode.js";
27
28
  export interface UserRecipe extends Extract<Recipe, {
28
29
  k: "user";
29
30
  }> {
30
31
  }
31
32
  /** A missing or empty value is refused here, before any SQL runs; correctness never relies on NULL semantics. */
32
33
  export declare const userBinds: (sqlite: boolean) => (recipe: UserRecipe, caller: Caller) => unknown;
33
- export declare const userRefs: (sqlite: boolean, version: number) => (recipe: Recipe, caller: Caller) => unknown;
34
+ export declare const userRefs: (sqlite: boolean, version: number) => (recipe: Recipe, caller: Caller, input?: Readonly<Record<string, unknown>>) => unknown;
34
35
  export { roleName, rolesFrom } from "./user-caller.js";
@@ -1,12 +1,9 @@
1
- /** Only the original caller carries authority. */
2
1
  import { ContractError } from "./contract-error.js";
3
2
  export const USER_REFS_V1 = 1;
4
3
  import { valueFor, isHostCaller } from "./caller-identity.js";
5
4
  import { isAllValues } from "./user-caller.js";
6
- /** Whether the caller holds `{ all: true }` for a field: its row-template term is dropped. */
7
5
  export const isAll = (caller, field) => isAllValues(valueFor(caller, field));
8
6
  const SCOPE_TERM = /\/\*s:(\w+)\*\/([^]*?)\/\*\|\*\/([^]*?)\/\*;\*\//g;
9
- /** Generated SQL prints each Schema scope term twice; `{ all: true }` keeps the always-true form, every other caller the filter. */
10
7
  export const scoped = (sql, caller) => sql.replace(SCOPE_TERM, (_, field, kept, all) => isAll(caller, field) ? all : kept);
11
8
  export const scopedAll = (value, caller) => JSON.parse(JSON.stringify(value), (_key, v) => typeof v === "string" ? scoped(v, caller) : v);
12
9
  const missing = (caller, field) => {
@@ -16,12 +13,10 @@ const missing = (caller, field) => {
16
13
  throw new ContractError('UNAUTHENTICATED', `Rows scoped by '${field}' require an authenticated caller.`);
17
14
  throw new ContractError('AUTH_DENIED', `The caller has no value for '${field}', which scopes these rows.`);
18
15
  };
19
- /** Store and Admin share the insert membership check. */
20
16
  export function scopeInsert(scopes, caller, set) {
21
17
  let out = set;
22
18
  for (const scope of scopes) {
23
19
  const own = ([field]) => field.toLowerCase() === scope.column;
24
- // An optional input forwarded as `undefined` is "not given": autofill or the usual refusal applies, not a scope denial.
25
20
  out = Object.fromEntries(Object.entries(out).filter(entry => !(own(entry) && entry[1] === undefined)));
26
21
  const given = Object.entries(out).find(own);
27
22
  if (caller.kind === 'system' || isAll(caller, scope.field)) {
@@ -43,12 +38,10 @@ export function scopeInsert(scopes, caller, set) {
43
38
  }
44
39
  return out;
45
40
  }
46
- export { modeOf } from "./caller-identity.js";
41
+ export { modeOf } from "./caller-mode.js";
47
42
  const pgArray = (values) => `{${values.map(value => `"${String(value).replace(/[\\"]/g, "\\$&")}"`).join(",")}}`;
48
- /** A missing or empty value is refused here, before any SQL runs; correctness never relies on NULL semantics. */
49
43
  export const userBinds = (sqlite) => (recipe, caller) => {
50
44
  const value = valueFor(caller, recipe.field);
51
- // An all-values scope still binds its parameter; restrict never widens.
52
45
  if (isAllValues(value) && recipe.scope)
53
46
  return sqlite ? "[]" : "{}";
54
47
  if (isAllValues(value))
@@ -62,9 +55,16 @@ export const userRefs = (sqlite, version) => {
62
55
  if (version !== USER_REFS_V1)
63
56
  throw new ContractError('USER_REFS_VERSION', 'User-reference bind version differs.');
64
57
  const user = userBinds(sqlite);
65
- return (recipe, caller) => {
58
+ return (recipe, caller, input) => {
66
59
  if (recipe.k === 'user')
67
60
  return user(recipe, caller);
61
+ if (recipe.k === 'scoped') {
62
+ const given = recipe.name !== undefined ? input?.[recipe.name] ?? undefined : recipe.value;
63
+ const value = scopeInsert([recipe], caller, { [recipe.column]: given })[recipe.column];
64
+ if (typeof value !== recipe.type)
65
+ throw new ContractError('INPUT_VALIDATION_FAILED', `'${recipe.column}' must be a ${recipe.type}.`);
66
+ return value;
67
+ }
68
68
  throw new ContractError('USER_REFS_VERSION', `Unknown user-reference bind recipe '${recipe.k}'.`);
69
69
  };
70
70
  };
@@ -1,8 +1,5 @@
1
- /** Helpers of generated input validation: the checks themselves are compiled per operation, no schema is read at request time. */
2
1
  import { invalid } from "./surface.js";
3
- /** INPUT_VALIDATION_FAILED at the JSON pointer `path`. */
4
2
  export function fail(path, message) { return invalid(message, path); }
5
- /** JSON equality for `enum`. */
6
3
  export function equal(a, b) {
7
4
  if (a === b)
8
5
  return true;
@@ -15,7 +12,6 @@ const pointer = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
15
12
  const tests = { string: (value) => typeof value === "string", number: Number.isFinite, integer: Number.isSafeInteger, boolean: (value) => typeof value === "boolean", object: (value) => typeof value === "object" && value !== null && !Array.isArray(value) };
16
13
  const is = (value, path, type, nullable) => { if (!(tests[type](value) || nullable && value === null))
17
14
  fail(path, "Expected " + type + (nullable ? " or null." : ".")); };
18
- /** The check of the publishing transitions' input `{id, expectedVersion}` at `path`. */
19
15
  export function transition(path, raw) {
20
16
  const keys = ["id", "expectedVersion"];
21
17
  is(raw, path, "object");
@@ -38,10 +34,6 @@ function number(raw, path, integer) { const value = Number(raw); if (raw.trim()
38
34
  fail(path, "Expected a valid number."); return value; }
39
35
  function boolean(raw, path) { if (raw !== "true" && raw !== "false")
40
36
  fail(path, "Expected true or false."); return raw === "true"; }
41
- /**
42
- * A View's query string: each declared field (typed `boolean`, `integer` or `number` when the input says so) is read
43
- * from `params`, then validated; `limit` and `cursor` are the paging options. `operation` is `views/<name>`.
44
- */
45
37
  export function query(params, validate, operation, fields) {
46
38
  const input = {};
47
39
  for (const [key, type] of fields) {
@@ -1,4 +1,3 @@
1
- /** Selected web authorization loads on a request, outside the ordinary web startup graph. */
2
1
  import { web } from "./web.js";
3
2
  import { gatewayCaller, hostFailure } from "./host-caller.js";
4
3
  import { accessRequest } from "./host-access.js";
@@ -34,7 +33,6 @@ export function gatewayWeb(options, table) {
34
33
  throw error;
35
34
  }
36
35
  } }]));
37
- // The ordinary renderer validates query/path fields; wrapped pages decide before any SQL.
38
36
  const result = await site[method](request, env, options.gateway ? undefined : ctx);
39
37
  if (failure)
40
38
  throw failure;
@@ -1,4 +1,3 @@
1
- /** HTML head formatting loads only when the application asks for head tags. */
2
1
  const escape = (v) => String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
3
2
  export function headTags(canonical, row, markdown, locales) {
4
3
  let tags = '<link rel="canonical" href="' + escape(canonical) + '">';
@@ -0,0 +1,39 @@
1
+ const escape = (v) => String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
2
+ const label = (v) => String(v).replace(/[\r\n]/g, " ").replace(/[\\[\]]/g, "\\$&");
3
+ const size = (s) => new TextEncoder().encode(s).length;
4
+ export async function webIndex(sitemap, options, pages, list, validUrl) {
5
+ const cap = sitemap ? 50000 : 5000, limit = sitemap ? 50 * 1024 * 1024 : 1024 * 1024;
6
+ const start = sitemap ? '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' : "# " + label(options.title) + "\n\n> " + label(options.description) + "\n\n## Pages\n\n", end = sitemap ? "</urlset>" : "\n";
7
+ const links = [];
8
+ let visited = 0, requests = 0, bytes = size(start + end);
9
+ if (bytes > limit)
10
+ throw new TypeError("Web index header exceeds its byte limit.");
11
+ pagesLoop: for (const entry of pages) {
12
+ if (!entry.page.list)
13
+ continue;
14
+ let cursor;
15
+ do {
16
+ if (requests >= (sitemap ? 100 : 10))
17
+ break pagesLoop;
18
+ requests++;
19
+ const page = await list(entry.page.list, cursor);
20
+ for (const row of page.rows) {
21
+ if (visited++ >= cap)
22
+ break pagesLoop;
23
+ const url = validUrl(entry.path, row, entry.page.view);
24
+ if (!url)
25
+ continue;
26
+ const line = sitemap ? "<url><loc>" + escape(url) + "</loc></url>" : "- [" + label(row.title ?? url) + "](" + url + "): " + label(row.description ?? "") + "\n";
27
+ const length = size(line);
28
+ if (bytes + length > limit)
29
+ break pagesLoop;
30
+ bytes += length;
31
+ links.push(line);
32
+ }
33
+ if (visited >= cap)
34
+ break pagesLoop;
35
+ cursor = page.nextCursor;
36
+ } while (cursor);
37
+ }
38
+ return start + links.join("") + end;
39
+ }
@@ -1,4 +1,3 @@
1
- /** Response hashing is needed only when a Web route actually renders. */
2
1
  export async function webResponse(request, body, type, cacheControl) {
3
2
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
4
3
  const etag = '"' + Array.from(new Uint8Array(hash), b => b.toString(16).padStart(2, '0')).join('') + '"';