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