@aotter/mantle 0.2.0-alpha.10 → 0.2.0-alpha.11
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/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +12 -611
- package/dist/build/compiler.js +3 -7029
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +0 -15
- package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +0 -2
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +9 -6
- package/dist/infrastructure/adminPaging.js +0 -2
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +0 -29
- package/dist/infrastructure/cli.js +0 -7
- package/dist/infrastructure/generate.js +0 -14
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +0 -5
- package/dist/infrastructure/init.js +0 -4
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +0 -3
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.js +1 -11
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +1 -9
- package/dist/kernel/contracts.js +1 -42
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +0 -4
- package/dist/kernel/driver/d1.js +0 -1
- package/dist/kernel/driver/postgres.js +0 -15
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +2 -26
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +1 -2
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +2 -3
- package/dist/kernel/rest.js +0 -3
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.js +1 -26
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +0 -6
- package/dist/kernel/user-fields.d.ts +1 -1
- package/dist/kernel/user-fields.js +1 -11
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +28 -21
- package/dist/usecase/adminClient.js +0 -21
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +0 -6
- package/dist/usecase/handlers.js +0 -3
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +0 -2
- package/dist/usecase/publishing.js +0 -4
- package/dist/usecase/rest.js +0 -1
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +1 -13
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/package.json +3 -3
- package/docs/examples/app-shell/src/routes.ts +3 -1
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/handbook/cloudflare/service-entry.md +3 -0
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/agent-setup.md +4 -4
- package/docs/handbook/guides/public-pages.md +6 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/schema.md +1 -1
- package/docs/handbook/releases/index.md +3 -3
- package/package.json +2 -2
- package/skills/README.md +1 -1
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
package/dist/kernel/media.js
CHANGED
|
@@ -1,33 +1,23 @@
|
|
|
1
|
-
/** 0.2 media lifecycle over native target drivers. No bytes pass through this module. */
|
|
2
1
|
import { ContractError } from "./contracts.js";
|
|
3
2
|
const P = "media";
|
|
4
|
-
/** HTTP status of each media code: a custom MediaStorage may throw them without one. */
|
|
5
3
|
const STATUS = new Map(Object.entries({
|
|
6
4
|
MEDIA_PURPOSE_REJECTED: 400, MEDIA_SVG_REJECTED: 400, MEDIA_MIME_REJECTED: 400, MEDIA_VARIANTS_INCOMPLETE: 400, MEDIA_VARIANT_SIZE_EXCEEDED: 400, MEDIA_VARIANTS_SUSPICIOUS_SIZE: 400,
|
|
7
5
|
MEDIA_ASSET_NOT_FOUND: 404, MEDIA_OBJECT_NOT_FOUND: 409, MEDIA_UPLOAD_EXPIRED: 410, SITE_NOT_CONFIGURED: 501, MEDIA_NOT_CONFIGURED: 501, PARTIAL_FAILURE: 503,
|
|
8
6
|
}));
|
|
9
7
|
const extra = (code) => ({ status: STATUS.get(code), ...(code === "PARTIAL_FAILURE" ? { fields: { failure: { outcome: "partial", retry: "safe", resource: "media" } } } : {}) });
|
|
10
|
-
/**
|
|
11
|
-
* A media, site or partial-failure error with the HTTP status (and `failure` payload) its code has on the wire. Code
|
|
12
|
-
* outside this module raising these codes uses it: core `statusOf` maps only the core codes.
|
|
13
|
-
*/
|
|
14
8
|
export const mediaError = (code, message, path = P, options) => new ContractError(code, message, path, { ...options, ...extra(code) });
|
|
15
9
|
const fail = (code, message, options) => mediaError(code, message, P, options);
|
|
16
|
-
/** A storage error keeps the wire status its code has always had. */
|
|
17
10
|
const own = (error) => { if (error instanceof ContractError && !error.status && STATUS.has(error.code))
|
|
18
11
|
Object.assign(error, extra(error.code)); return error; };
|
|
19
12
|
const rejected = (results) => results.filter((r) => r.status === "rejected");
|
|
20
13
|
const bad = (expected) => fail("INPUT_VALIDATION_FAILED", `expected ${expected}`);
|
|
21
|
-
/** SVG is refused: object storage does not sanitize it, and an SVG can carry script. */
|
|
22
14
|
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif"]);
|
|
23
|
-
/** Modern formats a skipped optimization makes larger than the fallback. */
|
|
24
15
|
const MODERN = new Set(["image/avif", "image/webp"]);
|
|
25
16
|
const ROLES = new Set(["primary", "alternate", "fallback"]);
|
|
26
17
|
const TTL_MS = 15 * 60 * 1000;
|
|
27
18
|
const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
28
19
|
const MAX_TEXT = 1000;
|
|
29
20
|
const MAX_FILENAME = 255;
|
|
30
|
-
/** `alt` and `caption`: absent, or a string of at most 1000 characters. */
|
|
31
21
|
function texts(body) {
|
|
32
22
|
const b = obj(body);
|
|
33
23
|
for (const k of ["alt", "caption"])
|
|
@@ -35,7 +25,6 @@ function texts(body) {
|
|
|
35
25
|
throw bad(`{ alt?: string, caption?: string }, each at most ${MAX_TEXT} characters`);
|
|
36
26
|
return { ...(typeof b["alt"] === "string" ? { alt: b["alt"] } : {}), ...(typeof b["caption"] === "string" ? { caption: b["caption"] } : {}) };
|
|
37
27
|
}
|
|
38
|
-
/** Exactly one variant per required slot, nothing outside them, and exactly one primary. */
|
|
39
28
|
function checkVariants(policy, variants) {
|
|
40
29
|
const slots = policy.required.map(slot => slot.split(","));
|
|
41
30
|
const slotOf = new Map(slots.flatMap((mimes, i) => mimes.map((m) => [m, i])));
|
|
@@ -99,7 +88,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
99
88
|
checkVariants(policy, asked);
|
|
100
89
|
const at = now();
|
|
101
90
|
const expiresAt = at + TTL_MS;
|
|
102
|
-
// bearer-equivalent until commit: storage keys under it must not be guessable
|
|
103
91
|
const uploadGroupId = crypto.randomUUID();
|
|
104
92
|
const specs = asked.map(({ mimeType, byteSize, role }) => ({ mimeType, byteSize, role, maxBytes: policy.maxBytes[mimeType] }));
|
|
105
93
|
let capabilities;
|
|
@@ -126,7 +114,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
126
114
|
return assetOf(committed);
|
|
127
115
|
const row = await one(`SELECT record FROM pending_media_uploads WHERE id = ${p(1)}`, [uploadGroupId]);
|
|
128
116
|
const record = row ? JSON.parse(String(row.record)) : null;
|
|
129
|
-
// a rejected group's objects are deleted: an upload URL takes any type and size, so what was PUT is not trusted
|
|
130
117
|
const discard = () => removeAll(record?.variants ?? []);
|
|
131
118
|
if (!record || record.expiresAt <= now()) {
|
|
132
119
|
if (record) {
|
|
@@ -139,7 +126,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
139
126
|
try {
|
|
140
127
|
asset = await storage.commitUpload({
|
|
141
128
|
uploadGroupId, filename: record.filename, now: record.createdAt,
|
|
142
|
-
// the declared size is the cap: a larger object than announced is refused
|
|
143
129
|
variants: record.variants.map((v) => ({ mimeType: v.mimeType, role: v.role, storageKey: v.storageKey, maxBytes: Math.min(v.maxBytes, v.expectedSize) })),
|
|
144
130
|
...((patch.alt ?? record.alt) !== undefined ? { alt: patch.alt ?? record.alt } : {}),
|
|
145
131
|
...((patch.caption ?? record.caption) !== undefined ? { caption: patch.caption ?? record.caption } : {}),
|
|
@@ -158,7 +144,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
158
144
|
if (asset.id !== uploadGroupId)
|
|
159
145
|
throw new ContractError("OUTCOME_UNKNOWN", "Storage returned a different asset id; reconcile this upload group.", P);
|
|
160
146
|
await driver.write([save(asset, p), unpend(uploadGroupId)]).catch(async (error) => {
|
|
161
|
-
// Cleanup requires positive native rollback evidence; an unclassified failure may have committed.
|
|
162
147
|
if (driver.definitive?.(error) === true) {
|
|
163
148
|
const failed = rejected(await removeAll(asset.variants));
|
|
164
149
|
if (failed.length)
|
|
@@ -168,7 +153,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
168
153
|
throw error instanceof ContractError && error.code === "OUTCOME_UNKNOWN" ? error : new ContractError("OUTCOME_UNKNOWN", "The write outcome is unknown; reconcile this upload group before retrying.", P, { cause: error });
|
|
169
154
|
});
|
|
170
155
|
const durable = await get(uploadGroupId);
|
|
171
|
-
// ON CONFLICT may select another concurrent attempt. Only discard this attempt's unselected copies.
|
|
172
156
|
const selected = new Set(durable.variants.map(v => v.storageKey));
|
|
173
157
|
if (rejected(await removeAll(asset.variants.filter(v => !selected.has(v.storageKey)))).length)
|
|
174
158
|
throw new ContractError("OUTCOME_UNKNOWN", "The asset is committed, but unselected upload copies need storage reconciliation.", P);
|
|
@@ -193,7 +177,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
193
177
|
const patch = Object.entries(texts(request));
|
|
194
178
|
if (!patch.length)
|
|
195
179
|
return get(id);
|
|
196
|
-
// only the patched columns, and only a row that still exists: a concurrent delete or edit of the other field stands
|
|
197
180
|
const [r] = (await driver.write([{ sql: `UPDATE media_assets SET ${patch.map(([k], i) => `${k} = ${p(i + 2)}`).join(", ")} WHERE id = ${p(1)} RETURNING ${COLUMNS}`, binds: [id, ...patch.map(([, v]) => v)] }]))[0];
|
|
198
181
|
if (!r)
|
|
199
182
|
throw fail("MEDIA_ASSET_NOT_FOUND", `no media asset '${id}'`);
|
|
@@ -209,7 +192,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
209
192
|
},
|
|
210
193
|
};
|
|
211
194
|
}
|
|
212
|
-
/** The 0.2 Admin library item's convenience fields, without changing the storage DTO. */
|
|
213
195
|
export function mediaItem(asset) {
|
|
214
196
|
const primary = asset.variants.find(v => v.role === "primary") ?? asset.variants[0];
|
|
215
197
|
return { id: asset.id, variants: asset.variants, primaryUrl: primary?.publicUrl ?? null, mime: primary?.mimeType ?? null, byteSize: primary?.byteSize ?? null, alt: asset.alt ?? null, caption: asset.caption ?? null, createdAt: asset.createdAt };
|
package/dist/kernel/paging.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { modeOf } from "./caller-
|
|
1
|
+
import { modeOf } from "./caller-mode.js";
|
|
2
2
|
import { binds } from "./binds.js";
|
|
3
3
|
import { invalid } from "./surface.js";
|
|
4
|
-
/** Shared keyset wire format; a selected View binds its cursor to its operation. */
|
|
5
4
|
export const encodeCursor = (value, id, operation) => encodeURIComponent(JSON.stringify(operation === undefined ? [value, id] : [value, id, operation]));
|
|
6
5
|
export function decodeCursor(cursor, path = "", operation) {
|
|
7
6
|
try {
|
|
@@ -12,7 +11,6 @@ export function decodeCursor(cursor, path = "", operation) {
|
|
|
12
11
|
catch { }
|
|
13
12
|
invalid("Malformed or foreign cursor.", path);
|
|
14
13
|
}
|
|
15
|
-
/** Selected boundaries may accept a NULL key; their generated kind guard validates it before native I/O. */
|
|
16
14
|
export function decodeNullableCursor(cursor, path, operation) {
|
|
17
15
|
try {
|
|
18
16
|
const c = JSON.parse(decodeURIComponent(cursor));
|
|
@@ -23,7 +21,6 @@ export function decodeNullableCursor(cursor, path, operation) {
|
|
|
23
21
|
invalid("Malformed or foreign cursor.", path);
|
|
24
22
|
}
|
|
25
23
|
export const MAX_PAGE_SIZE = 500;
|
|
26
|
-
/** Transport and native entry bounds share one check; Views retain their declared cap. */
|
|
27
24
|
export function checkPageLimit(limit, path) {
|
|
28
25
|
if (!Number.isSafeInteger(limit) || Number(limit) < 1 || Number(limit) > MAX_PAGE_SIZE)
|
|
29
26
|
invalid("limit is 1 to 500.", path);
|
|
@@ -41,10 +38,6 @@ export function validCursor(kind, sqlite, key) {
|
|
|
41
38
|
default: return typeof key === "string";
|
|
42
39
|
}
|
|
43
40
|
}
|
|
44
|
-
/**
|
|
45
|
-
* A View's paged boundary: `options.limit` defaults to min(50, cap) and is capped at cap and 500. Generated modules
|
|
46
|
-
* call it from their exported function, so importing a View runs no kernel code.
|
|
47
|
-
*/
|
|
48
41
|
export async function page(spec, db, caller, input, options = {}) {
|
|
49
42
|
const { path, cap } = spec;
|
|
50
43
|
spec.auth?.(caller);
|
|
@@ -62,7 +55,6 @@ export async function page(spec, db, caller, input, options = {}) {
|
|
|
62
55
|
throw 0;
|
|
63
56
|
if (spec.keys === 1 && encoded[0] !== encoded[1])
|
|
64
57
|
throw 0;
|
|
65
|
-
// Preserve the existing [id,id,operation] single-key and [value,id,operation] two-key formats.
|
|
66
58
|
keys = spec.keys === 1 ? [encoded[1]] : encoded.slice(0, -1);
|
|
67
59
|
for (const [i, kind] of spec.cursor.entries()) {
|
|
68
60
|
if (!validCursor(kind, spec.sqlite, keys[i]))
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
/** A generated publishing transition, admin or system only; `write` claims translation state, released on CONFLICT. */
|
|
2
1
|
import { isAdmin, deny, ROW_CONFLICT } from "./contracts.js";
|
|
3
|
-
import { checkFresh } from "./caller-
|
|
2
|
+
import { checkFresh } from "./caller-mode.js";
|
|
4
3
|
import { bound } from "./binds.js";
|
|
5
4
|
export async function transition(db, caller, input, path, steps, failure, d, execute, write) {
|
|
6
5
|
checkFresh(caller, false, path);
|
package/dist/kernel/r2-media.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Native R2 binding plus an app-owned SigV4 signer. No SDK dependency. */
|
|
2
1
|
import { ContractError } from "./contracts.js";
|
|
3
2
|
import { mediaError } from "./media.js";
|
|
4
3
|
const EXT = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/avif": "avif", "image/gif": "gif" };
|
|
@@ -16,7 +15,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
16
15
|
const ttl = Math.max(60, Math.floor((expiresAt - now) / 1000));
|
|
17
16
|
return {
|
|
18
17
|
capabilities: await Promise.all(variants.map(async (v) => {
|
|
19
|
-
// server-made keys, one directory per asset so its variants list together
|
|
20
18
|
const storageKey = `${UPLOADS}${purpose}/${uploadGroupId}/${v.role}.${ext(v.mimeType)}`;
|
|
21
19
|
const target = new URL(`${s3}/${storageKey}`);
|
|
22
20
|
target.searchParams.set("X-Amz-Expires", String(ttl));
|
|
@@ -26,7 +24,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
26
24
|
};
|
|
27
25
|
},
|
|
28
26
|
async commitUpload({ uploadGroupId, filename, variants, alt, caption, now }) {
|
|
29
|
-
// every variant is checked before any is published, so a rejected group publishes nothing
|
|
30
27
|
const heads = await Promise.all(variants.map(spec => bucket.head(spec.storageKey)));
|
|
31
28
|
for (const [i, spec] of variants.entries()) {
|
|
32
29
|
const o = heads[i];
|
|
@@ -38,19 +35,16 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
38
35
|
if (o.size > spec.maxBytes)
|
|
39
36
|
throw fail("MEDIA_VARIANT_SIZE_EXCEEDED", `the stored ${spec.role} is ${o.size} bytes, more than ${spec.maxBytes}`);
|
|
40
37
|
}
|
|
41
|
-
// Each attempt owns its public copies: a concurrent loser cannot overwrite or clean up a committed winner.
|
|
42
38
|
const attempt = crypto.randomUUID();
|
|
43
39
|
const out = [];
|
|
44
40
|
try {
|
|
45
41
|
for (const [i, spec] of variants.entries()) {
|
|
46
42
|
const key = `committed/${uploadGroupId}/${attempt}/${spec.role}.${ext(spec.mimeType)}`;
|
|
47
43
|
const o = await bucket.get(spec.storageKey);
|
|
48
|
-
// an upload URL may PUT again between the check and the copy: the copy must be what was checked
|
|
49
44
|
if (!o || o.size !== heads[i].size || o.httpMetadata?.contentType !== spec.mimeType) {
|
|
50
45
|
await cancel(o);
|
|
51
46
|
throw fail("MEDIA_OBJECT_NOT_FOUND", `variant ${spec.role} of '${uploadGroupId}' changed during commit`);
|
|
52
47
|
}
|
|
53
|
-
// Remember a possibly published key before awaiting put: a failed response may still have stored it.
|
|
54
48
|
out.push({ mimeType: spec.mimeType, publicUrl: `${base}/${key}`, storageKey: key, byteSize: o.size, role: spec.role });
|
|
55
49
|
try {
|
|
56
50
|
await bucket.put(key, o.body, {
|
|
@@ -63,7 +57,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
63
57
|
throw error;
|
|
64
58
|
}
|
|
65
59
|
}
|
|
66
|
-
// The use case removes upload keys only after its SQL transaction is durable.
|
|
67
60
|
}
|
|
68
61
|
catch (error) {
|
|
69
62
|
const cleanup = await Promise.allSettled(out.map(v => bucket.delete(v.storageKey)));
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
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;
|
package/dist/kernel/rest.js
CHANGED
|
@@ -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))
|
package/dist/kernel/scalars.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
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) {
|
|
@@ -11,7 +6,6 @@ export function encodeTimestamptz(v) {
|
|
|
11
6
|
throw new ContractError("SQL_TYPE", "Invalid Date is not a timestamp");
|
|
12
7
|
return micros;
|
|
13
8
|
}
|
|
14
|
-
// hours 00-23: Date.parse would roll T24:00 over to the next day
|
|
15
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());
|
|
16
10
|
if (!m)
|
|
17
11
|
throw new ContractError("SQL_TYPE", `'${v}' is not a timestamp with an explicit offset`);
|
|
@@ -24,14 +18,13 @@ export function encodeTimestamptz(v) {
|
|
|
24
18
|
throw new ContractError("SQL_TYPE", `'${v}' is not a valid timestamp`);
|
|
25
19
|
return micros;
|
|
26
20
|
}
|
|
27
|
-
/** A date is the string a calendar shows; a Date instant has no single calendar day, so it is not accepted. */
|
|
28
21
|
export function encodeDate(v) {
|
|
29
22
|
const m = /^(\d{4})-(\d\d)-(\d\d)$/.exec(v);
|
|
30
23
|
if (!m)
|
|
31
24
|
throw new ContractError("SQL_TYPE", `'${v}' is not a date`);
|
|
32
25
|
const [, y, mo, d] = m;
|
|
33
26
|
const dt = new Date(0);
|
|
34
|
-
dt.setUTCFullYear(+y, +mo - 1, +d);
|
|
27
|
+
dt.setUTCFullYear(+y, +mo - 1, +d);
|
|
35
28
|
if (dt.getUTCDate() !== +d || dt.getUTCMonth() !== +mo - 1)
|
|
36
29
|
throw new ContractError("SQL_TYPE", `'${v}' is not a valid date`);
|
|
37
30
|
return dt.getTime() / 86_400_000;
|
|
@@ -40,5 +33,4 @@ export function isoTimestamptz(v) {
|
|
|
40
33
|
const us = encodeTimestamptz(v), ms = Math.floor(us / 1000);
|
|
41
34
|
return new Date(ms).toISOString().replace("Z", String(us - ms * 1000).padStart(3, "0") + "Z");
|
|
42
35
|
}
|
|
43
|
-
/** The input itself: the pattern admits only the canonical form, and a day that does not exist was refused. */
|
|
44
36
|
export const isoDate = (v) => (encodeDate(v), v);
|
package/dist/kernel/search.js
CHANGED
|
@@ -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 ');
|
package/dist/kernel/site.js
CHANGED
|
@@ -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;
|
package/dist/kernel/store.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { scoped } from "./user-fields.js";
|
|
2
|
-
import { modeOf, checkFresh } from "./caller-
|
|
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);
|
|
@@ -129,7 +115,6 @@ export function createStore(d, drv, rules, facts, operations) {
|
|
|
129
115
|
}
|
|
130
116
|
function collection(db, caller, p, write) {
|
|
131
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}`);
|
|
132
|
-
/** 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). */
|
|
133
118
|
const put = (values, column, v) => {
|
|
134
119
|
const type = p.ts.includes(column) ? "timestamptz" : p.d.includes(column) ? "date" : v !== null && typeof v === "object" && !(v instanceof Date) ? "json" : "";
|
|
135
120
|
values.push(type === "json" ? JSON.stringify(v) : encode(d, type, v));
|
|
@@ -147,7 +132,6 @@ export function createStore(d, drv, rules, facts, operations) {
|
|
|
147
132
|
return out;
|
|
148
133
|
};
|
|
149
134
|
const rows = (sql, values) => drv.read(db, scoped(sql, caller), values);
|
|
150
|
-
/** Core never reads back the scope column, and `_rid` is storage's. */
|
|
151
135
|
const clean = (list) => { for (const r of list) {
|
|
152
136
|
delete r._rid;
|
|
153
137
|
if (f.s)
|
|
@@ -182,26 +166,21 @@ export function createStore(d, drv, rules, facts, operations) {
|
|
|
182
166
|
conds.push(`(${kc} ${up ? ">" : "<"} ${m} OR (${kc} = ${m} AND ${after}) OR ${kc} IS NULL)`);
|
|
183
167
|
}
|
|
184
168
|
}
|
|
185
|
-
// an ORDER BY column is NOT NULL (the generated type offers no other), so no NULL placement
|
|
186
169
|
const list = await rows(`${select()}${where(conds)} ORDER BY ${name === "id" ? "" : `${kc} ${way} NULLS LAST, `}${id} ${way} LIMIT ${limit + 1}`, values);
|
|
187
170
|
if (list.length <= limit)
|
|
188
171
|
return { rows: clean(list), cursor: null };
|
|
189
172
|
const page = list.slice(0, limit), end = page[limit - 1];
|
|
190
173
|
return { rows: clean(page), cursor: encodeCursor(end[name], end.id) };
|
|
191
174
|
}
|
|
192
|
-
/** A write: placeholders are numbered as values are pushed; a Core bind recipe is evaluated against this caller and clock. */
|
|
193
175
|
const writer = (lock) => {
|
|
194
176
|
const values = [], ms = Date.now(), into = d.ident(p.name);
|
|
195
177
|
const push = (v) => (values.push(v), d.param(values.length));
|
|
196
178
|
const recipe = (r) => push(evaluate(d, [r], caller, { version: lock }, ms)[0]);
|
|
197
|
-
/** The marks of the non-null columns written, by folded name. */
|
|
198
179
|
const marks = new Map();
|
|
199
180
|
const given = (set, insert = false) => Object.entries(set).map(([name, v]) => {
|
|
200
|
-
// SQLite matches column names without case, so compare folded
|
|
201
181
|
const key = name.toLowerCase();
|
|
202
182
|
if (SYSTEM.includes(key) || key === p.scope || !insert && !!f.u?.some(u => u[0] === key))
|
|
203
183
|
throw no(`${name} is not writable`);
|
|
204
|
-
// a driver binds undefined as NULL: an update would silently clear the column
|
|
205
184
|
if (v === undefined)
|
|
206
185
|
throw no(`${name} is undefined`);
|
|
207
186
|
const mark = put(values, name, v);
|
|
@@ -209,9 +188,7 @@ export function createStore(d, drv, rules, facts, operations) {
|
|
|
209
188
|
marks.set(key, mark);
|
|
210
189
|
return [d.ident(name), mark];
|
|
211
190
|
});
|
|
212
|
-
/** For each non-null reference written, the referenced row as the caller sees it, in the child's partition (`child`). */
|
|
213
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 ")})`] : []; });
|
|
214
|
-
/** `WHERE id = ? AND <the caller's row rules> AND version = ?` */
|
|
215
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 ")}`;
|
|
216
193
|
const step = (sql) => ({ sql: scoped(sql, caller), binds: values, row: true });
|
|
217
194
|
return { given, marks, recipe, refs, guard, step, into, returning: () => ` RETURNING *, ${p.returning ??= wires(p, "")}` };
|
|
@@ -243,7 +220,6 @@ export function createStore(d, drv, rules, facts, operations) {
|
|
|
243
220
|
names.push(d.ident(column));
|
|
244
221
|
marks.push(v === "newId" ? rules.newId : w.recipe(v));
|
|
245
222
|
}
|
|
246
|
-
// a reference to a scoped Schema: the row is inserted only if the parent check holds, else `row` fails with CONFLICT
|
|
247
223
|
const refs = w.refs(column => w.marks.get(column));
|
|
248
224
|
return w.step(`INSERT INTO ${w.into} (${names.join(", ")}) ${refs.length ? `SELECT ${marks.join(", ")} WHERE ${refs.join(" AND ")}` : `VALUES (${marks.join(", ")})`}${w.returning()}`);
|
|
249
225
|
}, rows => clean(rows)[0], `${p.name}/insert`);
|
|
@@ -253,7 +229,6 @@ export function createStore(d, drv, rules, facts, operations) {
|
|
|
253
229
|
const w = writer(lock), assigns = w.given(set).map(([name, mark]) => `${name} = ${mark}`);
|
|
254
230
|
for (const [column, v] of x.sets)
|
|
255
231
|
assigns.push(`${d.ident(column)} = ${v === "bump" ? `${d.ident(column)} + 1` : w.recipe(v)}`);
|
|
256
|
-
// the partition is never SET, so the child's is the row's own
|
|
257
232
|
const refs = w.refs(column => `${w.into}.${d.ident(column)}`);
|
|
258
233
|
return w.step(`UPDATE ${w.into} SET ${assigns.join(", ")}${w.guard(id, refs)}${w.returning()}`);
|
|
259
234
|
}, rows => clean(rows)[0], `${p.name}/update`);
|
|
@@ -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
|
+
}
|
package/dist/kernel/surface.js
CHANGED
|
@@ -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])]));
|
package/dist/kernel/sweep.js
CHANGED
|
@@ -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) {
|
|
@@ -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 } : {}) }));
|
|
@@ -24,7 +24,7 @@ export declare function scopeInsert(scopes: readonly {
|
|
|
24
24
|
readonly field: string;
|
|
25
25
|
readonly type: 'string' | 'number';
|
|
26
26
|
}[], caller: Caller, set: object): object;
|
|
27
|
-
export { modeOf } from "./caller-
|
|
27
|
+
export { modeOf } from "./caller-mode.js";
|
|
28
28
|
export interface UserRecipe extends Extract<Recipe, {
|
|
29
29
|
k: "user";
|
|
30
30
|
}> {
|
|
@@ -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-
|
|
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))
|
|
@@ -65,12 +58,9 @@ export const userRefs = (sqlite, version) => {
|
|
|
65
58
|
return (recipe, caller, input) => {
|
|
66
59
|
if (recipe.k === 'user')
|
|
67
60
|
return user(recipe, caller);
|
|
68
|
-
// A field-scoped Schema's scope column on an SQL INSERT: the Store's insert rule. A surface converts an omitted
|
|
69
|
-
// optional input that SQL also reads elsewhere to null, and a scope column is never null, so null is "not given".
|
|
70
61
|
if (recipe.k === 'scoped') {
|
|
71
62
|
const given = recipe.name !== undefined ? input?.[recipe.name] ?? undefined : recipe.value;
|
|
72
63
|
const value = scopeInsert([recipe], caller, { [recipe.column]: given })[recipe.column];
|
|
73
|
-
// scopeInsert checks the type only against a caller's own set; { all: true } and system callers name any value.
|
|
74
64
|
if (typeof value !== recipe.type)
|
|
75
65
|
throw new ContractError('INPUT_VALIDATION_FAILED', `'${recipe.column}' must be a ${recipe.type}.`);
|
|
76
66
|
return value;
|
|
@@ -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) {
|