@jskit-ai/resource-runtime 0.2.13 → 0.2.14
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/CHANGELOG.md +1 -0
- package/package.json +1 -1
- package/src/server/handleHidden.js +16 -12
- package/test/hiddenFields.test.js +11 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- A handle's `joinSource()` carries the table's ownership fields (`workspaceId`, `userId`) as join keys, hidden or not, and `column()` names them: a report joining another owner's scoped rows on the workspace, such as a system job reading each workspace's settings beside its bookings, was refused (`contactRoles.joinSource() carries no hidden field, so it has no column for "workspaceId"`). They name the caller's own scope, so nothing secret leaves the owner; every other hidden column stays out, and `column()` still refuses it.
|
|
5
6
|
- Depends on json-rest-api 3.1.0 exactly (from 3.0.1), from the registry with its published integrity: `lockMode: "noKeyUpdate"`, ON CONFLICT on the matching unique index's exact columns, the PostgreSQL merge race on an uncommitted insert, and the `xid8` field type the change horizon reads.
|
|
6
7
|
- Tests pin three paths the guide now documents: a shared row of a `workspace_or_shared` table is written in the owner's root system context, naming `workspaceId: null`, also inside a request's transaction, and never from a request or a derived job context (`forUser` is refused 403); a job's `actorId` never moves its scope, while a row policy may hold a job done for a person to that person's rows; and a hand route's `request.callOptions()` scopes user, workspace and workspace_user tables as the same request's action does, with no user scope when no one is signed in.
|
|
7
8
|
- A table resource reads an xid8 change stamp through an `xid8` field, json-rest-api 3.1.0's type for PostgreSQL's transaction id: decimal text below 2^64, which filters such as `changedSince: { type: "xid8", actualField: "changed", filterOperator: ">=" }` compare as transaction ids in SQL. A malformed cursor is refused as `REST_API_VALIDATION` before any statement, where a string field sent it to PostgreSQL (22P02), and the field is refused at registration on MySQL, MariaDB and SQLite. With `changeHorizon()`, a read of what changed since goes through the handle, where it was a hand-written scoped query; the data-access guide's "What changed since" gives the migration (an xid8 column a trigger stamps on every insert and update), the field, the filters and the read.
|
package/package.json
CHANGED
|
@@ -134,8 +134,8 @@ const HANDLE_SCOPED_QUERIES = new WeakSet();
|
|
|
134
134
|
const HANDLE_WORK_RUNNERS = new WeakSet();
|
|
135
135
|
// The names an owner's own wrappers of those runners keep.
|
|
136
136
|
const WORK_RUNNER_NAMES = Object.freeze(new Set(["withTransaction", "lockOrCreate"]));
|
|
137
|
-
// The builders a handle's joinSource() answers, which carry the table's visible columns only: the
|
|
138
|
-
// withoutHiddenFields hands another feature.
|
|
137
|
+
// The builders a handle's joinSource() answers, which carry the table's visible columns and ownership fields only: the
|
|
138
|
+
// one query builder withoutHiddenFields hands another feature.
|
|
139
139
|
const JOIN_SOURCES = new WeakSet();
|
|
140
140
|
|
|
141
141
|
/**
|
|
@@ -151,13 +151,14 @@ function markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate }) {
|
|
|
151
151
|
/**
|
|
152
152
|
* A handle's joinSource(): the table as a join source for another owner's report, which joins it in its own
|
|
153
153
|
* statement. It answers `{ query, column }`: the rows scopedQuery() gives under `alias` (the caller's ownership, the
|
|
154
|
-
* row policy and `filters` applied) with the stored columns a read answers
|
|
155
|
-
*
|
|
154
|
+
* row policy and `filters` applied) with the stored columns a read answers and the ownership fields, hidden or not,
|
|
155
|
+
* since a report joins on them and they name the caller's own scope; never another hidden column. `column(field)`
|
|
156
|
+
* names each under that alias. A CRUD feature's repository answers it to any feature, where it
|
|
156
157
|
* refuses scopedQuery(). The builder comes wrapped, since a builder is thenable.
|
|
157
158
|
* @param {object} dependencies
|
|
158
159
|
* @param {{ knex: { instance: any } }} dependencies.api
|
|
159
160
|
* @param {{ name: string, schema: Record<string, any> }} dependencies.resource
|
|
160
|
-
* @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any
|
|
161
|
+
* @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any>, ownershipFields?: string[] } } }} dependencies.jsonRest
|
|
161
162
|
* @param {(params: { alias: string, filters?: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
|
|
162
163
|
* @param {(field: string, alias?: string) => string} dependencies.column
|
|
163
164
|
*/
|
|
@@ -173,22 +174,25 @@ function createJoinSource({ api, resource, jsonRest, scopedQuery, column }) {
|
|
|
173
174
|
throw new TypeError(`${name}.joinSource() needs the alias the report joins the ${name} under.`);
|
|
174
175
|
}
|
|
175
176
|
const { query } = await scopedQuery({ alias, ...(filters === undefined ? {} : { filters }) }, options);
|
|
176
|
-
const { schemaStructure } = jsonRest().vars.schemaInfo;
|
|
177
|
-
const
|
|
177
|
+
const { schemaStructure, ownershipFields = [] } = jsonRest().vars.schemaInfo;
|
|
178
|
+
const joinKeys = new Set(ownershipFields);
|
|
179
|
+
const carried = Object.keys(schemaStructure).filter((field) => {
|
|
178
180
|
const definition = schemaStructure[field];
|
|
179
|
-
return field !== "id" && definition.hidden !== true
|
|
181
|
+
return field !== "id" && (definition.hidden !== true || joinKeys.has(field)) &&
|
|
182
|
+
definition.virtual !== true && !definition.belongsToPolymorphic;
|
|
180
183
|
});
|
|
181
|
-
// The scoped rows' visible columns, as a derived table under the alias: nothing a caller adds
|
|
184
|
+
// The scoped rows' visible columns and ownership fields, as a derived table under the alias: nothing a caller adds
|
|
185
|
+
// reaches the others.
|
|
182
186
|
const source = readOnlyQueryBuilder(
|
|
183
|
-
(options.trx || api.knex.instance).from(query.select(["id", ...
|
|
187
|
+
(options.trx || api.knex.instance).from(query.select(["id", ...carried].map((field) => column(field, alias))).as(alias)),
|
|
184
188
|
{ resourceType: name }
|
|
185
189
|
);
|
|
186
190
|
JOIN_SOURCES.add(source);
|
|
187
191
|
return Object.freeze({
|
|
188
192
|
query: source,
|
|
189
193
|
column(field) {
|
|
190
|
-
if (resource.schema[field]?.hidden === true) {
|
|
191
|
-
throw new TypeError(`${name}.joinSource() carries no hidden field, so it has no column for "${field}".`);
|
|
194
|
+
if (resource.schema[field]?.hidden === true && !joinKeys.has(field)) {
|
|
195
|
+
throw new TypeError(`${name}.joinSource() carries no hidden field but its ownership fields, so it has no column for "${field}".`);
|
|
192
196
|
}
|
|
193
197
|
return column(field, alias);
|
|
194
198
|
}
|
|
@@ -114,7 +114,7 @@ test("withoutHiddenFields refuses another feature's read naming a hidden field,
|
|
|
114
114
|
assert.equal(withoutHiddenFields(cards, plain), cards);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column", async (t) => {
|
|
117
|
+
test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column but the ownership fields", async (t) => {
|
|
118
118
|
const { knex, cards, resource, system, options } = await createCardFixture(t);
|
|
119
119
|
// Methods an owner's decorateRepository adds with the handle it receives, answering its scoped rows as scopedQuery()
|
|
120
120
|
// gives them, every column with them: wrapped, and at once.
|
|
@@ -145,13 +145,20 @@ test("withoutHiddenFields refuses another feature's scopedQuery, and any builder
|
|
|
145
145
|
[{ token: "t1" }, { token: "t2" }, { token: "t3" }]);
|
|
146
146
|
|
|
147
147
|
// The cross-owner report pattern: the table's joinSource() answers the caller's scoped rows, which the report
|
|
148
|
-
// selects from, with the columns a read answers and no other.
|
|
148
|
+
// selects from, with the columns a read answers and the ownership fields, and no other.
|
|
149
149
|
const source = await exposed.joinSource({ alias: "c" }, options);
|
|
150
150
|
const rows = await source.query.select({ title: source.column("title") }).orderBy(source.column("id"));
|
|
151
151
|
assert.deepEqual(rows, [{ title: "Rex" }, { title: "Bo" }]);
|
|
152
152
|
const { query: rex } = await exposed.joinSource({ alias: "c", filters: { title: "Rex" } }, options);
|
|
153
|
-
assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "title"]]);
|
|
154
|
-
assert.throws(() => source.column("publicToken"),
|
|
153
|
+
assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "workspace_id", "title"]]);
|
|
154
|
+
assert.throws(() => source.column("publicToken"),
|
|
155
|
+
/cards\.joinSource\(\) carries no hidden field but its ownership fields, so it has no column for "publicToken"/u);
|
|
156
|
+
// The hidden workspaceId is a join key the source carries: a system report counts each workspace's cards on it.
|
|
157
|
+
const perWorkspace = await exposed.joinSource({ alias: "c" }, { context: system });
|
|
158
|
+
const workspaceId = perWorkspace.column("workspaceId");
|
|
159
|
+
const counts = await perWorkspace.query.select({ workspaceId }).count({ cards: perWorkspace.column("id") })
|
|
160
|
+
.groupBy(workspaceId).orderBy(workspaceId);
|
|
161
|
+
assert.deepEqual(counts.map((row) => [Number(row.workspaceId), Number(row.cards)]), [[1, 2], [2, 1]]);
|
|
155
162
|
const again = await exposed.joinSource({ alias: "c" }, options);
|
|
156
163
|
await assert.rejects(async () => again.query.select(cards.column("publicToken", "c")), /public_token/u);
|
|
157
164
|
await assert.rejects(exposed.joinSource({}, options), /cards\.joinSource\(\) needs the alias the report joins the cards under/u);
|