@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.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/dist/aggregate-service.js +135 -0
  3. package/dist/audit/console-queue.d.ts +5 -0
  4. package/dist/audit/console-queue.js +5 -0
  5. package/dist/auth/jwt.d.ts +21 -0
  6. package/dist/auth/jwt.js +49 -0
  7. package/dist/batch/refs.js +31 -0
  8. package/dist/batch/reorder.js +79 -0
  9. package/dist/batch/run-batch.js +105 -0
  10. package/dist/batch/schema.js +51 -0
  11. package/dist/better-auth/authenticator.d.ts +22 -0
  12. package/dist/better-auth/authenticator.js +21 -0
  13. package/dist/better-auth/create-auth.d.ts +1244 -0
  14. package/dist/better-auth/create-auth.js +93 -0
  15. package/dist/better-auth/jwks-cache.d.ts +9 -0
  16. package/dist/better-auth/jwks-cache.js +31 -0
  17. package/dist/better-auth/password-reset.js +32 -0
  18. package/dist/better-auth/status-route.js +17 -0
  19. package/dist/better-auth/tokens.d.ts +18 -0
  20. package/dist/better-auth/tokens.js +37 -0
  21. package/dist/better-auth/users.d.ts +50 -0
  22. package/dist/better-auth/users.js +111 -0
  23. package/dist/check-shell.js +17 -0
  24. package/dist/check-views.js +45 -0
  25. package/dist/create-admin.d.ts +23 -0
  26. package/dist/create-admin.js +85 -0
  27. package/dist/error-handler.js +44 -0
  28. package/dist/etag.js +22 -0
  29. package/dist/filter-input.js +28 -0
  30. package/dist/filter-shape.js +25 -0
  31. package/dist/hash.js +10 -0
  32. package/dist/index.d.ts +11 -0
  33. package/dist/index.js +10 -0
  34. package/dist/key.js +22 -0
  35. package/dist/list-service.js +96 -0
  36. package/dist/mail/mail-settings.js +26 -0
  37. package/dist/mail/smtp-mailer.d.ts +11 -0
  38. package/dist/mail/smtp-mailer.js +17 -0
  39. package/dist/meta.js +63 -0
  40. package/dist/openapi/components.js +331 -0
  41. package/dist/openapi/document.js +74 -0
  42. package/dist/openapi/naming.js +4 -0
  43. package/dist/openapi/resource-paths.js +417 -0
  44. package/dist/openapi/schemas.js +93 -0
  45. package/dist/params.js +48 -0
  46. package/dist/problem.d.ts +17 -0
  47. package/dist/problem.js +43 -0
  48. package/dist/read-service.js +65 -0
  49. package/dist/records.js +66 -0
  50. package/dist/registry.js +44 -0
  51. package/dist/request-access.js +97 -0
  52. package/dist/resource-source.d.ts +35 -0
  53. package/dist/resource-source.js +22 -0
  54. package/dist/reveal-service.js +43 -0
  55. package/dist/routes/batch.js +21 -0
  56. package/dist/routes/collection.js +63 -0
  57. package/dist/routes/docs.js +20 -0
  58. package/dist/routes/meta.js +22 -0
  59. package/dist/routes/preferences.js +12 -0
  60. package/dist/routes/record.js +97 -0
  61. package/dist/routes/target.js +18 -0
  62. package/dist/row-permissions.js +69 -0
  63. package/dist/rows.js +12 -0
  64. package/dist/scan-guard.js +72 -0
  65. package/dist/sql-parts.js +32 -0
  66. package/dist/tenant.js +10 -0
  67. package/dist/transactions.js +14 -0
  68. package/dist/types.d.ts +97 -0
  69. package/dist/validate-body.js +73 -0
  70. package/dist/view-access.js +54 -0
  71. package/dist/view-related.js +59 -0
  72. package/dist/write-pipeline.js +94 -0
  73. package/dist/write-shared.js +52 -0
  74. package/dist/write-update.js +45 -0
  75. package/package.json +39 -3
  76. package/README.md +0 -3
@@ -0,0 +1,18 @@
1
+ import { notFound } from "../problem.js";
2
+ //#region src/routes/target.ts
3
+ /** `orders` is the collection; `orders:facets` is a custom method (AIP-136). Resource names never contain a colon. */
4
+ const resolveTarget = (deps, target) => {
5
+ const colon = target.indexOf(":");
6
+ const name = colon < 0 ? target : target.slice(0, colon);
7
+ const entry = deps.registry.find(name);
8
+ if (!entry) throw notFound(`There is no resource "${name}"`);
9
+ return {
10
+ entry,
11
+ action: colon < 0 ? "" : target.slice(colon + 1)
12
+ };
13
+ };
14
+ const unknownAction = (action) => {
15
+ throw notFound(`There is no method ":${action}"`);
16
+ };
17
+ //#endregion
18
+ export { resolveTarget, unknownAction };
@@ -0,0 +1,69 @@
1
+ import { tenantScope } from "./tenant.js";
2
+ import { recordDecision } from "./request-access.js";
3
+ import { aliasedColumn, columnId, scopeConditions, tableId, whereClause } from "./sql-parts.js";
4
+ import { fetchRecord } from "./records.js";
5
+ import { compileFilter } from "@protobase/query";
6
+ import { sql } from "kysely";
7
+ //#region src/row-permissions.ts
8
+ const keyOf = (access, row) => JSON.stringify(access.full.primaryKey.map((name) => String(row[name])));
9
+ const matching = async (trx, access, rows, filter) => {
10
+ const { full } = access;
11
+ const keyFields = full.primaryKey.map((name) => full.fields[name]);
12
+ const tuples = rows.map((row) => sql`(${sql.join(keyFields.map((field) => row[field.name]))})`);
13
+ const tenant = tenantScope(access.entry, access.session)?.tenantValue;
14
+ const conditions = [
15
+ ...scopeConditions(full, tenant),
16
+ compileFilter(full, filter),
17
+ sql`(${sql.join(keyFields.map(columnId))}) in (${sql.join(tuples)})`
18
+ ];
19
+ const found = await sql`select ${sql.join(keyFields.map(aliasedColumn))} from ${tableId(full)} as t${whereClause(conditions)}`.execute(trx);
20
+ return new Set(found.rows.map((row) => keyOf(access, row)));
21
+ };
22
+ /**
23
+ * What the caller may do with each of these (exposed) rows: the operation must be allowed, the row must match the
24
+ * operation's row filter (checked in the database), and a record-level rule must pass for the stored record. This only
25
+ * informs the UI; every write is checked again by the write pipeline.
26
+ */
27
+ const rowPermissions = async (trx, access, rows) => {
28
+ const { resolved, entry, session } = access;
29
+ const result = rows.map(() => ({
30
+ update: true,
31
+ delete: true
32
+ }));
33
+ if (rows.length === 0) return result;
34
+ for (const operation of ["update", "delete"]) {
35
+ if (!resolved.operations[operation]) {
36
+ result.forEach((permissions) => {
37
+ permissions[operation] = false;
38
+ });
39
+ continue;
40
+ }
41
+ const filter = resolved.rowFilter[operation];
42
+ if (filter) {
43
+ const allowed = await matching(trx, access, rows, filter);
44
+ rows.forEach((row, i) => {
45
+ if (!allowed.has(keyOf(access, row))) result[i][operation] = false;
46
+ });
47
+ }
48
+ if (!resolved.recordChecks.includes(operation)) continue;
49
+ const tenant = tenantScope(entry, session)?.tenantValue;
50
+ for (const [i, row] of rows.entries()) {
51
+ if (!result[i][operation]) continue;
52
+ const key = access.full.primaryKey.map((name) => row[name]);
53
+ const stored = await fetchRecord(trx, entry, key, tenant);
54
+ if (!stored) {
55
+ result[i][operation] = false;
56
+ continue;
57
+ }
58
+ const decision = await recordDecision(access, operation, stored.record);
59
+ if (decision === false) {
60
+ result[i][operation] = false;
61
+ continue;
62
+ }
63
+ if (decision !== true) result[i][operation] = (await matching(trx, access, [row], decision)).size > 0;
64
+ }
65
+ }
66
+ return result;
67
+ };
68
+ //#endregion
69
+ export { rowPermissions };
package/dist/rows.js ADDED
@@ -0,0 +1,12 @@
1
+ //#region src/rows.ts
2
+ const exposeValue = (type, value) => {
3
+ if (typeof value === "bigint") return value.toString();
4
+ if (!(value instanceof Date)) return value;
5
+ const iso = value.toISOString();
6
+ return type === "date" ? iso.slice(0, 10) : iso;
7
+ };
8
+ /** A row as the API shows it: dates as ISO text, 64-bit integers as strings. */
9
+ const exposeRow = (model, row) => Object.fromEntries(Object.entries(row).filter(([name]) => Object.hasOwn(model.fields, name)).map(([name, value]) => [name, exposeValue(model.fields[name].type, value)]));
10
+ const pickFields = (row, names) => names ? Object.fromEntries(names.map((name) => [name, row[name]])) : row;
11
+ //#endregion
12
+ export { exposeRow, pickFields };
@@ -0,0 +1,72 @@
1
+ import { HttpProblem } from "./problem.js";
2
+ import { scopeConditions, tableId } from "./sql-parts.js";
3
+ import { filterFields, filterShape } from "./filter-shape.js";
4
+ import { buildListQuery, compileFilter, explainGuard } from "@protobase/query";
5
+ import { sql } from "kysely";
6
+ //#region src/scan-guard.ts
7
+ const maxCached = 1e3;
8
+ const filteredQuery = (db, entry, shape, { scope }) => {
9
+ const { model } = entry;
10
+ const conditions = [
11
+ ...scopeConditions(scope.fullModel ?? model, scope.tenantValue),
12
+ ...scope.rowFilter ? [compileFilter(model, scope.rowFilter)] : [],
13
+ ...shape.filter ? [compileFilter(model, shape.filter)] : []
14
+ ];
15
+ let query = db.selectFrom(sql`${tableId(model)}`.as("t")).select(sql.lit(1).as("one"));
16
+ for (const condition of conditions) query = query.where(condition);
17
+ return query;
18
+ };
19
+ const pagedQuery = async (db, shape, { model, scope }) => {
20
+ const { query } = await buildListQuery(db, model, shape, scope);
21
+ return query;
22
+ };
23
+ const indexStatement = (entry, fields) => {
24
+ const { schema, name } = entry.model.table;
25
+ const table = schema ? `"${schema}"."${name}"` : `"${name}"`;
26
+ const columns = [...new Set(fields.map((field) => field.column))];
27
+ if (columns.length === 0) return "an index that covers the filter your role is limited by";
28
+ return `create index on ${table} (${columns.map((column) => `"${column}"`).join(", ")})`;
29
+ };
30
+ /** Plans each list query shape once (EXPLAIN, never executed) and remembers the verdict. */
31
+ const createScanGuard = (db, options = {}) => {
32
+ const { mode = "reject", seqScanRows } = options;
33
+ const verdicts = /* @__PURE__ */ new Map();
34
+ const thresholds = {
35
+ ...seqScanRows && { seqScanRows },
36
+ sortRows: Number.MAX_SAFE_INTEGER
37
+ };
38
+ const judge = async (entry, shape, context) => {
39
+ const filtered = shape.filter ? await explainGuard(db, filteredQuery(db, entry, shape, context), thresholds) : void 0;
40
+ const paged = await explainGuard(db, await pagedQuery(db, shape, context), thresholds);
41
+ const sortField = shape.sort?.[0] && entry.model.fields[shape.sort[0][0]];
42
+ const suggestions = [...filtered && !filtered.ok ? [indexStatement(entry, filterFields(shape.filter))] : [], ...!paged.ok && sortField ? [indexStatement(entry, [sortField])] : []];
43
+ return {
44
+ findings: [...filtered?.findings ?? [], ...paged.findings],
45
+ suggestion: suggestions[0] ?? indexStatement(entry, filterFields(shape.filter))
46
+ };
47
+ };
48
+ const verdictFor = async (entry, shape, context) => {
49
+ const key = `${entry.name}|${filterShape(context.scope.rowFilter)}|${filterShape(shape.filter)}|${(shape.sort ?? []).map(([name, direction]) => `${name}:${direction}`).join(",")}`;
50
+ const known = verdicts.get(key);
51
+ if (known) return known;
52
+ const verdict = await judge(entry, shape, context);
53
+ if (verdicts.size >= maxCached) verdicts.delete(verdicts.keys().next().value);
54
+ verdicts.set(key, verdict);
55
+ return verdict;
56
+ };
57
+ /** Throws a 400 problem for an expensive shape in `reject` mode; in `warn` mode returns the warning text. */
58
+ return async (entry, shape, context) => {
59
+ if (mode === "off") return void 0;
60
+ const { findings, suggestion } = await verdictFor(entry, shape, context);
61
+ const finding = findings[0];
62
+ if (!finding) return void 0;
63
+ const detail = `This query would sequentially scan "${finding.relation}" (about ${finding.rows} rows). Add an index, for example: ${suggestion}`;
64
+ if (mode === "warn") return detail;
65
+ throw new HttpProblem(400, "expensive-query", "Bad Request", detail, {
66
+ findings,
67
+ suggestion
68
+ });
69
+ };
70
+ };
71
+ //#endregion
72
+ export { createScanGuard };
@@ -0,0 +1,32 @@
1
+ import { compileFilter } from "@protobase/query";
2
+ import { sql } from "kysely";
3
+ //#region src/sql-parts.ts
4
+ const etagAlias = "__pb_etag";
5
+ const tableId = (model) => {
6
+ const { schema, name } = model.table;
7
+ return schema ? sql.id(schema, name) : sql.id(name);
8
+ };
9
+ const columnId = (field) => sql`${sql.id(field.column)}`;
10
+ const aliasedColumn = (field) => sql`${columnId(field)} as ${sql.id(field.name)}`;
11
+ /** `column as "field"` for the given fields (default: all), plus the ETag source column as exact text when the table has one. */
12
+ const returningList = (entry, fields = Object.values(entry.model.fields)) => {
13
+ const columns = fields.map(aliasedColumn);
14
+ if (entry.etag) columns.push(sql`${columnId(entry.etag.field)}::text as ${sql.id(etagAlias)}`);
15
+ return sql.join(columns);
16
+ };
17
+ const columnNamed = (model, name) => {
18
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
19
+ return field ? columnId(field) : sql`${sql.id(name)}`;
20
+ };
21
+ /** Rows of the caller's tenant, live ones unless `deleted` says otherwise: the conditions every statement on a resource starts with. */
22
+ const scopeConditions = (model, tenant, deleted = "hide") => {
23
+ const conditions = [];
24
+ if (model.softDelete && deleted !== "show") conditions.push(sql`${columnNamed(model, model.softDelete)} is ${sql.raw(deleted === "only" ? "not null" : "null")}`);
25
+ if (model.tenant && tenant !== void 0) conditions.push(sql`${columnNamed(model, model.tenant)} = ${tenant}`);
26
+ return conditions;
27
+ };
28
+ const keyConditions = (model, key) => model.primaryKey.map((name, i) => sql`${columnId(model.fields[name])} = ${key[i]}`);
29
+ const filterCondition = (model, filter) => filter ? [compileFilter(model, filter)] : [];
30
+ const whereClause = (conditions) => conditions.length === 0 ? sql`` : sql` where ${sql.join(conditions, sql` and `)}`;
31
+ //#endregion
32
+ export { aliasedColumn, columnId, etagAlias, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause };
package/dist/tenant.js ADDED
@@ -0,0 +1,10 @@
1
+ import { forbidden } from "./problem.js";
2
+ //#region src/tenant.ts
3
+ /** The query-layer scope for the caller; a tenant-scoped resource without a tenant is refused. */
4
+ const tenantScope = (entry, session) => {
5
+ if (!entry.model.tenant) return void 0;
6
+ if (session.tenant === void 0) throw forbidden("tenant-required", `Resource "${entry.name}" is tenant-scoped and the caller has no tenant`);
7
+ return { tenantValue: session.tenant };
8
+ };
9
+ //#endregion
10
+ export { tenantScope };
@@ -0,0 +1,14 @@
1
+ import { sql } from "kysely";
2
+ //#region src/transactions.ts
3
+ /** One transaction whose statements Postgres cancels after `timeoutMs` (SET LOCAL statement_timeout). */
4
+ const writeTransaction = (db, timeoutMs, run) => db.transaction().execute(async (trx) => {
5
+ await sql`select set_config('statement_timeout', ${String(timeoutMs)}, true)`.execute(trx);
6
+ return run(trx);
7
+ });
8
+ /** Same, and Postgres refuses any write inside it. */
9
+ const readTransaction = (db, timeoutMs, run) => writeTransaction(db, timeoutMs, async (trx) => {
10
+ await sql`set transaction read only`.execute(trx);
11
+ return run(trx);
12
+ });
13
+ //#endregion
14
+ export { readTransaction, writeTransaction };
@@ -0,0 +1,97 @@
1
+ import { Hono } from "hono";
2
+ import { PageModel } from "@protobase/layout";
3
+ import { ResolveOptions, ResourceModel, ViewModel } from "@protobase/schema";
4
+ import { Db } from "@protobase/query";
5
+ //#region src/types.d.ts
6
+ export type TenantValue = string | number;
7
+ export type User = {
8
+ id: string | number;
9
+ roles: string[];
10
+ };
11
+ export type Session = {
12
+ user: User;
13
+ tenant?: TenantValue;
14
+ };
15
+ /** Resolves the caller of a request, or throws a 401 problem (see `unauthorized`). */
16
+ export type Authenticator = (request: Request) => Promise<Session> | Session;
17
+ export type AdminEnv = {
18
+ Variables: {
19
+ session: Session;
20
+ };
21
+ };
22
+ export type Row = Record<string, unknown>;
23
+ export type WriteOperation = 'create' | 'update' | 'delete' | 'undelete';
24
+ /** Passed to every pipeline hook, inside the write transaction, after the row was written. */
25
+ export type WriteEvent = {
26
+ operation: WriteOperation;
27
+ resource: ResourceModel;
28
+ user: User;
29
+ tenant?: TenantValue;
30
+ before?: Row;
31
+ after?: Row;
32
+ };
33
+ /** Extension point of the write pipeline: a hook that throws rolls the write back. */
34
+ export type PipelineHook = (event: WriteEvent, trx: Db) => Promise<void>;
35
+ /** What happened, by whom, to which value. It never carries the value itself. */
36
+ export type AuditEvent = {
37
+ type: 'field.revealed';
38
+ /** ISO 8601, when the server answered. */
39
+ at: string;
40
+ actor: {
41
+ id: string | number;
42
+ roles: string[];
43
+ };
44
+ /** The caller's tenant (organization), when the resource has one. */
45
+ tenant?: TenantValue;
46
+ resource: string;
47
+ /** The record's key as its URL has it (composite keys joined with a comma). */
48
+ recordKey: string;
49
+ field: string;
50
+ /** Where the request came from, as its headers say; proxies set `forwardedFor`, so it is only as trustworthy as they are. */
51
+ origin: {
52
+ userAgent?: string;
53
+ forwardedFor?: string;
54
+ };
55
+ };
56
+ /**
57
+ * Where audit events go. `publish` resolves once the event is accepted; when it rejects, the request that caused the
58
+ * event fails, so nothing is revealed without its event. A real queue replaces the console one through
59
+ * `options.audit` in `protobase.config.ts`.
60
+ */
61
+ export type AuditQueue = {
62
+ publish(event: AuditEvent): Promise<void>;
63
+ };
64
+ export type ScanGuardMode = 'reject' | 'warn' | 'off';
65
+ export type AdminOptions = {
66
+ /** Default `/api/v1`. */
67
+ basePath?: string;
68
+ /** Applied to every request's transaction with SET LOCAL. Default 15000. */
69
+ statementTimeoutMs?: number;
70
+ /** List shapes that would sequentially scan a table of more than `seqScanRows` rows (default 10000). Default mode `reject`. */
71
+ scanGuard?: {
72
+ mode?: ScanGuardMode;
73
+ seqScanRows?: number;
74
+ };
75
+ /** Runs inside the write transaction after each create, update and delete. */
76
+ writeHooks?: PipelineHook[];
77
+ /** The project's roles (from `defineRoles`): an operation without an access rule needs the matching capability. */
78
+ roles?: ResolveOptions['roles'];
79
+ /** What an operation without a rule does; overrides the capability check. Without `roles` the default is allow. */
80
+ defaultAccess?: ResolveOptions['defaultAccess'];
81
+ /** Whether list items carry their `permissions` (update, delete): true by default, or `{ except: [...] }` for resources that opt out. */
82
+ rowPermissions?: boolean | {
83
+ except: string[];
84
+ };
85
+ /** Receives an event for every reveal of a sensitive field. Default: printed to the console, nothing kept. */
86
+ audit?: AuditQueue;
87
+ /** Called with every error the API turns into a 500, so the host can report it. */
88
+ onUnhandledError?: (error: unknown) => void;
89
+ };
90
+ export type ViewSource = {
91
+ toModel(): ViewModel;
92
+ };
93
+ export type PageSource = {
94
+ toModel(): PageModel;
95
+ };
96
+ export type Admin = Hono<AdminEnv>;
97
+ //#endregion
@@ -0,0 +1,73 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { writableFields } from "./request-access.js";
3
+ //#region src/validate-body.ts
4
+ const fail = (errors) => badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
5
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6
+ const reserved = ["etag", "permissions"];
7
+ /**
8
+ * Checks a create or update body for this caller. A field the caller cannot read is an unknown field, one they can read
9
+ * but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always read-only);
10
+ * every provided value goes through its field's zod schema, then `.validate()` rules run on the result.
11
+ * Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
12
+ * `current` is the stored record an update merges into for `.validate()` rules.
13
+ */
14
+ const validateBody = (access, mode, body, current = {}) => {
15
+ if (!isObject(body)) throw badRequest("invalid-record", "The request body must be a JSON object");
16
+ const { entry, readable: model, full } = access;
17
+ const writable = new Set(writableFields(access, mode));
18
+ const errors = [];
19
+ const values = {};
20
+ for (const name of Object.keys(body)) if (reserved.includes(name) && !Object.hasOwn(model.fields, name)) errors.push({
21
+ field: name,
22
+ code: "read-only",
23
+ message: name === "etag" ? "The version is not part of the body; send it in the If-Match header" : `"${name}" is read-only`
24
+ });
25
+ else if (!Object.hasOwn(model.fields, name)) errors.push({
26
+ field: name,
27
+ code: "unknown-field",
28
+ message: `Unknown field "${name}"`
29
+ });
30
+ else if (!writable.has(name) || name === full.tenant) errors.push({
31
+ field: name,
32
+ code: "read-only",
33
+ message: `"${name}" is read-only`
34
+ });
35
+ if (errors.length > 0) throw fail(errors);
36
+ for (const [name, source] of Object.entries(entry.source.state.fields ?? {})) {
37
+ const field = full.fields[name];
38
+ if (!source || !field || !writable.has(name) || name === full.tenant) continue;
39
+ const provided = Object.hasOwn(body, name);
40
+ if (!provided && mode === "update") continue;
41
+ if (!provided && field.default && "db" in field.default) continue;
42
+ const parsed = source.schema.safeParse(provided ? body[name] : void 0);
43
+ if (parsed.success) {
44
+ if (parsed.data !== void 0) values[name] = parsed.data;
45
+ continue;
46
+ }
47
+ if (!provided && field.nullable) continue;
48
+ if (!provided) errors.push({
49
+ field: name,
50
+ code: "required",
51
+ message: `"${name}" is required`
52
+ });
53
+ else errors.push(...parsed.error.issues.map((issue) => ({
54
+ field: name,
55
+ code: "invalid-value",
56
+ message: issue.message
57
+ })));
58
+ }
59
+ if (errors.length > 0) throw fail(errors);
60
+ const merged = {
61
+ ...current,
62
+ ...values
63
+ };
64
+ const issues = entry.source.validators.flatMap((validate) => validate(merged) ?? []);
65
+ if (issues.length > 0) throw fail(issues.map((issue) => ({
66
+ field: Object.hasOwn(model.fields, issue.field) ? issue.field : "_record",
67
+ code: "invalid-record",
68
+ message: issue.message
69
+ })));
70
+ return values;
71
+ };
72
+ //#endregion
73
+ export { validateBody };
@@ -0,0 +1,54 @@
1
+ import { relatedErrors } from "./view-related.js";
2
+ import { resolveFieldPath } from "@protobase/layout";
3
+ import { navRecentErrors } from "@protobase/schema";
4
+ //#region src/view-access.ts
5
+ const pruneRelated = (item, resource, models) => {
6
+ const { columns, ...rest } = item;
7
+ const visible = models[item.resource] ? columns?.filter((column) => resolveFieldPath(models, item.resource, column).ok) : void 0;
8
+ const narrowed = visible && visible.length > 0 ? {
9
+ ...rest,
10
+ columns: visible
11
+ } : rest;
12
+ return relatedErrors(narrowed, resource, models).length === 0 ? [narrowed] : [];
13
+ };
14
+ /**
15
+ * The view with every reference to a field the caller cannot read removed: columns, sort, search, field labels,
16
+ * filter widgets, layout, chart, title, search result, the update actions that set one, and the sidebar's `recent` group
17
+ * when its status, filter or order uses a hidden field. Nothing in it may name a hidden field. Related sections are checked against
18
+ * `models`, the caller's view of every resource.
19
+ */
20
+ const pruneView = (view, model, models) => {
21
+ const visible = (name) => Object.hasOwn(model.fields, name);
22
+ const { title, chart, list, nav, searchResult, ...rest } = view;
23
+ const { recent, ...placement } = nav ?? {};
24
+ const resultTitle = searchResult?.title?.filter(visible);
25
+ const subtitle = searchResult?.subtitle;
26
+ return {
27
+ ...rest,
28
+ ...searchResult && { searchResult: {
29
+ ...resultTitle?.length && { title: resultTitle },
30
+ ...subtitle !== void 0 && visible(subtitle) && { subtitle }
31
+ } },
32
+ ...nav && { nav: recent && navRecentErrors(recent, model).length === 0 ? nav : placement },
33
+ ...title !== void 0 && visible(title) && { title },
34
+ ...chart && visible(chart.field) && { chart },
35
+ ...list && { list: {
36
+ columns: list.columns.filter(visible),
37
+ ...list.sort && { sort: list.sort.filter(([name]) => visible(name)) },
38
+ ...list.search && { search: list.search.filter(visible) }
39
+ } },
40
+ fields: Object.fromEntries(Object.entries(view.fields).filter(([name]) => visible(name))),
41
+ filters: view.filters.filter((widget) => visible(widget.field)),
42
+ actions: view.actions.filter((action) => action.run?.kind !== "update" || Object.keys(action.run.values).every(visible)),
43
+ layout: view.layout.flatMap((item) => {
44
+ if (item.kind === "related") return pruneRelated(item, view.resource, models);
45
+ const fields = item.fields.filter(visible);
46
+ return fields.length > 0 ? [{
47
+ ...item,
48
+ fields
49
+ }] : [];
50
+ })
51
+ };
52
+ };
53
+ //#endregion
54
+ export { pruneView };
@@ -0,0 +1,59 @@
1
+ import { resolveFieldPath } from "@protobase/layout";
2
+ import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
3
+ //#region src/view-related.ts
4
+ const filterErrors = (model, source) => {
5
+ if (source === void 0) return [];
6
+ const parsed = parseFilter(source);
7
+ if (!parsed.ok) return parsed.errors.map((error) => error.message);
8
+ const checked = parsed.ast ? checkFilter(model, parsed.ast) : void 0;
9
+ return checked && !checked.ok ? checked.errors.map((error) => error.message) : [];
10
+ };
11
+ const sortErrors = (model, source) => {
12
+ if (source === void 0) return [];
13
+ const parsed = parseOrderBy(source);
14
+ if (!parsed.ok) return parsed.errors.map((error) => error.message);
15
+ const checked = checkOrderBy(model, parsed.items);
16
+ return checked.ok ? [] : checked.errors.map((error) => error.message);
17
+ };
18
+ const matchErrors = (model, name, to) => {
19
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
20
+ if (!field) return [`unknown field "${name}" on ${model.name}`];
21
+ if (!field.filterable) return [`"${name}" on ${model.name} is not filterable`];
22
+ if (to !== void 0 && field.relation?.resource !== to) return [`"${name}" on ${model.name} is not a relation to ${to}`];
23
+ return [];
24
+ };
25
+ /** The field whose values the `through` records hand on: `key`, else their single-column primary key. */
26
+ const throughKey = (via, key) => key ?? (via.primaryKey.length === 1 ? via.primaryKey[0] : void 0);
27
+ const throughErrors = (through, resource, models) => {
28
+ const via = models[through.resource];
29
+ if (!via) return [`unknown resource "${through.resource}"`];
30
+ const key = throughKey(via, through.key);
31
+ return [
32
+ ...matchErrors(via, through.field, resource),
33
+ ...key === void 0 ? [`${via.name} has a composite key, so name the field to collect with "key"`] : Object.hasOwn(via.fields, key) ? [] : [`unknown field "${key}" on ${via.name}`],
34
+ ...filterErrors(via, through.filter).map((error) => `filter: ${error}`)
35
+ ].map((error) => `through: ${error}`);
36
+ };
37
+ /**
38
+ * What is wrong with a related section on the record page of `resource`, against these models; empty when it can be
39
+ * shown. Against the full models it checks the config; against a caller's models, whether that caller can use it.
40
+ */
41
+ const relatedErrors = (item, resource, models) => {
42
+ const owner = models[resource];
43
+ const target = models[item.resource];
44
+ if (!owner || !target) return [`unknown resource "${owner ? item.resource : resource}"`];
45
+ if (owner.primaryKey.length !== 1) return [`${resource} has a composite key, so a related section cannot match it`];
46
+ return [
47
+ ...item.through ? [...throughErrors(item.through, resource, models), ...matchErrors(target, item.field)] : matchErrors(target, item.field, resource),
48
+ ...filterErrors(target, item.filter).map((error) => `filter: ${error}`),
49
+ ...sortErrors(target, item.sort).map((error) => `sort: ${error}`),
50
+ ...(item.columns ?? []).flatMap((column) => {
51
+ const resolved = resolveFieldPath(models, target.name, column);
52
+ if (!resolved.ok) return [`column: ${resolved.message}`];
53
+ return resolved.steps.at(-1).field.sensitive ? [`column: "${column}" is sensitive, so it cannot be listed`] : [];
54
+ }),
55
+ ...item.pageSize === void 0 || Number.isInteger(item.pageSize) && item.pageSize >= 1 && item.pageSize <= 100 ? [] : ["pageSize must be a whole number from 1 to 100"]
56
+ ];
57
+ };
58
+ //#endregion
59
+ export { relatedErrors, throughKey };
@@ -0,0 +1,94 @@
1
+ import { HttpProblem, badRequest } from "./problem.js";
2
+ import { tenantScope } from "./tenant.js";
3
+ import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
4
+ import { writeTransaction } from "./transactions.js";
5
+ import { deleteRecord, fetchRecord, insertRecord, restoreRecord } from "./records.js";
6
+ import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier, shown } from "./write-shared.js";
7
+ import { validateBody } from "./validate-body.js";
8
+ import { applyUpdate, planUpdate } from "./write-update.js";
9
+ //#region src/write-pipeline.ts
10
+ const withOwner = (access, values) => {
11
+ const { owner } = access.full;
12
+ const caller = access.session.user.id;
13
+ if (!owner || !access.resolved.rowFilter.create || values[owner] !== void 0 || caller === void 0) return values;
14
+ return {
15
+ ...values,
16
+ [owner]: caller
17
+ };
18
+ };
19
+ const create = async (trx, deps, access, body) => {
20
+ const { entry, full } = access;
21
+ const scope = tenantScope(entry, access.session);
22
+ const tenantValues = full.tenant && scope ? { [full.tenant]: scope.tenantValue } : {};
23
+ const preset = typeof body === "object" && body !== null && !Array.isArray(body) && writableFields(access, "create").includes(full.owner ?? "") ? withOwner(access, body) : body;
24
+ const row = {
25
+ ...withOwner(access, validateBody(access, "create", preset, tenantValues)),
26
+ ...tenantValues
27
+ };
28
+ const filter = await decide(access, "create", void 0, row);
29
+ const created = await insertRecord(trx, entry, row, { etagFields: Object.keys(access.model.fields) });
30
+ await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
31
+ await notifier(deps, access)(trx, "create", void 0, created.record);
32
+ return shown(access, created);
33
+ };
34
+ const update = async (trx, deps, access, request) => {
35
+ const plan = await planUpdate(trx, access, request.key, request.body, request.ifMatch);
36
+ if (Object.keys(plan.values).length === 0) return shown(access, plan.current);
37
+ return shown(access, await applyUpdate(trx, deps, access, request.key, plan));
38
+ };
39
+ const removeOrRestore = async (trx, deps, access, request) => {
40
+ const { entry, full } = access;
41
+ const undelete = request.operation === "undelete";
42
+ if (undelete && !full.softDelete) throw badRequest("undelete-unsupported", `Resource "${entry.name}" does not soft delete, so there is nothing to undelete`);
43
+ const scope = tenantScope(entry, access.session);
44
+ const operation = undelete ? "update" : "delete";
45
+ const deleted = undelete ? "only" : "hide";
46
+ const target = targetFilter(access, operation);
47
+ const shape = { etagFields: Object.keys(access.model.fields) };
48
+ const current = await fetchRecord(trx, entry, request.key, scope?.tenantValue, {
49
+ deleted,
50
+ lock: true,
51
+ ...target && { filter: target },
52
+ ...shape
53
+ });
54
+ if (!current) throw undelete && await fetchRecord(trx, entry, request.key, scope?.tenantValue, {
55
+ filter: access.resolved.rowFilter.read,
56
+ fields: [firstKeyField(access)]
57
+ }) ? new HttpProblem(409, "not-deleted", "Conflict", `This ${entry.name} record is not deleted`) : missing(entry);
58
+ checkPrecondition(current, request.ifMatch, false);
59
+ const filter = await decide(access, operation, current.record);
60
+ if (filter && !await fetchRecord(trx, entry, request.key, scope?.tenantValue, {
61
+ deleted,
62
+ filter,
63
+ fields: [firstKeyField(access)]
64
+ })) throw denied(entry, operation);
65
+ const notify = notifier(deps, access);
66
+ if (undelete) {
67
+ const restored = await restoreRecord(trx, entry, request.key, scope?.tenantValue, shape);
68
+ await assertWritten(trx, access, restored, "undelete", access.resolved.rowFilter.update, filter);
69
+ await notify(trx, "undelete", current.record, restored.record);
70
+ return shown(access, restored);
71
+ }
72
+ const removed = await deleteRecord(trx, entry, request.key, scope?.tenantValue, shape);
73
+ await notify(trx, "delete", current.record, void 0);
74
+ return shown(access, removed);
75
+ };
76
+ /**
77
+ * One write inside an open transaction, for a caller whose access is already resolved: operations and field access,
78
+ * tenant scope, validation against the fields they may write, optimistic concurrency, row filters and record-level
79
+ * rules checked in the database, then the hooks. Batches run many of these in one transaction.
80
+ */
81
+ const writeIn = async (trx, deps, access, request) => {
82
+ requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
83
+ if (request.operation === "create") return create(trx, deps, access, request.body);
84
+ if (request.operation === "update") return update(trx, deps, access, request);
85
+ return removeOrRestore(trx, deps, access, request);
86
+ };
87
+ /** The path every single write takes: resolve access once, then `writeIn` in its own transaction with a statement timeout. The caller has already authenticated `session`. */
88
+ const runWrite = async (deps, entry, session, request) => {
89
+ const access = await requestAccess(deps, entry, session);
90
+ requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
91
+ return writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request));
92
+ };
93
+ //#endregion
94
+ export { runWrite, writeIn };