@protobase/server 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/aggregate-service.js +135 -0
- package/dist/audit/console-queue.d.ts +5 -0
- package/dist/audit/console-queue.js +5 -0
- package/dist/auth/jwt.d.ts +21 -0
- package/dist/auth/jwt.js +49 -0
- package/dist/batch/refs.js +31 -0
- package/dist/batch/reorder.js +79 -0
- package/dist/batch/run-batch.js +105 -0
- package/dist/batch/schema.js +51 -0
- package/dist/better-auth/authenticator.d.ts +22 -0
- package/dist/better-auth/authenticator.js +21 -0
- package/dist/better-auth/create-auth.d.ts +1244 -0
- package/dist/better-auth/create-auth.js +93 -0
- package/dist/better-auth/jwks-cache.d.ts +9 -0
- package/dist/better-auth/jwks-cache.js +31 -0
- package/dist/better-auth/password-reset.js +32 -0
- package/dist/better-auth/status-route.js +17 -0
- package/dist/better-auth/tokens.d.ts +18 -0
- package/dist/better-auth/tokens.js +37 -0
- package/dist/better-auth/users.d.ts +50 -0
- package/dist/better-auth/users.js +111 -0
- package/dist/check-shell.js +17 -0
- package/dist/check-views.js +45 -0
- package/dist/create-admin.d.ts +23 -0
- package/dist/create-admin.js +85 -0
- package/dist/error-handler.js +44 -0
- package/dist/etag.js +22 -0
- package/dist/filter-input.js +28 -0
- package/dist/filter-shape.js +25 -0
- package/dist/hash.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/key.js +22 -0
- package/dist/list-service.js +96 -0
- package/dist/mail/mail-settings.js +26 -0
- package/dist/mail/smtp-mailer.d.ts +11 -0
- package/dist/mail/smtp-mailer.js +17 -0
- package/dist/meta.js +63 -0
- package/dist/openapi/components.js +331 -0
- package/dist/openapi/document.js +74 -0
- package/dist/openapi/naming.js +4 -0
- package/dist/openapi/resource-paths.js +417 -0
- package/dist/openapi/schemas.js +93 -0
- package/dist/params.js +48 -0
- package/dist/problem.d.ts +17 -0
- package/dist/problem.js +43 -0
- package/dist/read-service.js +65 -0
- package/dist/records.js +66 -0
- package/dist/registry.js +44 -0
- package/dist/request-access.js +97 -0
- package/dist/resource-source.d.ts +35 -0
- package/dist/resource-source.js +22 -0
- package/dist/reveal-service.js +43 -0
- package/dist/routes/batch.js +21 -0
- package/dist/routes/collection.js +63 -0
- package/dist/routes/docs.js +20 -0
- package/dist/routes/meta.js +22 -0
- package/dist/routes/preferences.js +12 -0
- package/dist/routes/record.js +97 -0
- package/dist/routes/target.js +18 -0
- package/dist/row-permissions.js +69 -0
- package/dist/rows.js +12 -0
- package/dist/scan-guard.js +72 -0
- package/dist/sql-parts.js +32 -0
- package/dist/tenant.js +10 -0
- package/dist/transactions.js +14 -0
- package/dist/types.d.ts +97 -0
- package/dist/validate-body.js +73 -0
- package/dist/view-access.js +54 -0
- package/dist/view-related.js +59 -0
- package/dist/write-pipeline.js +94 -0
- package/dist/write-shared.js +52 -0
- package/dist/write-update.js +45 -0
- package/package.json +39 -3
- package/README.md +0 -3
package/dist/problem.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/problem.ts
|
|
2
|
+
/** An RFC 9457 problem. Throw it anywhere below a route; the central error handler renders it. */
|
|
3
|
+
var HttpProblem = class extends Error {
|
|
4
|
+
status;
|
|
5
|
+
slug;
|
|
6
|
+
title;
|
|
7
|
+
extras;
|
|
8
|
+
headers;
|
|
9
|
+
constructor(status, slug, title, detail, extras = {}, headers = {}) {
|
|
10
|
+
super(detail);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.slug = slug;
|
|
13
|
+
this.title = title;
|
|
14
|
+
this.extras = extras;
|
|
15
|
+
this.headers = headers;
|
|
16
|
+
}
|
|
17
|
+
get detail() {
|
|
18
|
+
return this.message;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const problemType = (slug) => `urn:protobase:problem:${slug}`;
|
|
22
|
+
const problemResponse = (problem, instance) => new Response(JSON.stringify({
|
|
23
|
+
type: problemType(problem.slug),
|
|
24
|
+
title: problem.title,
|
|
25
|
+
status: problem.status,
|
|
26
|
+
detail: problem.detail,
|
|
27
|
+
instance,
|
|
28
|
+
...problem.extras
|
|
29
|
+
}), {
|
|
30
|
+
status: problem.status,
|
|
31
|
+
headers: {
|
|
32
|
+
"content-type": "application/problem+json",
|
|
33
|
+
...problem.headers
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const badRequest = (slug, detail, extras) => new HttpProblem(400, slug, "Bad Request", detail, extras);
|
|
37
|
+
const unauthorized = (detail) => new HttpProblem(401, "unauthenticated", "Unauthorized", detail, {}, { "www-authenticate": "Bearer" });
|
|
38
|
+
const forbidden = (slug, detail) => new HttpProblem(403, slug, "Forbidden", detail);
|
|
39
|
+
const notFound = (detail) => new HttpProblem(404, "not-found", "Not Found", detail);
|
|
40
|
+
const preconditionFailed = (detail) => new HttpProblem(412, "precondition-failed", "Precondition Failed", detail);
|
|
41
|
+
const preconditionRequired = (detail) => new HttpProblem(428, "precondition-required", "Precondition Required", detail);
|
|
42
|
+
//#endregion
|
|
43
|
+
export { HttpProblem, badRequest, forbidden, notFound, preconditionFailed, preconditionRequired, problemResponse, problemType, unauthorized };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { notFound } from "./problem.js";
|
|
2
|
+
import { tenantScope } from "./tenant.js";
|
|
3
|
+
import { recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields } from "./request-access.js";
|
|
4
|
+
import { readTransaction } from "./transactions.js";
|
|
5
|
+
import { pickFields } from "./rows.js";
|
|
6
|
+
import { fetchRecord } from "./records.js";
|
|
7
|
+
import { rowPermissions } from "./row-permissions.js";
|
|
8
|
+
//#region src/read-service.ts
|
|
9
|
+
/** `edit` for fields the caller may change on this record, `read` for the rest (sensitive ones included); hidden fields are not listed at all. */
|
|
10
|
+
const fieldModes = (access, canUpdate) => Object.fromEntries(Object.keys(access.readable.fields).map((name) => [name, canUpdate && writableFields(access, "update").includes(name) ? "edit" : "read"]));
|
|
11
|
+
/**
|
|
12
|
+
* The row with this key if the caller may read it: inside their tenant and row filter, and passing a record-level read
|
|
13
|
+
* rule. Such a rule may look at fields the caller cannot see, so then the whole row is read; the caller cuts it down.
|
|
14
|
+
*/
|
|
15
|
+
const fetchReadable = async (trx, access, key, deleted, { fields, ...shape }) => {
|
|
16
|
+
const { entry } = access;
|
|
17
|
+
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
18
|
+
const recordRule = access.resolved.recordChecks.includes("read");
|
|
19
|
+
const filter = targetFilter(access, "read");
|
|
20
|
+
const current = await fetchRecord(trx, entry, key, tenant, {
|
|
21
|
+
deleted,
|
|
22
|
+
...filter && { filter },
|
|
23
|
+
...shape,
|
|
24
|
+
...!recordRule && fields && { fields }
|
|
25
|
+
});
|
|
26
|
+
if (!current || !recordRule) return current;
|
|
27
|
+
const decision = await recordDecision(access, "read", current.record);
|
|
28
|
+
if (decision === false) return void 0;
|
|
29
|
+
if (decision !== true && !await fetchRecord(trx, entry, key, tenant, {
|
|
30
|
+
deleted,
|
|
31
|
+
filter: decision,
|
|
32
|
+
fields: [access.full.fields[access.full.primaryKey[0]]]
|
|
33
|
+
})) return void 0;
|
|
34
|
+
return current;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The record with this key as the caller may see it: readable fields only, inside the caller's tenant and row filter.
|
|
38
|
+
* A key outside the tenant, outside the row filter, or failing a record-level read rule is a plain 404.
|
|
39
|
+
*/
|
|
40
|
+
const readRecord = async (deps, entry, session, key, deleted = "hide") => {
|
|
41
|
+
const access = await requestAccess(deps, entry, session);
|
|
42
|
+
requireOperation(access, "read");
|
|
43
|
+
const names = Object.keys(access.model.fields);
|
|
44
|
+
const found = await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
|
|
45
|
+
const current = await fetchReadable(trx, access, key, deleted, {
|
|
46
|
+
etagFields: names,
|
|
47
|
+
fields: visibleFields(access)
|
|
48
|
+
});
|
|
49
|
+
if (!current) return void 0;
|
|
50
|
+
const record = pickFields(current.record, names);
|
|
51
|
+
const [row] = await rowPermissions(trx, access, [record]);
|
|
52
|
+
return {
|
|
53
|
+
record,
|
|
54
|
+
etag: current.etag,
|
|
55
|
+
permissions: {
|
|
56
|
+
...row,
|
|
57
|
+
fields: fieldModes(access, row.update)
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
if (!found) throw notFound(`No ${entry.name} with this key`);
|
|
62
|
+
return found;
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { fetchReadable, fieldModes, readRecord };
|
package/dist/records.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
2
|
+
import { etagOf } from "./etag.js";
|
|
3
|
+
import { exposeRow, pickFields } from "./rows.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/records.ts
|
|
6
|
+
const stored = async (entry, raw, shape = {}) => {
|
|
7
|
+
if (!raw) return void 0;
|
|
8
|
+
const record = exposeRow(entry.model, raw);
|
|
9
|
+
return {
|
|
10
|
+
record,
|
|
11
|
+
etag: await etagOf(entry, raw, shape.etagFields ? pickFields(record, shape.etagFields) : record)
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
const rowsOf = async (db, query) => (await query.execute(db)).rows;
|
|
15
|
+
/** The row of the caller's tenant with this key (live unless `deleted` says otherwise), optionally narrowed by a filter. */
|
|
16
|
+
const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
17
|
+
const { filter, deleted, lock = false, ...shape } = options;
|
|
18
|
+
const conditions = [
|
|
19
|
+
...scopeConditions(entry.model, tenant, deleted),
|
|
20
|
+
...keyConditions(entry.model, key),
|
|
21
|
+
...filterCondition(entry.model, filter)
|
|
22
|
+
];
|
|
23
|
+
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lock ? sql` for update` : sql``}`;
|
|
24
|
+
return stored(entry, (await rowsOf(db, query))[0], shape);
|
|
25
|
+
};
|
|
26
|
+
const writeValue = (field, value) => field.type === "json" && value !== null ? JSON.stringify(value) : value;
|
|
27
|
+
const insertRecord = async (db, entry, values, shape = {}) => {
|
|
28
|
+
const fields = Object.keys(values).map((name) => entry.model.fields[name]);
|
|
29
|
+
const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => writeValue(field, values[field.name])))})`;
|
|
30
|
+
const query = sql`insert into ${tableId(entry.model)} ${body} returning ${returningList(entry, shape.fields)}`;
|
|
31
|
+
return await stored(entry, (await rowsOf(db, query))[0], shape);
|
|
32
|
+
};
|
|
33
|
+
const etagBump = (entry) => {
|
|
34
|
+
if (!entry.etag) return [];
|
|
35
|
+
const column = columnId(entry.etag.field);
|
|
36
|
+
return [entry.etag.kind === "version" ? sql`${column} = ${column} + 1` : sql`${column} = clock_timestamp()`];
|
|
37
|
+
};
|
|
38
|
+
const updateRecord = async (db, entry, key, tenant, values, shape = {}) => {
|
|
39
|
+
const assignments = [...Object.entries(values).map(([name, value]) => {
|
|
40
|
+
const field = entry.model.fields[name];
|
|
41
|
+
return sql`${columnId(field)} = ${writeValue(field, value)}`;
|
|
42
|
+
}), ...etagBump(entry)];
|
|
43
|
+
const conditions = [...scopeConditions(entry.model, tenant), ...keyConditions(entry.model, key)];
|
|
44
|
+
const query = sql`update ${tableId(entry.model)} as t set ${sql.join(assignments)}${whereClause(conditions)} returning ${returningList(entry, shape.fields)}`;
|
|
45
|
+
return await stored(entry, (await rowsOf(db, query))[0], shape);
|
|
46
|
+
};
|
|
47
|
+
const deleteRecord = async (db, entry, key, tenant, shape = {}) => {
|
|
48
|
+
const { model } = entry;
|
|
49
|
+
const conditions = [...scopeConditions(model, tenant), ...keyConditions(model, key)];
|
|
50
|
+
const softField = model.softDelete ? model.fields[model.softDelete] : void 0;
|
|
51
|
+
if (model.softDelete && !softField) throw new Error(`Soft delete field "${model.softDelete}" is not a field of "${model.name}"`);
|
|
52
|
+
const query = softField ? sql`update ${tableId(model)} as t set ${columnId(softField)} = clock_timestamp()${whereClause(conditions)} returning ${returningList(entry, shape.fields)}` : sql`delete from ${tableId(model)} as t${whereClause(conditions)} returning ${returningList(entry, shape.fields)}`;
|
|
53
|
+
return await stored(entry, (await rowsOf(db, query))[0], shape);
|
|
54
|
+
};
|
|
55
|
+
/** Clears the soft delete column of a deleted row. */
|
|
56
|
+
const restoreRecord = async (db, entry, key, tenant, shape = {}) => {
|
|
57
|
+
const { model } = entry;
|
|
58
|
+
const softField = model.softDelete ? model.fields[model.softDelete] : void 0;
|
|
59
|
+
if (!softField) throw new Error(`Resource "${model.name}" has no soft delete field to clear`);
|
|
60
|
+
const conditions = [...scopeConditions(model, tenant, "only"), ...keyConditions(model, key)];
|
|
61
|
+
const assignments = [sql`${columnId(softField)} = null`, ...etagBump(entry)];
|
|
62
|
+
const query = sql`update ${tableId(model)} as t set ${sql.join(assignments)}${whereClause(conditions)} returning ${returningList(entry, shape.fields)}`;
|
|
63
|
+
return await stored(entry, (await rowsOf(db, query))[0], shape);
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
export { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord };
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { keyTypes } from "@protobase/schema";
|
|
2
|
+
//#region src/registry.ts
|
|
3
|
+
const etagSource = (model) => {
|
|
4
|
+
const fields = Object.values(model.fields).filter((field) => field.readOnly);
|
|
5
|
+
const version = fields.find((field) => field.column === "version" && (field.type === "integer" || field.type === "bigint"));
|
|
6
|
+
if (version) return {
|
|
7
|
+
field: version,
|
|
8
|
+
kind: "version"
|
|
9
|
+
};
|
|
10
|
+
const updated = fields.find((field) => field.column === "updated_at" && field.type === "timestamp");
|
|
11
|
+
return updated && {
|
|
12
|
+
field: updated,
|
|
13
|
+
kind: "timestamp"
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
const buildRegistry = (resources) => {
|
|
17
|
+
const built = resources.map((source) => ({
|
|
18
|
+
source,
|
|
19
|
+
model: source.toModel()
|
|
20
|
+
}));
|
|
21
|
+
const models = new Map(built.map(({ model }) => [model.name, model]));
|
|
22
|
+
const entries = built.map(({ source, model }) => {
|
|
23
|
+
if (/[/:?#]/.test(model.name)) throw new Error(`Resource name "${model.name}" cannot be used in a URL path`);
|
|
24
|
+
if (Object.hasOwn(model.fields, "etag")) throw new Error(`Resource "${model.name}" has a field named "etag", which the API uses for the record version`);
|
|
25
|
+
const types = keyTypes(model, (name) => models.get(name));
|
|
26
|
+
if (types.length !== model.primaryKey.length) throw new Error(`Not implemented: resource "${model.name}" has a primary key field that points at a composite-key resource; the server maps one key part per key field`);
|
|
27
|
+
const etag = etagSource(model);
|
|
28
|
+
return {
|
|
29
|
+
name: model.name,
|
|
30
|
+
source,
|
|
31
|
+
model,
|
|
32
|
+
keyTypes: types,
|
|
33
|
+
...etag && { etag }
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
37
|
+
if (byName.size !== entries.length) throw new Error("Resource names must be unique");
|
|
38
|
+
return {
|
|
39
|
+
entries,
|
|
40
|
+
find: (name) => byName.get(name)
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { buildRegistry };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { forbidden, notFound } from "./problem.js";
|
|
2
|
+
import { checkedFilter } from "./filter-input.js";
|
|
3
|
+
import { tenantScope } from "./tenant.js";
|
|
4
|
+
import { checkFilter, resolveAccess, restrictModel } from "@protobase/schema";
|
|
5
|
+
//#region src/request-access.ts
|
|
6
|
+
const withoutSensitive = (model) => ({
|
|
7
|
+
...model,
|
|
8
|
+
fields: Object.fromEntries(Object.entries(model.fields).filter(([, field]) => !field.sensitive))
|
|
9
|
+
});
|
|
10
|
+
const requestAccess = async (deps, entry, session) => {
|
|
11
|
+
const resolved = await resolveAccess(entry.source, { user: {
|
|
12
|
+
id: session.user.id,
|
|
13
|
+
roles: session.user.roles
|
|
14
|
+
} }, deps.accessOptions);
|
|
15
|
+
const readable = restrictModel(entry.model, resolved);
|
|
16
|
+
return {
|
|
17
|
+
entry,
|
|
18
|
+
session,
|
|
19
|
+
resolved,
|
|
20
|
+
full: entry.model,
|
|
21
|
+
readable,
|
|
22
|
+
model: withoutSensitive(readable),
|
|
23
|
+
deps
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const hasNoAccess = (resolved) => !Object.values(resolved.operations).some(Boolean);
|
|
27
|
+
/** 403 when the operation is denied; a resource the user can do nothing with does not exist for them. */
|
|
28
|
+
const requireOperation = (access, operation) => {
|
|
29
|
+
if (access.resolved.operations[operation]) return;
|
|
30
|
+
if (hasNoAccess(access.resolved)) throw notFound(`There is no resource "${access.entry.name}"`);
|
|
31
|
+
throw forbidden("access-denied", `You may not ${operation} ${access.entry.name}`);
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Fields the caller may write on create or update. A field they cannot read is never writable, even when its write rule
|
|
35
|
+
* allows it (nobody writes blind; a sensitive field counts as readable); the tenant column is always set by the server.
|
|
36
|
+
*/
|
|
37
|
+
const writableFields = (access, mode) => access.resolved.writableFields[mode].filter((name) => Object.hasOwn(access.readable.fields, name) && name !== access.full.tenant);
|
|
38
|
+
const visibleFields = (access) => Object.values(access.model.fields);
|
|
39
|
+
const andFilters = (filters) => {
|
|
40
|
+
const present = filters.filter((filter) => filter !== void 0);
|
|
41
|
+
if (present.length < 2) return present[0];
|
|
42
|
+
return {
|
|
43
|
+
kind: "and",
|
|
44
|
+
args: present,
|
|
45
|
+
span: {
|
|
46
|
+
start: 0,
|
|
47
|
+
end: 0
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** The row filter that narrows which rows an operation may target: the read filter, plus the operation's own. */
|
|
52
|
+
const targetFilter = (access, operation) => operation === "read" ? access.resolved.rowFilter.read : andFilters([access.resolved.rowFilter.read, access.resolved.rowFilter[operation]]);
|
|
53
|
+
/** The query-layer scope for reads: tenant, the access row filter against the full model, soft delete from the full model. */
|
|
54
|
+
const readScope = (access, fullModel = access.full) => {
|
|
55
|
+
const tenant = tenantScope(access.entry, access.session);
|
|
56
|
+
const rowFilter = access.resolved.rowFilter.read;
|
|
57
|
+
return {
|
|
58
|
+
...tenant && { tenantValue: tenant.tenantValue },
|
|
59
|
+
...rowFilter && { rowFilter },
|
|
60
|
+
fullModel
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const trusted = (model) => ({
|
|
64
|
+
...model,
|
|
65
|
+
fields: Object.fromEntries(Object.entries(model.fields).map(([name, field]) => [name, {
|
|
66
|
+
...field,
|
|
67
|
+
filterable: true
|
|
68
|
+
}]))
|
|
69
|
+
});
|
|
70
|
+
const asFilter = (access, expr, operation) => {
|
|
71
|
+
if (typeof expr === "string") return checkedFilter(trusted(access.full), expr);
|
|
72
|
+
const result = checkFilter(trusted(access.full), expr);
|
|
73
|
+
if (!result.ok) throw new Error(`The ${operation} access rule of "${access.entry.name}" returned an invalid filter: ${result.errors[0].message}`);
|
|
74
|
+
return result.filter;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The record-level rule of an operation, run with the stored record (and the input of a write). Returns a verdict, or a
|
|
78
|
+
* filter that the caller checks in the database, so its semantics match reads exactly.
|
|
79
|
+
*/
|
|
80
|
+
const recordDecision = async (access, operation, record, input) => {
|
|
81
|
+
const { entry, deps, full, session } = access;
|
|
82
|
+
const explicit = entry.source.accessRules[operation] ?? (operation === "list" ? entry.source.accessRules.read : void 0);
|
|
83
|
+
const fallback = deps.accessOptions.defaultAccess ? () => deps.accessOptions.defaultAccess === "allow" : deps.accessOptions.roles ? deps.accessOptions.roles.can(`${entry.name}.${operation === "list" ? "read" : operation}`) : () => true;
|
|
84
|
+
const result = await (explicit ?? fallback)({
|
|
85
|
+
user: {
|
|
86
|
+
id: session.user.id,
|
|
87
|
+
roles: session.user.roles
|
|
88
|
+
},
|
|
89
|
+
model: full,
|
|
90
|
+
operation,
|
|
91
|
+
record
|
|
92
|
+
}, record, input);
|
|
93
|
+
if (typeof result === "boolean") return result;
|
|
94
|
+
return asFilter(access, result, operation);
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { andFilters, readScope, recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { PageDefinition } from "@protobase/layout";
|
|
2
|
+
import { AccessSource, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/resource-source.d.ts
|
|
5
|
+
type FieldSource = {
|
|
6
|
+
schema: z.ZodType;
|
|
7
|
+
meta: {
|
|
8
|
+
readOnly: boolean;
|
|
9
|
+
nullable: boolean;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/** What the server needs from a resource builder; every `resource(...)` chain satisfies it. */
|
|
13
|
+
export type ResourceSource = AccessSource & {
|
|
14
|
+
readonly state: {
|
|
15
|
+
fields?: Record<string, FieldSource | null>;
|
|
16
|
+
};
|
|
17
|
+
readonly validators: readonly ((record: any) => Array<{
|
|
18
|
+
field: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}> | undefined)[];
|
|
21
|
+
recordSchema(): z.ZodType;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Splits a config module (`import * as config from './config'`) into its resources, views, pages and, when it exports
|
|
25
|
+
* one `userMenu(...)`, the user menu.
|
|
26
|
+
*/
|
|
27
|
+
export declare const configExports: (module: Record<string, unknown>) => {
|
|
28
|
+
userMenu?: UserMenuSource | undefined;
|
|
29
|
+
resources: ResourceSource[];
|
|
30
|
+
views: Array<{
|
|
31
|
+
toModel(): ViewModel;
|
|
32
|
+
}>;
|
|
33
|
+
pages: PageDefinition[];
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isPageDefinition } from "@protobase/layout";
|
|
2
|
+
//#region src/resource-source.ts
|
|
3
|
+
/**
|
|
4
|
+
* Splits a config module (`import * as config from './config'`) into its resources, views, pages and, when it exports
|
|
5
|
+
* one `userMenu(...)`, the user menu.
|
|
6
|
+
*/
|
|
7
|
+
const configExports = (module) => {
|
|
8
|
+
const values = Object.values(module);
|
|
9
|
+
const pages = values.filter(isPageDefinition);
|
|
10
|
+
const others = values.filter((value) => !isPageDefinition(value));
|
|
11
|
+
const buildable = others.filter((value) => typeof value?.toModel === "function");
|
|
12
|
+
const menus = others.filter((value) => typeof value?.toUserMenuModel === "function");
|
|
13
|
+
if (menus.length > 1) throw new Error(`The config module exports ${menus.length} user menus; export one userMenu(...)`);
|
|
14
|
+
return {
|
|
15
|
+
resources: buildable.filter((value) => typeof value.recordSchema === "function"),
|
|
16
|
+
views: buildable.filter((value) => typeof value.recordSchema !== "function"),
|
|
17
|
+
pages,
|
|
18
|
+
...menus[0] && { userMenu: menus[0] }
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { configExports };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { badRequest, notFound } from "./problem.js";
|
|
2
|
+
import { tenantScope } from "./tenant.js";
|
|
3
|
+
import { requestAccess, requireOperation } from "./request-access.js";
|
|
4
|
+
import { readTransaction } from "./transactions.js";
|
|
5
|
+
import { fetchReadable } from "./read-service.js";
|
|
6
|
+
import { encodeKey } from "@protobase/schema";
|
|
7
|
+
//#region src/reveal-service.ts
|
|
8
|
+
/**
|
|
9
|
+
* The value of one sensitive field of one record, for a caller who may read the record and the field: the same tenant,
|
|
10
|
+
* row filter and record-level rule as a get. The reveal is published to the audit queue before the value is returned;
|
|
11
|
+
* when publishing fails, so does the reveal.
|
|
12
|
+
*/
|
|
13
|
+
const revealField = async (deps, entry, session, request) => {
|
|
14
|
+
const access = await requestAccess(deps, entry, session);
|
|
15
|
+
requireOperation(access, "read");
|
|
16
|
+
const name = typeof request.field === "string" ? request.field : "";
|
|
17
|
+
const field = Object.hasOwn(access.readable.fields, name) ? access.readable.fields[name] : void 0;
|
|
18
|
+
if (!field) throw badRequest("invalid-parameter", `Unknown field "${name}"`);
|
|
19
|
+
if (!field.sensitive) throw badRequest("invalid-parameter", `"${name}" is not sensitive; it comes with the record`);
|
|
20
|
+
const keyFields = access.full.primaryKey.map((part) => access.full.fields[part]);
|
|
21
|
+
const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, access.full.fields[name]] }));
|
|
22
|
+
if (!found) throw notFound(`No ${entry.name} with this key`);
|
|
23
|
+
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
24
|
+
await deps.audit.publish({
|
|
25
|
+
type: "field.revealed",
|
|
26
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
27
|
+
actor: {
|
|
28
|
+
id: session.user.id,
|
|
29
|
+
roles: [...session.user.roles]
|
|
30
|
+
},
|
|
31
|
+
...tenant !== void 0 && { tenant },
|
|
32
|
+
resource: entry.name,
|
|
33
|
+
recordKey: encodeKey(request.key),
|
|
34
|
+
field: name,
|
|
35
|
+
origin: request.origin
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
field: name,
|
|
39
|
+
value: found.record[name] ?? null
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { revealField };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { batchBody } from "../batch/schema.js";
|
|
2
|
+
import { runBatch } from "../batch/run-batch.js";
|
|
3
|
+
import { parseParams } from "../params.js";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
//#region src/routes/batch.ts
|
|
6
|
+
/**
|
|
7
|
+
* `POST /api/v1:batchWrite`. It sits beside the API's own app, not inside it: the method name is glued to the version
|
|
8
|
+
* segment, so the route is registered on the root with the full path and authenticates itself.
|
|
9
|
+
*/
|
|
10
|
+
const batchRoute = (deps, authenticate, meta) => {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.post(`${deps.basePath}:batchWrite`, async (c) => {
|
|
13
|
+
const session = await authenticate(c.req.raw);
|
|
14
|
+
const { ops } = parseParams(batchBody, await c.req.json());
|
|
15
|
+
const results = await runBatch(deps, session, ops);
|
|
16
|
+
return c.json({ results }, 200, { "X-Meta-Version": await meta.version(session.user.roles) });
|
|
17
|
+
});
|
|
18
|
+
return app;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { batchRoute };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { runWrite } from "../write-pipeline.js";
|
|
2
|
+
import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
|
|
3
|
+
import { listRecords } from "../list-service.js";
|
|
4
|
+
import { returnPreference } from "./preferences.js";
|
|
5
|
+
import { resolveTarget, unknownAction } from "./target.js";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import { encodeKey } from "@protobase/schema";
|
|
8
|
+
//#region src/routes/collection.ts
|
|
9
|
+
/** Filters in a JSON body may be much longer than in a URL. */
|
|
10
|
+
const searchFilterLength = 2e5;
|
|
11
|
+
const warningHeader = (warning) => warning ? { "X-Protobase-Warning": warning } : {};
|
|
12
|
+
const collectionRoutes = (deps) => {
|
|
13
|
+
const app = new Hono();
|
|
14
|
+
app.get("/:target", async (c) => {
|
|
15
|
+
const { entry, action } = resolveTarget(deps, c.req.param("target"));
|
|
16
|
+
const session = c.get("session");
|
|
17
|
+
const query = c.req.query();
|
|
18
|
+
if (action === "") {
|
|
19
|
+
const { body, warning } = await listRecords(deps, entry, session, query);
|
|
20
|
+
return c.json(body, 200, warningHeader(warning));
|
|
21
|
+
}
|
|
22
|
+
if (action === "facets") return c.json(await facets(deps, entry, session, query));
|
|
23
|
+
if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
|
|
24
|
+
if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
|
|
25
|
+
if (action === "seek") {
|
|
26
|
+
const { body, warning } = await listRecords(deps, entry, session, query, void 0, true);
|
|
27
|
+
return c.json(body, 200, warningHeader(warning));
|
|
28
|
+
}
|
|
29
|
+
return unknownAction(action);
|
|
30
|
+
});
|
|
31
|
+
app.post("/:target", async (c) => {
|
|
32
|
+
const { entry, action } = resolveTarget(deps, c.req.param("target"));
|
|
33
|
+
const session = c.get("session");
|
|
34
|
+
const body = await c.req.json();
|
|
35
|
+
if (action === "search") {
|
|
36
|
+
const { body: page, warning } = await listRecords(deps, entry, session, body, searchFilterLength);
|
|
37
|
+
return c.json(page, 200, warningHeader(warning));
|
|
38
|
+
}
|
|
39
|
+
if (action !== "") return unknownAction(action);
|
|
40
|
+
const created = await runWrite(deps, entry, session, {
|
|
41
|
+
operation: "create",
|
|
42
|
+
body
|
|
43
|
+
});
|
|
44
|
+
const key = entry.model.primaryKey.map((name) => created.record[name]);
|
|
45
|
+
const location = `${deps.basePath}/${entry.name}/${key.length === 1 ? encodeURIComponent(String(key[0])) : encodeKey(key)}`;
|
|
46
|
+
const { minimal, applied } = returnPreference(c);
|
|
47
|
+
const headers = {
|
|
48
|
+
Location: location,
|
|
49
|
+
ETag: created.etag,
|
|
50
|
+
...applied
|
|
51
|
+
};
|
|
52
|
+
return minimal ? new Response(null, {
|
|
53
|
+
status: 201,
|
|
54
|
+
headers
|
|
55
|
+
}) : c.json({
|
|
56
|
+
...created.record,
|
|
57
|
+
etag: created.etag
|
|
58
|
+
}, 201, headers);
|
|
59
|
+
});
|
|
60
|
+
return app;
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { collectionRoutes };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { requestAccess } from "../request-access.js";
|
|
2
|
+
import { openApiDocument } from "../openapi/document.js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { Scalar } from "@scalar/hono-api-reference";
|
|
5
|
+
//#region src/routes/docs.ts
|
|
6
|
+
/** The OpenAPI document and the Scalar reference. Both need a token: the document lists what the caller may see and do. */
|
|
7
|
+
const docsRoutes = (deps) => {
|
|
8
|
+
const app = new Hono();
|
|
9
|
+
app.get("/openapi.json", async (c) => {
|
|
10
|
+
const visible = (await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, c.get("session"))))).filter((access) => Object.values(access.resolved.operations).some(Boolean));
|
|
11
|
+
return c.json(openApiDocument(visible, deps.basePath, deps.systemPath));
|
|
12
|
+
});
|
|
13
|
+
app.get("/docs", Scalar({
|
|
14
|
+
url: `${deps.systemPath}/openapi.json`,
|
|
15
|
+
pageTitle: "Protobase API"
|
|
16
|
+
}));
|
|
17
|
+
return app;
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { docsRoutes };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ifNoneMatchSatisfied } from "../etag.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
//#region src/routes/meta.ts
|
|
4
|
+
const metaRoutes = (meta) => {
|
|
5
|
+
const app = new Hono();
|
|
6
|
+
app.get("/meta", async (c) => {
|
|
7
|
+
const { body, etag } = await meta.forCaller(c.get("session"));
|
|
8
|
+
const headers = {
|
|
9
|
+
ETag: etag,
|
|
10
|
+
"Cache-Control": "no-cache"
|
|
11
|
+
};
|
|
12
|
+
const given = c.req.header("if-none-match");
|
|
13
|
+
if (given && ifNoneMatchSatisfied(given, etag)) return new Response(null, {
|
|
14
|
+
status: 304,
|
|
15
|
+
headers
|
|
16
|
+
});
|
|
17
|
+
return c.json(body, 200, headers);
|
|
18
|
+
});
|
|
19
|
+
return app;
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { metaRoutes };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/routes/preferences.ts
|
|
2
|
+
/** RFC 7240 `Prefer: return=minimal | representation`; the representation is the default. */
|
|
3
|
+
const returnPreference = (c) => {
|
|
4
|
+
const match = /return=(minimal|representation)/.exec(c.req.header("prefer") ?? "");
|
|
5
|
+
const applied = match ? { "Preference-Applied": match[0] } : {};
|
|
6
|
+
return {
|
|
7
|
+
minimal: match?.[1] === "minimal",
|
|
8
|
+
applied
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { returnPreference };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
|
+
import { pickFields } from "../rows.js";
|
|
3
|
+
import { runWrite } from "../write-pipeline.js";
|
|
4
|
+
import { keyFromPath } from "../key.js";
|
|
5
|
+
import { deletedRows, selectedFields } from "../params.js";
|
|
6
|
+
import { returnPreference } from "./preferences.js";
|
|
7
|
+
import { resolveTarget } from "./target.js";
|
|
8
|
+
import { readRecord } from "../read-service.js";
|
|
9
|
+
import { revealField } from "../reveal-service.js";
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
//#region src/routes/record.ts
|
|
12
|
+
const headerValue = (name, value) => value === void 0 ? {} : { [name]: value };
|
|
13
|
+
const recordRoutes = (deps) => {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
app.get("/:resource/:key", async (c) => {
|
|
16
|
+
const { entry } = resolveTarget(deps, c.req.param("resource"));
|
|
17
|
+
const deleted = deletedRows(entry.model, c.req.query("show_deleted") === "true");
|
|
18
|
+
const found = await readRecord(deps, entry, c.get("session"), keyFromPath(c, entry), deleted);
|
|
19
|
+
const columns = selectedFields({
|
|
20
|
+
...entry.model,
|
|
21
|
+
fields: Object.fromEntries(Object.keys(found.record).map((name) => [name, entry.model.fields[name]]))
|
|
22
|
+
}, c.req.query("fields"));
|
|
23
|
+
return c.json({
|
|
24
|
+
...pickFields(found.record, columns),
|
|
25
|
+
etag: found.etag,
|
|
26
|
+
permissions: found.permissions
|
|
27
|
+
}, 200, { ETag: found.etag });
|
|
28
|
+
});
|
|
29
|
+
const reveal = async (c, entry) => {
|
|
30
|
+
const body = await c.req.json();
|
|
31
|
+
const revealed = await revealField(deps, entry, c.get("session"), {
|
|
32
|
+
key: keyFromPath(c, entry, ":reveal"),
|
|
33
|
+
field: typeof body === "object" && body !== null ? body.field : void 0,
|
|
34
|
+
origin: {
|
|
35
|
+
...headerValue("userAgent", c.req.header("user-agent")),
|
|
36
|
+
...headerValue("forwardedFor", c.req.header("x-forwarded-for"))
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return c.json(revealed, 200, { "Cache-Control": "no-store" });
|
|
40
|
+
};
|
|
41
|
+
app.post("/:resource/:key", async (c) => {
|
|
42
|
+
const { entry } = resolveTarget(deps, c.req.param("resource"));
|
|
43
|
+
if (c.req.param("key").endsWith(":reveal")) return reveal(c, entry);
|
|
44
|
+
if (!c.req.param("key").endsWith(":undelete")) throw notFound("There is no such method");
|
|
45
|
+
const restored = await runWrite(deps, entry, c.get("session"), {
|
|
46
|
+
operation: "undelete",
|
|
47
|
+
key: keyFromPath(c, entry, ":undelete"),
|
|
48
|
+
ifMatch: c.req.header("if-match") ?? null
|
|
49
|
+
});
|
|
50
|
+
const { minimal, applied } = returnPreference(c);
|
|
51
|
+
const headers = {
|
|
52
|
+
ETag: restored.etag,
|
|
53
|
+
...applied
|
|
54
|
+
};
|
|
55
|
+
return minimal ? new Response(null, {
|
|
56
|
+
status: 204,
|
|
57
|
+
headers
|
|
58
|
+
}) : c.json({
|
|
59
|
+
...restored.record,
|
|
60
|
+
etag: restored.etag
|
|
61
|
+
}, 200, headers);
|
|
62
|
+
});
|
|
63
|
+
app.patch("/:resource/:key", async (c) => {
|
|
64
|
+
const { entry } = resolveTarget(deps, c.req.param("resource"));
|
|
65
|
+
const updated = await runWrite(deps, entry, c.get("session"), {
|
|
66
|
+
operation: "update",
|
|
67
|
+
key: keyFromPath(c, entry),
|
|
68
|
+
body: await c.req.json(),
|
|
69
|
+
ifMatch: c.req.header("if-match") ?? null
|
|
70
|
+
});
|
|
71
|
+
const { minimal, applied } = returnPreference(c);
|
|
72
|
+
const headers = {
|
|
73
|
+
ETag: updated.etag,
|
|
74
|
+
...applied
|
|
75
|
+
};
|
|
76
|
+
return minimal ? new Response(null, {
|
|
77
|
+
status: 204,
|
|
78
|
+
headers
|
|
79
|
+
}) : c.json({
|
|
80
|
+
...updated.record,
|
|
81
|
+
etag: updated.etag
|
|
82
|
+
}, 200, headers);
|
|
83
|
+
});
|
|
84
|
+
app.delete("/:resource/:key", async (c) => {
|
|
85
|
+
const { entry } = resolveTarget(deps, c.req.param("resource"));
|
|
86
|
+
const deleted = await runWrite(deps, entry, c.get("session"), {
|
|
87
|
+
operation: "delete",
|
|
88
|
+
key: keyFromPath(c, entry),
|
|
89
|
+
ifMatch: c.req.header("if-match") ?? null
|
|
90
|
+
});
|
|
91
|
+
const preference = c.req.header("prefer") ?? "";
|
|
92
|
+
return /return=representation/.test(preference) ? c.json(deleted.record, 200, { "Preference-Applied": "return=representation" }) : new Response(null, { status: 204 });
|
|
93
|
+
});
|
|
94
|
+
return app;
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { recordRoutes };
|