@protobase/server 0.0.0-stage → 0.1.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/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 +1252 -0
- package/dist/better-auth/create-auth.js +104 -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 +19 -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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { badRequest } from "./problem.js";
|
|
2
|
+
import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
|
|
3
|
+
//#region src/filter-input.ts
|
|
4
|
+
const filterProblem = (parameter, errors) => badRequest(parameter === "filter" ? "invalid-filter" : "invalid-order-by", `The ${parameter} parameter is invalid: ${errors[0].message}`, {
|
|
5
|
+
parameter,
|
|
6
|
+
errors
|
|
7
|
+
});
|
|
8
|
+
/** AIP-160 text to a checked filter; throws a 400 problem carrying the checker's errors with spans and hints. */
|
|
9
|
+
const checkedFilter = (model, source, maxLength) => {
|
|
10
|
+
if (!source?.trim()) return void 0;
|
|
11
|
+
const parsed = parseFilter(source, maxLength ? { maxLength } : {});
|
|
12
|
+
if (!parsed.ok) throw filterProblem("filter", parsed.errors);
|
|
13
|
+
if (!parsed.ast) return void 0;
|
|
14
|
+
const checked = checkFilter(model, parsed.ast);
|
|
15
|
+
if (!checked.ok) throw filterProblem("filter", checked.errors);
|
|
16
|
+
return checked.filter;
|
|
17
|
+
};
|
|
18
|
+
/** AIP-132 `order_by` text to a sort spec; throws a 400 problem on any error. */
|
|
19
|
+
const checkedSort = (model, source) => {
|
|
20
|
+
if (!source?.trim()) return void 0;
|
|
21
|
+
const parsed = parseOrderBy(source);
|
|
22
|
+
if (!parsed.ok) throw filterProblem("order_by", parsed.errors);
|
|
23
|
+
const checked = checkOrderBy(model, parsed.items);
|
|
24
|
+
if (!checked.ok) throw filterProblem("order_by", checked.errors);
|
|
25
|
+
return checked.sort;
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { checkedFilter, checkedSort };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/filter-shape.ts
|
|
2
|
+
/** The filter with its values blanked out: queries that differ only in values share a shape. */
|
|
3
|
+
const filterShape = (filter) => {
|
|
4
|
+
if (!filter) return "";
|
|
5
|
+
switch (filter.kind) {
|
|
6
|
+
case "and":
|
|
7
|
+
case "or": return `${filter.kind}(${filter.args.map(filterShape).join(",")})`;
|
|
8
|
+
case "not": return `not(${filterShape(filter.arg)})`;
|
|
9
|
+
case "compare": return `${filter.field.name}${filter.op}?`;
|
|
10
|
+
case "search": return "search";
|
|
11
|
+
default: return `${filter.kind}:${filter.field.name}`;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const filterFields = (filter) => {
|
|
15
|
+
if (!filter) return [];
|
|
16
|
+
switch (filter.kind) {
|
|
17
|
+
case "and":
|
|
18
|
+
case "or": return filter.args.flatMap(filterFields);
|
|
19
|
+
case "not": return filterFields(filter.arg);
|
|
20
|
+
case "search": return [];
|
|
21
|
+
default: return [filter.field];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { filterFields, filterShape };
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/hash.ts
|
|
2
|
+
/** Hex SHA-256 of a string, through Web Crypto so it runs on Node and Workers. */
|
|
3
|
+
const sha256Hex = async (text) => {
|
|
4
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
5
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
};
|
|
7
|
+
/** JSON with object keys sorted, so equal content always serializes equally. */
|
|
8
|
+
const stableJson = (value) => JSON.stringify(value, (_, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : v);
|
|
9
|
+
//#endregion
|
|
10
|
+
export { sha256Hex, stableJson };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AdminAuth, CreateAuthOptions, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
2
|
+
import { ResourceSource, configExports } from "./resource-source.js";
|
|
3
|
+
import { Admin, AdminOptions, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
|
|
4
|
+
import { CreateAdminInput, createAdmin } from "./create-admin.js";
|
|
5
|
+
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
6
|
+
import { JwtOptions, jwtAuthenticator } from "./auth/jwt.js";
|
|
7
|
+
import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
|
|
8
|
+
import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator } from "./better-auth/authenticator.js";
|
|
9
|
+
import { NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
|
|
10
|
+
import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
|
|
11
|
+
export { type Admin, type AdminAuth, type AdminOptions, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, type NewUser, type PipelineHook, type ResourceSource, type ScanGuardMode, type Session, type StoredUser, type TenantValue, type User, type ViewSource, type WriteEvent, type WriteOperation, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, createAdmin, createAuth, createUser, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, roleChoices, setUserBanned, setUserRole, unauthorized };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
2
|
+
import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
3
|
+
import { createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
|
|
4
|
+
import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
|
|
5
|
+
import { createAdmin } from "./create-admin.js";
|
|
6
|
+
import { configExports } from "./resource-source.js";
|
|
7
|
+
import { jwtAuthenticator } from "./auth/jwt.js";
|
|
8
|
+
import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
|
|
9
|
+
import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
|
|
10
|
+
export { HttpProblem, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, createAdmin, createAuth, createUser, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, roleChoices, setUserBanned, setUserRole, unauthorized };
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { badRequest } from "./problem.js";
|
|
2
|
+
import { decodeKey } from "@protobase/schema";
|
|
3
|
+
//#region src/key.ts
|
|
4
|
+
/** Decodes key text (`a,b` for composite keys, parts percent-encoded). decodeKey is a pure parser: every error it throws is a malformed key. */
|
|
5
|
+
const decodeKeyText = (entry, raw) => {
|
|
6
|
+
try {
|
|
7
|
+
const key = decodeKey(raw, entry.keyTypes);
|
|
8
|
+
return Array.isArray(key) ? key : [key];
|
|
9
|
+
} catch {
|
|
10
|
+
throw badRequest("invalid-key", `"${raw}" is not a valid ${entry.name} key`);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The key from the `:key` path segment, minus a custom method `suffix` such as `:undelete`. A single key is Hono's decoded
|
|
15
|
+
* param; composite parts stay percent-encoded so an encoded comma inside a part survives.
|
|
16
|
+
*/
|
|
17
|
+
const keyFromPath = (c, entry, suffix = "") => {
|
|
18
|
+
const segment = entry.keyTypes.length === 1 ? c.req.param("key") : new URL(c.req.url).pathname.split("/").pop();
|
|
19
|
+
return decodeKeyText(entry, suffix && segment.endsWith(suffix) ? segment.slice(0, -suffix.length) : segment);
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { decodeKeyText, keyFromPath };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { checkedFilter, checkedSort } from "./filter-input.js";
|
|
2
|
+
import { readScope, requestAccess, requireOperation } from "./request-access.js";
|
|
3
|
+
import { readTransaction } from "./transactions.js";
|
|
4
|
+
import { etagAlias } from "./sql-parts.js";
|
|
5
|
+
import { etagOf } from "./etag.js";
|
|
6
|
+
import { exposeRow, pickFields } from "./rows.js";
|
|
7
|
+
import { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields } from "./params.js";
|
|
8
|
+
import { rowPermissions } from "./row-permissions.js";
|
|
9
|
+
import { estimateCount, exactCount, listQuery, seekToPosition } from "@protobase/query";
|
|
10
|
+
//#region src/list-service.ts
|
|
11
|
+
/**
|
|
12
|
+
* AIP-132 list, the `:search` variant and (with `jump`) `:seek`: both take the same parameters, from the query string or a JSON body.
|
|
13
|
+
* Everything the caller names (filter, order_by, fields, search) is checked against the model as this caller sees it,
|
|
14
|
+
* so a hidden field is refused exactly like an unknown one; the access row filter is applied by the query layer.
|
|
15
|
+
*/
|
|
16
|
+
const listRecords = async (deps, entry, session, input, maxFilterLength, jump = false) => {
|
|
17
|
+
const params = jump ? parseParams(seekParams, input) : parseParams(listParams, input);
|
|
18
|
+
const access = await requestAccess(deps, entry, session);
|
|
19
|
+
requireOperation(access, "list");
|
|
20
|
+
const showDeleted = deletedRows(access.full, params.show_deleted) === "show";
|
|
21
|
+
const full = showDeleted ? {
|
|
22
|
+
...access.full,
|
|
23
|
+
softDelete: void 0
|
|
24
|
+
} : access.full;
|
|
25
|
+
const model = showDeleted ? {
|
|
26
|
+
...access.model,
|
|
27
|
+
softDelete: void 0
|
|
28
|
+
} : access.model;
|
|
29
|
+
const filter = checkedFilter(model, params.filter, maxFilterLength);
|
|
30
|
+
const sort = checkedSort(model, params.order_by);
|
|
31
|
+
const columns = selectedFields(model, params.fields);
|
|
32
|
+
const limit = pageSizeOf(params.page_size);
|
|
33
|
+
const scope = readScope(access, full);
|
|
34
|
+
const etagField = entry.etag && !model.fields[entry.etag.field.name] ? full.fields[entry.etag.field.name] : void 0;
|
|
35
|
+
const queryModel = etagField ? {
|
|
36
|
+
...model,
|
|
37
|
+
fields: {
|
|
38
|
+
...model.fields,
|
|
39
|
+
[etagField.name]: etagField
|
|
40
|
+
}
|
|
41
|
+
} : model;
|
|
42
|
+
const read = entry.etag && columns ? columns : Object.keys(model.fields);
|
|
43
|
+
const shape = {
|
|
44
|
+
...filter && { filter },
|
|
45
|
+
...sort && { sort },
|
|
46
|
+
limit
|
|
47
|
+
};
|
|
48
|
+
const warning = await deps.guard(entry, shape, {
|
|
49
|
+
model: queryModel,
|
|
50
|
+
scope
|
|
51
|
+
});
|
|
52
|
+
const position = jump ? params.position : void 0;
|
|
53
|
+
const result = await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
|
|
54
|
+
const extraColumns = entry.etag ? [{
|
|
55
|
+
field: entry.etag.field.name,
|
|
56
|
+
as: "text",
|
|
57
|
+
alias: etagAlias
|
|
58
|
+
}] : [];
|
|
59
|
+
const page = position === void 0 ? await listQuery(trx, queryModel, {
|
|
60
|
+
...shape,
|
|
61
|
+
columns: read,
|
|
62
|
+
...params.page_token && { cursor: params.page_token }
|
|
63
|
+
}, scope, { extraColumns }) : await seekToPosition(trx, queryModel, {
|
|
64
|
+
position,
|
|
65
|
+
orderBy: sort ?? [[entry.model.primaryKey[0], "asc"]],
|
|
66
|
+
...filter && { filter },
|
|
67
|
+
columns: read,
|
|
68
|
+
limit
|
|
69
|
+
}, scope, { extraColumns });
|
|
70
|
+
const rows = page.rows.map((row) => exposeRow(model, row));
|
|
71
|
+
return {
|
|
72
|
+
page,
|
|
73
|
+
rows,
|
|
74
|
+
etags: await Promise.all(rows.map((row, i) => etagOf(entry, page.rows[i], row))),
|
|
75
|
+
permissions: deps.rowPermissions(entry.name) ? await rowPermissions(trx, access, rows) : void 0,
|
|
76
|
+
estimate: await estimateCount(trx, queryModel, filter, scope),
|
|
77
|
+
...params.count === "exact" && { total: await exactCount(trx, queryModel, filter, scope) }
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
warning,
|
|
82
|
+
body: {
|
|
83
|
+
items: result.rows.map((row, i) => ({
|
|
84
|
+
...pickFields(row, columns),
|
|
85
|
+
etag: result.etags[i],
|
|
86
|
+
...result.permissions && { permissions: result.permissions[i] }
|
|
87
|
+
})),
|
|
88
|
+
next_page_token: result.page.nextCursor ?? "",
|
|
89
|
+
...position !== void 0 && { prev_page_token: result.page.prevCursor ?? "" },
|
|
90
|
+
total_size_estimate: result.estimate,
|
|
91
|
+
...result.total !== void 0 && { total_size: result.total }
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
export { listRecords };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/mail/mail-settings.ts
|
|
2
|
+
const smtpUrlVariable = "PROTOBASE_SMTP_URL";
|
|
3
|
+
const mailFromVariable = "PROTOBASE_MAIL_FROM";
|
|
4
|
+
const address = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
5
|
+
/**
|
|
6
|
+
* The SMTP server and sender the platform passes in `PROTOBASE_SMTP_URL` and `PROTOBASE_MAIL_FROM`; `undefined` when
|
|
7
|
+
* neither is set, which turns mail (and so password reset) off. One without the other, or a value that cannot be used,
|
|
8
|
+
* is a configuration error, so the server stops at startup instead of failing on the first reset request.
|
|
9
|
+
*/
|
|
10
|
+
const readMailSettings = (env) => {
|
|
11
|
+
const smtpUrl = env["PROTOBASE_SMTP_URL"] || void 0;
|
|
12
|
+
const from = env["PROTOBASE_MAIL_FROM"]?.trim() || void 0;
|
|
13
|
+
if (!smtpUrl && !from) return void 0;
|
|
14
|
+
if (!smtpUrl || !from) throw new Error(`${smtpUrlVariable} and ${mailFromVariable} go together: set both to send mail, or neither`);
|
|
15
|
+
if (!URL.canParse(smtpUrl)) throw new Error(`${smtpUrlVariable} is not a URL; use smtp://user:password@host:587 or smtps://...:465`);
|
|
16
|
+
const url = new URL(smtpUrl);
|
|
17
|
+
if (url.protocol !== "smtp:" && url.protocol !== "smtps:") throw new Error(`${smtpUrlVariable} must start with smtp:// or smtps://, not ${url.protocol}//`);
|
|
18
|
+
if (!url.hostname) throw new Error(`${smtpUrlVariable} has no host`);
|
|
19
|
+
if (!address.test(from)) throw new Error(`${mailFromVariable} must be a plain email address, like noreply@example.com`);
|
|
20
|
+
return {
|
|
21
|
+
smtpUrl,
|
|
22
|
+
from
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { mailFromVariable, readMailSettings, smtpUrlVariable };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/mail/smtp-mailer.d.ts
|
|
2
|
+
export type MailMessage = {
|
|
3
|
+
to: string;
|
|
4
|
+
subject: string;
|
|
5
|
+
text: string;
|
|
6
|
+
};
|
|
7
|
+
/** Sends one message; rejects when the server refuses it. */
|
|
8
|
+
export type Mailer = {
|
|
9
|
+
send: (message: MailMessage) => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/mail/smtp-mailer.ts
|
|
2
|
+
/**
|
|
3
|
+
* Plain-text mail over SMTP with Nodemailer: the most used Node SMTP client, maintained, with no dependencies of its
|
|
4
|
+
* own. It is loaded on the first message, so `@protobase/server` itself stays free of Node's network modules.
|
|
5
|
+
*/
|
|
6
|
+
const smtpMailer = (settings) => {
|
|
7
|
+
let transport;
|
|
8
|
+
return { send: async (message) => {
|
|
9
|
+
transport ??= import("nodemailer").then(({ createTransport }) => createTransport(settings.smtpUrl));
|
|
10
|
+
await (await transport).sendMail({
|
|
11
|
+
from: settings.from,
|
|
12
|
+
...message
|
|
13
|
+
});
|
|
14
|
+
} };
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { smtpMailer };
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sha256Hex, stableJson } from "./hash.js";
|
|
2
|
+
import { requestAccess } from "./request-access.js";
|
|
3
|
+
import { pruneView } from "./view-access.js";
|
|
4
|
+
import { prunePage } from "@protobase/layout";
|
|
5
|
+
import { pickView } from "@protobase/schema";
|
|
6
|
+
//#region src/meta.ts
|
|
7
|
+
const modelsHash = (deps) => sha256Hex(stableJson(deps.registry.entries.map((entry) => entry.model))).then((hash) => hash.slice(0, 32));
|
|
8
|
+
/**
|
|
9
|
+
* `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
|
|
10
|
+
* absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
|
|
11
|
+
* user menu without the resources they cannot see.
|
|
12
|
+
* The ETag hashes all of it, so it differs per user and per roles.
|
|
13
|
+
*/
|
|
14
|
+
const createMeta = (deps) => {
|
|
15
|
+
const base = modelsHash(deps);
|
|
16
|
+
const forCaller = async (session) => {
|
|
17
|
+
const visible = (await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, session)))).filter((access) => Object.values(access.resolved.operations).some(Boolean));
|
|
18
|
+
const listed = Object.fromEntries(visible.map((access) => [access.entry.name, access.model]));
|
|
19
|
+
const views = visible.flatMap((access) => {
|
|
20
|
+
const view = pickView(deps.views, session.user.roles, access.entry.name);
|
|
21
|
+
return view ? [pruneView(view, access.readable, listed)] : [];
|
|
22
|
+
});
|
|
23
|
+
const callerAccess = {
|
|
24
|
+
models: listed,
|
|
25
|
+
permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
|
|
26
|
+
create: resolved.operations.create,
|
|
27
|
+
update: resolved.operations.update
|
|
28
|
+
}])),
|
|
29
|
+
actions: (resource) => new Set(views.find((view) => view.resource === resource)?.actions.map((action) => action.name) ?? [])
|
|
30
|
+
};
|
|
31
|
+
const pages = deps.pages.flatMap((page) => prunePage(page, session.user.roles, callerAccess) ?? []);
|
|
32
|
+
const shown = (item) => item.kind === "link" || (item.kind === "resource" ? visible.some((access) => access.entry.name === item.resource) : pages.some((page) => page.name === item.page));
|
|
33
|
+
const body = {
|
|
34
|
+
resources: visible.map((access) => access.readable),
|
|
35
|
+
views,
|
|
36
|
+
pages,
|
|
37
|
+
permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
|
|
38
|
+
read: resolved.operations.read || resolved.operations.list,
|
|
39
|
+
create: resolved.operations.create,
|
|
40
|
+
update: resolved.operations.update,
|
|
41
|
+
delete: resolved.operations.delete,
|
|
42
|
+
conditional: [
|
|
43
|
+
"create",
|
|
44
|
+
"update",
|
|
45
|
+
"delete"
|
|
46
|
+
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
|
|
47
|
+
}])),
|
|
48
|
+
...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } }
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
body,
|
|
52
|
+
etag: `"${(await sha256Hex(stableJson(body))).slice(0, 32)}"`
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** The X-Meta-Version header: the models, and the roles the access depends on, so a role change moves it. */
|
|
56
|
+
const version = async (roles) => (await sha256Hex(`${await base}|${[...roles].sort().join(",")}`)).slice(0, 32);
|
|
57
|
+
return {
|
|
58
|
+
forCaller,
|
|
59
|
+
version
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { createMeta };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
//#region src/openapi/components.ts
|
|
2
|
+
const problemContent = { "application/problem+json": { schema: { $ref: "#/components/schemas/Problem" } } };
|
|
3
|
+
const problem = (description) => ({
|
|
4
|
+
description,
|
|
5
|
+
content: problemContent
|
|
6
|
+
});
|
|
7
|
+
const problemSchema = {
|
|
8
|
+
type: "object",
|
|
9
|
+
description: "RFC 9457 problem details",
|
|
10
|
+
required: [
|
|
11
|
+
"type",
|
|
12
|
+
"title",
|
|
13
|
+
"status",
|
|
14
|
+
"detail"
|
|
15
|
+
],
|
|
16
|
+
properties: {
|
|
17
|
+
type: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "A URN naming the kind of problem"
|
|
20
|
+
},
|
|
21
|
+
title: { type: "string" },
|
|
22
|
+
status: { type: "integer" },
|
|
23
|
+
detail: { type: "string" },
|
|
24
|
+
instance: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "The request path"
|
|
27
|
+
},
|
|
28
|
+
parameter: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "For filter and order_by errors, the offending parameter"
|
|
31
|
+
},
|
|
32
|
+
errors: {
|
|
33
|
+
type: "array",
|
|
34
|
+
description: "Filter and order_by errors (code, message, hint, span) or field errors (field, code, message)",
|
|
35
|
+
items: {
|
|
36
|
+
type: "object",
|
|
37
|
+
additionalProperties: true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: true
|
|
42
|
+
};
|
|
43
|
+
const errorResponses = {
|
|
44
|
+
BadRequest: problem("Invalid filter, order_by, parameter, key or body"),
|
|
45
|
+
Unauthorized: problem("Missing or invalid credentials"),
|
|
46
|
+
Forbidden: problem("The caller may not do this, or has no tenant"),
|
|
47
|
+
NotFound: problem("No such resource or record, or the record belongs to another tenant"),
|
|
48
|
+
PreconditionFailed: problem("If-Match no longer matches the record"),
|
|
49
|
+
PreconditionRequired: problem("If-Match is required"),
|
|
50
|
+
Conflict: problem("A unique or foreign key constraint rejected the write")
|
|
51
|
+
};
|
|
52
|
+
const parameters = {
|
|
53
|
+
filter: {
|
|
54
|
+
name: "filter",
|
|
55
|
+
in: "query",
|
|
56
|
+
description: [
|
|
57
|
+
"An [AIP-160](https://google.aip.dev/160) filter, for example `status = \"confirmed\" AND total > 100`.",
|
|
58
|
+
"Only filterable fields can be used.",
|
|
59
|
+
"Functions: `in(field, v1, v2, ...)`, `search(\"text\")` (also bare words), `similar(field, \"text\")`, `regex(field, \"pattern\")`, `isNull(field)`, `now()` with a duration offset such as `now() - 7d`.",
|
|
60
|
+
"Syntax errors come back as a problem with `errors` carrying spans and hints."
|
|
61
|
+
].join(" "),
|
|
62
|
+
schema: { type: "string" }
|
|
63
|
+
},
|
|
64
|
+
orderBy: {
|
|
65
|
+
name: "order_by",
|
|
66
|
+
in: "query",
|
|
67
|
+
description: "Comma separated sortable fields, each with an optional `desc`, as in [AIP-132](https://google.aip.dev/132#ordering): `createdAt desc, number`. The primary key breaks ties.",
|
|
68
|
+
schema: { type: "string" }
|
|
69
|
+
},
|
|
70
|
+
pageSize: {
|
|
71
|
+
name: "page_size",
|
|
72
|
+
in: "query",
|
|
73
|
+
description: "As in [AIP-158](https://google.aip.dev/158): default 50, maximum 500 (larger values are coerced).",
|
|
74
|
+
schema: {
|
|
75
|
+
type: "integer",
|
|
76
|
+
minimum: 0,
|
|
77
|
+
maximum: 500
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
pageToken: {
|
|
81
|
+
name: "page_token",
|
|
82
|
+
in: "query",
|
|
83
|
+
description: "The `next_page_token` of the previous page",
|
|
84
|
+
schema: { type: "string" }
|
|
85
|
+
},
|
|
86
|
+
fields: {
|
|
87
|
+
name: "fields",
|
|
88
|
+
in: "query",
|
|
89
|
+
description: "Comma separated field names to return",
|
|
90
|
+
schema: { type: "string" }
|
|
91
|
+
},
|
|
92
|
+
count: {
|
|
93
|
+
name: "count",
|
|
94
|
+
in: "query",
|
|
95
|
+
description: "`exact` adds `total_size`, a real count(*)",
|
|
96
|
+
schema: {
|
|
97
|
+
type: "string",
|
|
98
|
+
enum: ["exact"]
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
key: {
|
|
102
|
+
name: "key",
|
|
103
|
+
in: "path",
|
|
104
|
+
required: true,
|
|
105
|
+
description: "The primary key. Composite keys are comma separated parts, each percent-encoded.",
|
|
106
|
+
schema: { type: "string" }
|
|
107
|
+
},
|
|
108
|
+
ifMatch: {
|
|
109
|
+
name: "If-Match",
|
|
110
|
+
in: "header",
|
|
111
|
+
required: true,
|
|
112
|
+
description: "The `etag` of the record, from a GET or a list item; `*` matches any version",
|
|
113
|
+
schema: { type: "string" }
|
|
114
|
+
},
|
|
115
|
+
ifMatchOptional: {
|
|
116
|
+
name: "If-Match",
|
|
117
|
+
in: "header",
|
|
118
|
+
description: "When sent, the operation only happens if it matches the current `etag` ([AIP-135](https://google.aip.dev/135)); `*` matches any version",
|
|
119
|
+
schema: { type: "string" }
|
|
120
|
+
},
|
|
121
|
+
showDeleted: {
|
|
122
|
+
name: "show_deleted",
|
|
123
|
+
in: "query",
|
|
124
|
+
description: "Also return soft-deleted records ([AIP-164](https://google.aip.dev/164)); only on resources that soft delete",
|
|
125
|
+
schema: { type: "boolean" }
|
|
126
|
+
},
|
|
127
|
+
prefer: {
|
|
128
|
+
name: "Prefer",
|
|
129
|
+
in: "header",
|
|
130
|
+
description: "`return=minimal` or `return=representation` (default)",
|
|
131
|
+
schema: { type: "string" }
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const searchBody = {
|
|
135
|
+
type: "object",
|
|
136
|
+
description: "The list parameters as JSON, for filters too long for a URL",
|
|
137
|
+
properties: {
|
|
138
|
+
filter: {
|
|
139
|
+
type: "string",
|
|
140
|
+
description: parameters.filter.description
|
|
141
|
+
},
|
|
142
|
+
order_by: { type: "string" },
|
|
143
|
+
page_size: { type: "integer" },
|
|
144
|
+
page_token: { type: "string" },
|
|
145
|
+
fields: {
|
|
146
|
+
type: "array",
|
|
147
|
+
items: { type: "string" }
|
|
148
|
+
},
|
|
149
|
+
count: {
|
|
150
|
+
type: "string",
|
|
151
|
+
enum: ["exact"]
|
|
152
|
+
},
|
|
153
|
+
show_deleted: {
|
|
154
|
+
type: "boolean",
|
|
155
|
+
description: "Soft-delete resources only"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const batchSchemas = {
|
|
160
|
+
BatchRef: {
|
|
161
|
+
type: "object",
|
|
162
|
+
description: "Points at a record created earlier in the batch by its `ref` name: in a key it stands for that record's key, in `data` for its key (or, with `field`, that field's value)",
|
|
163
|
+
required: ["$ref"],
|
|
164
|
+
additionalProperties: { type: "string" }
|
|
165
|
+
},
|
|
166
|
+
BatchKey: {
|
|
167
|
+
description: "A record key: its text as in a URL (`a,b` when composite), a number, a list of parts, or a `$ref`",
|
|
168
|
+
anyOf: [
|
|
169
|
+
{ type: "string" },
|
|
170
|
+
{ type: "number" },
|
|
171
|
+
{
|
|
172
|
+
type: "array",
|
|
173
|
+
items: { anyOf: [
|
|
174
|
+
{ type: "string" },
|
|
175
|
+
{ type: "number" },
|
|
176
|
+
{ $ref: "#/components/schemas/BatchRef" }
|
|
177
|
+
] }
|
|
178
|
+
},
|
|
179
|
+
{ $ref: "#/components/schemas/BatchRef" }
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
BatchRequest: {
|
|
183
|
+
type: "object",
|
|
184
|
+
required: ["ops"],
|
|
185
|
+
properties: { ops: {
|
|
186
|
+
type: "array",
|
|
187
|
+
minItems: 1,
|
|
188
|
+
maxItems: 500,
|
|
189
|
+
description: "Run in order inside one transaction through the normal write pipeline; all or nothing",
|
|
190
|
+
items: { oneOf: [
|
|
191
|
+
{
|
|
192
|
+
type: "object",
|
|
193
|
+
required: [
|
|
194
|
+
"op",
|
|
195
|
+
"resource",
|
|
196
|
+
"data"
|
|
197
|
+
],
|
|
198
|
+
properties: {
|
|
199
|
+
op: { const: "create" },
|
|
200
|
+
resource: { type: "string" },
|
|
201
|
+
data: {
|
|
202
|
+
type: "object",
|
|
203
|
+
additionalProperties: true
|
|
204
|
+
},
|
|
205
|
+
ref: {
|
|
206
|
+
type: "string",
|
|
207
|
+
description: "Names the created record for later `$ref`s"
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
additionalProperties: false
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
type: "object",
|
|
214
|
+
required: [
|
|
215
|
+
"op",
|
|
216
|
+
"resource",
|
|
217
|
+
"key",
|
|
218
|
+
"data"
|
|
219
|
+
],
|
|
220
|
+
properties: {
|
|
221
|
+
op: { const: "update" },
|
|
222
|
+
resource: { type: "string" },
|
|
223
|
+
key: { $ref: "#/components/schemas/BatchKey" },
|
|
224
|
+
etag: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description: "Required, as `If-Match` on a single update; `*` matches any version"
|
|
227
|
+
},
|
|
228
|
+
data: {
|
|
229
|
+
type: "object",
|
|
230
|
+
additionalProperties: true
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
additionalProperties: false
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
type: "object",
|
|
237
|
+
required: [
|
|
238
|
+
"op",
|
|
239
|
+
"resource",
|
|
240
|
+
"key"
|
|
241
|
+
],
|
|
242
|
+
properties: {
|
|
243
|
+
op: { const: "delete" },
|
|
244
|
+
resource: { type: "string" },
|
|
245
|
+
key: { $ref: "#/components/schemas/BatchKey" },
|
|
246
|
+
etag: {
|
|
247
|
+
type: "string",
|
|
248
|
+
description: "Optional, as `If-Match` on a single delete"
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
additionalProperties: false
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
type: "object",
|
|
255
|
+
required: [
|
|
256
|
+
"op",
|
|
257
|
+
"resource",
|
|
258
|
+
"field",
|
|
259
|
+
"keys"
|
|
260
|
+
],
|
|
261
|
+
properties: {
|
|
262
|
+
op: { const: "reorder" },
|
|
263
|
+
resource: { type: "string" },
|
|
264
|
+
field: {
|
|
265
|
+
type: "string",
|
|
266
|
+
description: "An integer field the caller may write, for example `position`"
|
|
267
|
+
},
|
|
268
|
+
keys: {
|
|
269
|
+
type: "array",
|
|
270
|
+
minItems: 1,
|
|
271
|
+
items: { $ref: "#/components/schemas/BatchKey" },
|
|
272
|
+
description: "The records in their final order: `field` becomes 1, 2, 3, ..."
|
|
273
|
+
},
|
|
274
|
+
etags: {
|
|
275
|
+
type: "object",
|
|
276
|
+
additionalProperties: { type: "string" },
|
|
277
|
+
description: "ETag per key (the key as text) for every record whose value changes"
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
additionalProperties: false
|
|
281
|
+
}
|
|
282
|
+
] }
|
|
283
|
+
} }
|
|
284
|
+
},
|
|
285
|
+
BatchResponse: {
|
|
286
|
+
type: "object",
|
|
287
|
+
required: ["results"],
|
|
288
|
+
properties: { results: {
|
|
289
|
+
type: "array",
|
|
290
|
+
description: "One result per operation, in order: records come with their new `etag`",
|
|
291
|
+
items: {
|
|
292
|
+
type: "object",
|
|
293
|
+
required: ["op", "resource"],
|
|
294
|
+
properties: {
|
|
295
|
+
op: {
|
|
296
|
+
type: "string",
|
|
297
|
+
enum: [
|
|
298
|
+
"create",
|
|
299
|
+
"update",
|
|
300
|
+
"delete",
|
|
301
|
+
"reorder"
|
|
302
|
+
]
|
|
303
|
+
},
|
|
304
|
+
resource: { type: "string" },
|
|
305
|
+
ref: { type: "string" },
|
|
306
|
+
record: {
|
|
307
|
+
type: "object",
|
|
308
|
+
additionalProperties: true
|
|
309
|
+
},
|
|
310
|
+
etag: { type: "string" },
|
|
311
|
+
deleted: { type: "boolean" },
|
|
312
|
+
records: {
|
|
313
|
+
type: "array",
|
|
314
|
+
items: {
|
|
315
|
+
type: "object",
|
|
316
|
+
properties: {
|
|
317
|
+
record: {
|
|
318
|
+
type: "object",
|
|
319
|
+
additionalProperties: true
|
|
320
|
+
},
|
|
321
|
+
etag: { type: "string" }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} }
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
export { batchSchemas, errorResponses, parameters, problemSchema, searchBody };
|