@protobase/server 0.28.0 → 0.28.1
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/check-parents.js +16 -0
- package/dist/comments/comment-access.js +1 -1
- package/dist/create-admin.js +2 -0
- package/dist/error-handler.js +1 -0
- package/dist/fetch-readable.js +33 -0
- package/dist/history/read-history.js +1 -1
- package/dist/meta.js +2 -2
- package/dist/parent-access.js +84 -0
- package/dist/read-service.js +3 -29
- package/dist/records.js +2 -1
- package/dist/reveal-service.js +1 -1
- package/dist/row-permissions.js +19 -2
- package/dist/write-pipeline.js +5 -1
- package/dist/write-shared.js +13 -1
- package/dist/write-update.js +11 -3
- package/package.json +4 -4
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/check-parents.ts
|
|
2
|
+
/**
|
|
3
|
+
* Stops startup on a `.parent()` whose relation cannot find the parent record: a resource the app does not have, or one
|
|
4
|
+
* whose key is not exactly one field, the relation's one column.
|
|
5
|
+
*/
|
|
6
|
+
const checkParents = (registry) => {
|
|
7
|
+
const problems = registry.entries.flatMap(({ name, model }) => (model.parents ?? []).flatMap(({ field }) => {
|
|
8
|
+
const resource = model.fields[field].relation.resource;
|
|
9
|
+
const parent = registry.find(resource)?.model;
|
|
10
|
+
if (!parent) return [`${name} parent "${field}": unknown resource "${resource}"`];
|
|
11
|
+
return parent.primaryKey.length === 1 ? [] : [`${name} parent "${field}": "${resource}" has a key of ${parent.primaryKey.length} fields, not one`];
|
|
12
|
+
}));
|
|
13
|
+
if (problems.length > 0) throw new Error(`The parents cannot find the records they point at:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { checkParents };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { forbidden, notFound } from "../problem.js";
|
|
2
2
|
import { requestAccess, requireOperation } from "../request-access.js";
|
|
3
|
-
import { fetchReadable } from "../
|
|
3
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
4
4
|
//#region src/comments/comment-access.ts
|
|
5
5
|
/** The caller's access to a resource with comments, who must be allowed to read it; a resource without is a 404. */
|
|
6
6
|
const commentAccess = async (deps, entry, session) => {
|
package/dist/create-admin.js
CHANGED
|
@@ -41,6 +41,7 @@ import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
|
41
41
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
42
42
|
import { checkOwnerFields } from "./check-owners.js";
|
|
43
43
|
import { checkRoleLimits } from "./check-roles.js";
|
|
44
|
+
import { checkParents } from "./check-parents.js";
|
|
44
45
|
import { checkPositionAfter } from "./check-position-after.js";
|
|
45
46
|
import { checkViews } from "./check-views.js";
|
|
46
47
|
import { liveRoutes } from "./live/live-route.js";
|
|
@@ -111,6 +112,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
111
112
|
checkRoleLimits(registry, options.roles?.limits ?? []);
|
|
112
113
|
checkOwnerFields(registry, auth);
|
|
113
114
|
checkPositionAfter(registry);
|
|
115
|
+
checkParents(registry);
|
|
114
116
|
checkPages(pageModels, {
|
|
115
117
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
116
118
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name))),
|
package/dist/error-handler.js
CHANGED
|
@@ -28,6 +28,7 @@ const databaseProblem = (error) => {
|
|
|
28
28
|
if (error.code === "57014") return new HttpProblem(504, "statement-timeout", "Gateway Timeout", "The query exceeded the statement timeout");
|
|
29
29
|
if (error.code === "23505") return new HttpProblem(409, "conflict", "Conflict", "A record with the same unique value already exists");
|
|
30
30
|
if (error.code === "23503") return new HttpProblem(409, "conflict", "Conflict", "The record points at, or is pointed at by, another record");
|
|
31
|
+
if (error.code === "40P01") return new HttpProblem(409, "conflict", "Conflict", "The record was written at the same time as another; send it again");
|
|
31
32
|
if (error.code === "23502") return new HttpProblem(400, "invalid-record", "Bad Request", "A required column has no value");
|
|
32
33
|
if (error.code === "23514") return new HttpProblem(400, "invalid-record", "Bad Request", "A check constraint rejected the record");
|
|
33
34
|
if (error.code.startsWith("22")) return new HttpProblem(400, "invalid-value", "Bad Request", "A value has the wrong format for its column");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { tenantScope } from "./tenant.js";
|
|
2
|
+
import { recordDecision, targetFilter } from "./request-access.js";
|
|
3
|
+
import { fetchRecord } from "./records.js";
|
|
4
|
+
//#region src/fetch-readable.ts
|
|
5
|
+
/**
|
|
6
|
+
* The row with this key if the caller may read it: inside their tenant and row filter, and passing a record-level read
|
|
7
|
+
* rule. Such a rule may look at fields the caller cannot see, so then the whole row is read; the caller cuts it down.
|
|
8
|
+
* `lock` locks the row the first read finds.
|
|
9
|
+
*/
|
|
10
|
+
const fetchReadable = async (trx, access, key, deleted, { fields, lock, ...shape }) => {
|
|
11
|
+
const { entry } = access;
|
|
12
|
+
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
13
|
+
const recordRule = access.resolved.recordChecks.includes("read");
|
|
14
|
+
const filter = targetFilter(access, "read");
|
|
15
|
+
const current = await fetchRecord(trx, entry, key, tenant, {
|
|
16
|
+
deleted,
|
|
17
|
+
...filter && { filter },
|
|
18
|
+
...lock && { lock },
|
|
19
|
+
...shape,
|
|
20
|
+
...!recordRule && fields && { fields }
|
|
21
|
+
});
|
|
22
|
+
if (!current || !recordRule) return current;
|
|
23
|
+
const decision = await recordDecision(access, "read", current.record);
|
|
24
|
+
if (decision === false) return void 0;
|
|
25
|
+
if (decision !== true && !await fetchRecord(trx, entry, key, tenant, {
|
|
26
|
+
deleted,
|
|
27
|
+
filter: decision,
|
|
28
|
+
fields: [access.full.fields[access.full.primaryKey[0]]]
|
|
29
|
+
})) return void 0;
|
|
30
|
+
return current;
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { fetchReadable };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { notFound } from "../problem.js";
|
|
2
2
|
import { tenantScope } from "../tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "../request-access.js";
|
|
4
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
4
5
|
import { readTransaction } from "../transactions.js";
|
|
5
|
-
import { fetchReadable } from "../read-service.js";
|
|
6
6
|
import { quoteIdent } from "../changes/capture-sql.js";
|
|
7
7
|
import { parseParams } from "../params.js";
|
|
8
8
|
import { encodeKey } from "@protobase/schema/internal";
|
package/dist/meta.js
CHANGED
|
@@ -66,7 +66,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
66
66
|
resources: visible.map((access) => access.readable),
|
|
67
67
|
views,
|
|
68
68
|
pages,
|
|
69
|
-
permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
|
|
69
|
+
permissions: Object.fromEntries(visible.map(({ entry, resolved, full }) => [entry.name, {
|
|
70
70
|
read: resolved.operations.read || resolved.operations.list,
|
|
71
71
|
create: resolved.operations.create,
|
|
72
72
|
update: resolved.operations.update,
|
|
@@ -75,7 +75,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
75
75
|
"create",
|
|
76
76
|
"update",
|
|
77
77
|
"delete"
|
|
78
|
-
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
|
|
78
|
+
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0 || (full.parents?.length ?? 0) > 0))
|
|
79
79
|
}])),
|
|
80
80
|
...signIn.length > 0 && { signIn },
|
|
81
81
|
...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } },
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { tenantScope } from "./tenant.js";
|
|
2
|
+
import { recordDecision, requestAccess } from "./request-access.js";
|
|
3
|
+
import { fetchRecord } from "./records.js";
|
|
4
|
+
import { fetchReadable } from "./fetch-readable.js";
|
|
5
|
+
//#region src/parent-access.ts
|
|
6
|
+
/** A record's identity across resources; `1` and `"1"` are the same record. */
|
|
7
|
+
const pathKey = (resource, key) => JSON.stringify([resource, ...key.map(String)]);
|
|
8
|
+
/**
|
|
9
|
+
* The parents these records name through `model.parents`, a null or missing relation naming none, sorted by path key:
|
|
10
|
+
* the order they are locked in, so two writes naming the same parents lock them in the same order. A parent named twice
|
|
11
|
+
* with the same need is checked once.
|
|
12
|
+
*/
|
|
13
|
+
const parentTargets = (model, records) => {
|
|
14
|
+
const targets = records.flatMap((record) => (model.parents ?? []).flatMap(({ field, access }) => {
|
|
15
|
+
const value = record[field];
|
|
16
|
+
if (value === void 0 || value === null) return [];
|
|
17
|
+
const resource = model.fields[field].relation.resource;
|
|
18
|
+
return [{
|
|
19
|
+
field,
|
|
20
|
+
access,
|
|
21
|
+
resource,
|
|
22
|
+
value,
|
|
23
|
+
key: pathKey(resource, [value])
|
|
24
|
+
}];
|
|
25
|
+
}));
|
|
26
|
+
return targets.filter((target, index) => targets.findIndex((other) => other.key === target.key && other.access === target.access) === index).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
27
|
+
};
|
|
28
|
+
const mayOn = async (scope, entry, key, need, path) => {
|
|
29
|
+
const access = await scope.accessOf(entry);
|
|
30
|
+
if (!access.resolved.operations.read) return false;
|
|
31
|
+
const found = await fetchReadable(scope.trx, access, key, "hide", scope.lock ? { lock: "no-key-update" } : {});
|
|
32
|
+
if (!found) return false;
|
|
33
|
+
if (need === "read") return true;
|
|
34
|
+
if (!access.resolved.operations.update) return false;
|
|
35
|
+
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
36
|
+
const keyField = [access.full.fields[access.full.primaryKey[0]]];
|
|
37
|
+
const filter = access.resolved.rowFilter.update;
|
|
38
|
+
if (filter && !await fetchRecord(scope.trx, entry, key, tenant, {
|
|
39
|
+
filter,
|
|
40
|
+
fields: keyField
|
|
41
|
+
})) return false;
|
|
42
|
+
if (access.resolved.recordChecks.includes("update")) {
|
|
43
|
+
const decision = await recordDecision(access, "update", found.record, {});
|
|
44
|
+
if (decision === false) return false;
|
|
45
|
+
if (decision !== true && !await fetchRecord(scope.trx, entry, key, tenant, {
|
|
46
|
+
filter: decision,
|
|
47
|
+
fields: keyField
|
|
48
|
+
})) return false;
|
|
49
|
+
}
|
|
50
|
+
return parentsAllow(scope, access, [found.record], [...path, pathKey(entry.name, key)]);
|
|
51
|
+
};
|
|
52
|
+
const parentsAllow = async (scope, access, records, path) => {
|
|
53
|
+
for (const target of parentTargets(access.full, records)) {
|
|
54
|
+
if (path.includes(target.key)) return false;
|
|
55
|
+
const entry = scope.deps.registry.find(target.resource);
|
|
56
|
+
if (!entry) throw new Error(`Parent "${target.field}" of "${access.entry.name}" points at "${target.resource}", which the app does not have`);
|
|
57
|
+
if (!await mayOn(scope, entry, [target.value], target.access, path)) return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
const ownPath = (access, records) => records.filter((record) => access.full.primaryKey.every((name) => record[name] !== void 0 && record[name] !== null)).map((record) => pathKey(access.entry.name, access.full.primaryKey.map((name) => record[name])));
|
|
62
|
+
/**
|
|
63
|
+
* Whether the caller may write records of `access`'s resource given the parents they name (`.parent()`): an update passes
|
|
64
|
+
* the record before and after, so the parent it leaves and the one it moves to are locked in one order. With `lock`,
|
|
65
|
+
* each parent is locked FOR NO KEY UPDATE until the transaction ends, so a parent changed or deleted at the same moment
|
|
66
|
+
* cannot slip through; a read-only transaction cannot lock. Every call checks again; only each resource's access for
|
|
67
|
+
* the session is reused.
|
|
68
|
+
*/
|
|
69
|
+
const parentGuard = (trx, access, lock) => {
|
|
70
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
71
|
+
const accessOf = (entry) => {
|
|
72
|
+
if (!resolved.has(entry.name)) resolved.set(entry.name, requestAccess(access.deps, entry, access.session));
|
|
73
|
+
return resolved.get(entry.name);
|
|
74
|
+
};
|
|
75
|
+
const scope = {
|
|
76
|
+
trx,
|
|
77
|
+
deps: access.deps,
|
|
78
|
+
lock,
|
|
79
|
+
accessOf
|
|
80
|
+
};
|
|
81
|
+
return (...records) => parentsAllow(scope, access, records, ownPath(access, records));
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { parentGuard, parentTargets, pathKey };
|
package/dist/read-service.js
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
import { notFound } from "./problem.js";
|
|
2
|
-
import {
|
|
3
|
-
import { recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields } from "./request-access.js";
|
|
2
|
+
import { requestAccess, requireOperation, visibleFields, writableFields } from "./request-access.js";
|
|
4
3
|
import { presentFiles } from "./files/present.js";
|
|
5
4
|
import { pickFields } from "./rows.js";
|
|
6
|
-
import {
|
|
5
|
+
import { fetchReadable } from "./fetch-readable.js";
|
|
7
6
|
import { rowPermissions } from "./row-permissions.js";
|
|
8
7
|
import { readTransaction } from "./transactions.js";
|
|
9
8
|
//#region src/read-service.ts
|
|
10
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. */
|
|
11
10
|
const fieldModes = (access, canUpdate) => Object.fromEntries(Object.keys(access.readable.fields).map((name) => [name, canUpdate && writableFields(access, "update").includes(name) ? "edit" : "read"]));
|
|
12
11
|
/**
|
|
13
|
-
* The row with this key if the caller may read it: inside their tenant and row filter, and passing a record-level read
|
|
14
|
-
* rule. Such a rule may look at fields the caller cannot see, so then the whole row is read; the caller cuts it down.
|
|
15
|
-
*/
|
|
16
|
-
const fetchReadable = async (trx, access, key, deleted, { fields, ...shape }) => {
|
|
17
|
-
const { entry } = access;
|
|
18
|
-
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
19
|
-
const recordRule = access.resolved.recordChecks.includes("read");
|
|
20
|
-
const filter = targetFilter(access, "read");
|
|
21
|
-
const current = await fetchRecord(trx, entry, key, tenant, {
|
|
22
|
-
deleted,
|
|
23
|
-
...filter && { filter },
|
|
24
|
-
...shape,
|
|
25
|
-
...!recordRule && fields && { fields }
|
|
26
|
-
});
|
|
27
|
-
if (!current || !recordRule) return current;
|
|
28
|
-
const decision = await recordDecision(access, "read", current.record);
|
|
29
|
-
if (decision === false) return void 0;
|
|
30
|
-
if (decision !== true && !await fetchRecord(trx, entry, key, tenant, {
|
|
31
|
-
deleted,
|
|
32
|
-
filter: decision,
|
|
33
|
-
fields: [access.full.fields[access.full.primaryKey[0]]]
|
|
34
|
-
})) return void 0;
|
|
35
|
-
return current;
|
|
36
|
-
};
|
|
37
|
-
/**
|
|
38
12
|
* The record with this key as the caller may see it: readable fields only, inside the caller's tenant and row filter.
|
|
39
13
|
* A key outside the tenant, outside the row filter, or failing a record-level read rule is a plain 404.
|
|
40
14
|
*/
|
|
@@ -66,4 +40,4 @@ const readRecord = async (deps, entry, session, key, deleted = "hide") => {
|
|
|
66
40
|
};
|
|
67
41
|
};
|
|
68
42
|
//#endregion
|
|
69
|
-
export {
|
|
43
|
+
export { fieldModes, readRecord };
|
package/dist/records.js
CHANGED
|
@@ -12,6 +12,7 @@ const stored = async (entry, raw, shape = {}) => {
|
|
|
12
12
|
};
|
|
13
13
|
};
|
|
14
14
|
const rowsOf = async (db, query) => (await query.execute(db)).rows;
|
|
15
|
+
const lockClause = (lock) => lock === "no-key-update" ? sql` for no key update` : lock ? sql` for update` : sql``;
|
|
15
16
|
/** The row of the caller's tenant with this key (live unless `deleted` says otherwise), optionally narrowed by a filter. */
|
|
16
17
|
const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
17
18
|
const { filter, deleted, lock = false, ...shape } = options;
|
|
@@ -20,7 +21,7 @@ const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
|
20
21
|
...keyConditions(entry.model, key),
|
|
21
22
|
...filterCondition(entry.model, filter)
|
|
22
23
|
];
|
|
23
|
-
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lock
|
|
24
|
+
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
|
|
24
25
|
return stored(entry, (await rowsOf(db, query))[0], shape);
|
|
25
26
|
};
|
|
26
27
|
const writeValue = (field, value) => field.type === "json" && value !== null ? JSON.stringify(value) : value;
|
package/dist/reveal-service.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { badRequest, notFound } from "./problem.js";
|
|
2
2
|
import { tenantScope } from "./tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "./request-access.js";
|
|
4
|
+
import { fetchReadable } from "./fetch-readable.js";
|
|
4
5
|
import { readTransaction } from "./transactions.js";
|
|
5
|
-
import { fetchReadable } from "./read-service.js";
|
|
6
6
|
import { encodeKey } from "@protobase/schema/internal";
|
|
7
7
|
//#region src/reveal-service.ts
|
|
8
8
|
/**
|
package/dist/row-permissions.js
CHANGED
|
@@ -2,6 +2,7 @@ import { tenantScope } from "./tenant.js";
|
|
|
2
2
|
import { recordDecision } from "./request-access.js";
|
|
3
3
|
import { aliasedColumn, columnId, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
4
4
|
import { fetchRecord } from "./records.js";
|
|
5
|
+
import { parentGuard } from "./parent-access.js";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
import { compileFilter } from "@protobase/query";
|
|
7
8
|
//#region src/row-permissions.ts
|
|
@@ -21,8 +22,8 @@ const matching = async (trx, access, rows, filter) => {
|
|
|
21
22
|
};
|
|
22
23
|
/**
|
|
23
24
|
* 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),
|
|
25
|
-
* informs the UI; every write is checked again by the write pipeline.
|
|
25
|
+
* operation's row filter (checked in the database), a record-level rule must pass for the stored record, and its parents
|
|
26
|
+
* (`.parent()`) must allow writing it. This only informs the UI; every write is checked again by the write pipeline.
|
|
26
27
|
*/
|
|
27
28
|
const rowPermissions = async (trx, access, rows) => {
|
|
28
29
|
const { resolved, entry, session } = access;
|
|
@@ -63,7 +64,23 @@ const rowPermissions = async (trx, access, rows) => {
|
|
|
63
64
|
if (decision !== true) result[i][operation] = (await matching(trx, access, [row], decision)).size > 0;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
await withParents(trx, access, rows, result);
|
|
66
68
|
return result;
|
|
67
69
|
};
|
|
70
|
+
const withParents = async (trx, access, rows, result) => {
|
|
71
|
+
const { full, entry, session } = access;
|
|
72
|
+
if (!full.parents?.length) return;
|
|
73
|
+
const guard = parentGuard(trx, access, false);
|
|
74
|
+
const fields = [.../* @__PURE__ */ new Set([...full.primaryKey, ...full.parents.map((parent) => parent.field)])].map((name) => full.fields[name]);
|
|
75
|
+
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
76
|
+
for (const [i, row] of rows.entries()) {
|
|
77
|
+
const permissions = result[i];
|
|
78
|
+
if (!permissions.update && !permissions.delete) continue;
|
|
79
|
+
const stored = await fetchRecord(trx, entry, full.primaryKey.map((name) => row[name]), tenant, { fields });
|
|
80
|
+
const allowed = stored !== void 0 && await guard(stored.record);
|
|
81
|
+
permissions.update &&= allowed;
|
|
82
|
+
permissions.delete &&= allowed;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
68
85
|
//#endregion
|
|
69
86
|
export { rowPermissions };
|
package/dist/write-pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ import { isDatabaseError } from "./error-handler.js";
|
|
|
8
8
|
import { attachFiles } from "./files/attach.js";
|
|
9
9
|
import { actorSettings, wakeDispatch } from "./changes/actor.js";
|
|
10
10
|
import { validateBody } from "./validate-body.js";
|
|
11
|
-
import { assertWritten, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
|
|
11
|
+
import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
|
|
12
12
|
import { applyUpdate, planUpdate } from "./write-update.js";
|
|
13
13
|
import { checkRankAfter } from "./rank-after.js";
|
|
14
14
|
//#region src/write-pipeline.ts
|
|
@@ -31,8 +31,11 @@ const create = async (trx, deps, access, body) => {
|
|
|
31
31
|
...tenantValues
|
|
32
32
|
};
|
|
33
33
|
const filter = await decide(access, "create", void 0, row);
|
|
34
|
+
const parents = checkParents(trx, access, "create");
|
|
35
|
+
await parents(row);
|
|
34
36
|
const created = await insertRecord(trx, entry, row, { etagFields: Object.keys(access.model.fields) });
|
|
35
37
|
await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
|
|
38
|
+
await parents(created.record);
|
|
36
39
|
if (full.position) await checkRankAfter(trx, deps, access, full.position.field, [{
|
|
37
40
|
key: keyOf(access, created.record),
|
|
38
41
|
before: null
|
|
@@ -77,6 +80,7 @@ const removeOrRestore = async (trx, deps, access, request) => {
|
|
|
77
80
|
filter,
|
|
78
81
|
fields: [firstKeyField(access)]
|
|
79
82
|
})) throw denied(entry, operation);
|
|
83
|
+
await checkParents(trx, access, operation)(current.record);
|
|
80
84
|
const notify = notifier(deps, access);
|
|
81
85
|
if (undelete) {
|
|
82
86
|
const restored = await restoreRecord(trx, entry, request.key, scope?.tenantValue, shape);
|
package/dist/write-shared.js
CHANGED
|
@@ -5,6 +5,7 @@ import { presentFiles } from "./files/present.js";
|
|
|
5
5
|
import { ifMatchSatisfied } from "./etag.js";
|
|
6
6
|
import { pickFields } from "./rows.js";
|
|
7
7
|
import { fetchRecord } from "./records.js";
|
|
8
|
+
import { parentGuard } from "./parent-access.js";
|
|
8
9
|
import { scheduleRemovedFiles } from "./files/attach.js";
|
|
9
10
|
//#region src/write-shared.ts
|
|
10
11
|
const missing = (entry) => notFound(`No ${entry.name} with this key`);
|
|
@@ -22,6 +23,17 @@ const decide = async (access, operation, record, input) => {
|
|
|
22
23
|
if (decision === false) throw denied(access.entry, operation);
|
|
23
24
|
return decision === true ? void 0 : decision;
|
|
24
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Refuses a write whose records' parents (`.parent()`) do not give the caller the access it needs, with the same answer
|
|
28
|
+
* as a refused record-level rule; each parent stays locked until the write commits.
|
|
29
|
+
*/
|
|
30
|
+
const checkParents = (trx, access, operation) => {
|
|
31
|
+
if (!access.full.parents?.length) return async (..._records) => {};
|
|
32
|
+
const guard = parentGuard(trx, access, true);
|
|
33
|
+
return async (...records) => {
|
|
34
|
+
if (!await guard(...records)) throw denied(access.entry, operation);
|
|
35
|
+
};
|
|
36
|
+
};
|
|
25
37
|
const keyOf = (access, record) => access.full.primaryKey.map((name) => record[name]);
|
|
26
38
|
const firstKeyField = (access) => access.full.fields[access.full.primaryKey[0]];
|
|
27
39
|
const assertWritten = async (trx, access, written, operation, ...filters) => {
|
|
@@ -53,4 +65,4 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
|
|
|
53
65
|
await scheduleRemovedFiles(access, kind, before, after);
|
|
54
66
|
};
|
|
55
67
|
//#endregion
|
|
56
|
-
export { assertWritten, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown };
|
|
68
|
+
export { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown };
|
package/dist/write-update.js
CHANGED
|
@@ -3,11 +3,12 @@ import { targetFilter } from "./request-access.js";
|
|
|
3
3
|
import { fetchRecord, updateRecord } from "./records.js";
|
|
4
4
|
import { attachFiles } from "./files/attach.js";
|
|
5
5
|
import { validateBody } from "./validate-body.js";
|
|
6
|
-
import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
|
|
6
|
+
import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
|
|
7
7
|
//#region src/write-update.ts
|
|
8
8
|
/**
|
|
9
9
|
* Everything an update needs before it writes: the row (locked, inside the caller's tenant and row filters), the ETag
|
|
10
|
-
* precondition, validation against the fields the caller may write,
|
|
10
|
+
* precondition, validation against the fields the caller may write, the record-level rule with its filter, and the
|
|
11
|
+
* parents (`.parent()`) it leaves and moves to, checked in one call so they are locked in one order.
|
|
11
12
|
*/
|
|
12
13
|
const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
|
|
13
14
|
const { entry, full } = access;
|
|
@@ -27,10 +28,16 @@ const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
|
|
|
27
28
|
filter,
|
|
28
29
|
fields: [firstKeyField(access)]
|
|
29
30
|
})) throw denied(entry, "update");
|
|
31
|
+
const parents = checkParents(trx, access, "update");
|
|
32
|
+
if (Object.keys(values).length > 0) await parents(current.record, {
|
|
33
|
+
...current.record,
|
|
34
|
+
...values
|
|
35
|
+
});
|
|
30
36
|
return {
|
|
31
37
|
current,
|
|
32
38
|
values,
|
|
33
|
-
...filter && { filter }
|
|
39
|
+
...filter && { filter },
|
|
40
|
+
parents
|
|
34
41
|
};
|
|
35
42
|
};
|
|
36
43
|
/** Writes a planned update, checks the row as written against the filters, and runs the hooks. */
|
|
@@ -39,6 +46,7 @@ const applyUpdate = async (trx, deps, access, key, plan) => {
|
|
|
39
46
|
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
40
47
|
const updated = await updateRecord(trx, entry, key, tenant, plan.values, { etagFields: Object.keys(access.model.fields) });
|
|
41
48
|
await assertWritten(trx, access, updated, "update", access.resolved.rowFilter.update, plan.filter);
|
|
49
|
+
await plan.parents(updated.record);
|
|
42
50
|
await notifier(deps, access)(trx, "update", plan.current.record, updated.record);
|
|
43
51
|
return updated;
|
|
44
52
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.28.
|
|
36
|
-
"@protobase/query": "0.28.
|
|
37
|
-
"@protobase/schema": "0.28.
|
|
35
|
+
"@protobase/layout": "0.28.1",
|
|
36
|
+
"@protobase/query": "0.28.1",
|
|
37
|
+
"@protobase/schema": "0.28.1",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|