pgkiln 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
+ import pg from 'pg';
3
+ import { owner, runtime, type Client } from '../db.ts';
4
+ import { english } from '../i18n.ts';
5
+ import { loadApp, type App } from '../metadata.ts';
6
+ import { clientIp } from '../session.ts';
7
+ import { availabilityRefusal } from './availability.ts';
8
+ import { publicError } from './context.ts';
9
+ import { caller, RestError, restServicesOn } from './rest.ts';
10
+
11
+ // (sprint 100, Database Actions DA-11 part 1) AutoREST: a table or view that SQL Workshop › RESTful Services
12
+ // enabled (meta.rest_object, migration 113), served through an application:
13
+ //
14
+ // GET /a/<alias>/autorest/<object>/ rows, paged: ?limit= (1–500) &offset= ; filters: ?<column>=<value>
15
+ // (equality), ?q= (text columns contain it), ?order=col,col.desc
16
+ // POST /a/<alias>/autorest/<object>/ a JSON object: the new row (201)
17
+ // GET /a/<alias>/autorest/<object>/<key> one row by primary key (composite keys: 1,2), 404 when there is none
18
+ // PUT /a/<alias>/autorest/<object>/<key> the JSON object's columns changed; the row (200) or 404
19
+ // DELETE /a/<alias>/autorest/<object>/<key> 204 or 404
20
+ // GET /a/<alias>/autorest/openapi.json OpenAPI 3 of the application's objects (public ones without a token)
21
+ //
22
+ // Authentication is the application's (as for its REST modules, rest.ts caller()): a bearer token of an account
23
+ // or an OAuth client of the application; "public" objects need none (meta.app_user() = nobody). The statement
24
+ // runs as the application's database role with meta.app_user() and meta.has_role() set, so grants (also column
25
+ // grants) and row level security apply. The role may not be the object's owner (or a member of it), a superuser
26
+ // or a role that bypasses row level security: checked at every request, after SET ROLE. Names come from the
27
+ // catalog and are quoted; every value is a query parameter.
28
+
29
+ export interface RestObject {
30
+ id: number;
31
+ app_id: number;
32
+ schema_name: string;
33
+ object_name: string;
34
+ alias: string;
35
+ methods: string[];
36
+ auth: 'token' | 'public';
37
+ roles: string[];
38
+ page_size: number;
39
+ enabled: boolean;
40
+ }
41
+
42
+ export interface Column {
43
+ name: string;
44
+ type: string;
45
+ /** pg_type.typcategory: S string, N numeric, B boolean, D date/time, … */
46
+ cat: string;
47
+ readable: boolean;
48
+ /** position in the primary key (1…), or null */
49
+ pk: number | null;
50
+ }
51
+
52
+ export const AUTOREST_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const;
53
+ export const MAX_PAGE = 500;
54
+ /** Query parameters that are not column filters. */
55
+ const RESERVED = new Set(['limit', 'offset', 'q', 'order']);
56
+ export const RELKINDS: Record<string, string> = { r: 'table', p: 'table', v: 'view', m: 'materialized view', f: 'foreign table' };
57
+
58
+ const ident = pg.escapeIdentifier;
59
+ export const qualified = (o: Pick<RestObject, 'schema_name' | 'object_name'>) => `${ident(o.schema_name)}.${ident(o.object_name)}`;
60
+
61
+ /** The columns of a relation as `role` may use them (the catalog only; no privilege needed to read it). */
62
+ export async function describeColumns(db: { query: (sql: string, params: unknown[]) => Promise<pg.QueryResult> }, schema: string, name: string, role: string): Promise<Column[]> {
63
+ return (await db.query(
64
+ `select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, t.typcategory as cat,
65
+ has_column_privilege($3, c.oid, a.attnum, 'SELECT') as readable,
66
+ (select array_position(i.indkey::int2[], a.attnum) from pg_index i where i.indrelid = c.oid and i.indisprimary) as pk
67
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
68
+ join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
69
+ join pg_type t on t.oid = a.atttypid
70
+ where n.nspname = $1 and c.relname = $2
71
+ order by a.attnum`,
72
+ [schema, name, role],
73
+ )).rows as Column[];
74
+ }
75
+
76
+ /** The primary key's columns in key order. */
77
+ export const keyColumns = (cols: Column[]) => cols.filter((c) => c.pk !== null).sort((a, b) => a.pk! - b.pk!);
78
+
79
+ /** The object a request names, if the application published it and it is on. */
80
+ async function objectOf(a: App, alias: string) {
81
+ const o = await runtime.one<RestObject>('select * from meta.rest_object where app_id = $1 and alias = $2', [a.id, alias]);
82
+ return o?.enabled ? o : undefined;
83
+ }
84
+
85
+ /** Split a key segment "1" or "10,20" into its values (each part URL-decoded on its own). */
86
+ export function splitKey(raw: string, n: number): string[] | null {
87
+ if (!raw || raw.includes('/')) return null;
88
+ const parts = raw.split(',');
89
+ if (parts.length !== n) return null;
90
+ try {
91
+ return parts.map((p) => decodeURIComponent(p));
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** The ORDER BY of ?order=a,b.desc: only readable columns; null when it names something else. */
98
+ export function orderBy(spec: string | undefined, cols: Column[]): string | null {
99
+ const readable = new Map(cols.filter((c) => c.readable).map((c) => [c.name, c]));
100
+ if (spec === undefined || spec === '') {
101
+ const key = keyColumns(cols).filter((c) => c.readable);
102
+ return key.length ? key.map((c) => `t.${ident(c.name)}`).join(', ') : '1';
103
+ }
104
+ const out: string[] = [];
105
+ for (const part of spec.split(',')) {
106
+ const m = /^(.+?)(?:\.(asc|desc))?$/.exec(part);
107
+ if (!m || !readable.has(m[1])) return null;
108
+ out.push(`t.${ident(m[1])}${m[2] === 'desc' ? ' desc' : ''}`);
109
+ }
110
+ return out.join(', ');
111
+ }
112
+
113
+ const likeEscape = (s: string) => s.replace(/[\\%_]/g, (x) => `\\${x}`);
114
+ const one = (v: unknown) => (Array.isArray(v) ? v[0] : v);
115
+
116
+ /** Checks run after SET ROLE: the role isn't the owner and doesn't bypass row level security. */
117
+ async function checkRole(c: Client, o: RestObject) {
118
+ const r = (await c.query(
119
+ `select c.relkind, pg_has_role(current_user, c.relowner, 'MEMBER') as owns, r.rolsuper or r.rolbypassrls as bypass
120
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace, pg_roles r
121
+ where n.nspname = $1 and c.relname = $2 and r.rolname = current_user`,
122
+ [o.schema_name, o.object_name],
123
+ )).rows[0];
124
+ if (!r || !RELKINDS[r.relkind]) throw new RestError(404, 'Not found');
125
+ if (r.owns || r.bypass) throw new RestError(403, 'The application\'s database role owns this object or bypasses row level security: AutoREST refuses it.');
126
+ return r.relkind as string;
127
+ }
128
+
129
+ /** A JSON body as an object of known columns; RestError otherwise. */
130
+ function bodyColumns(body: unknown, cols: Column[]) {
131
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Buffer.isBuffer(body)) throw new RestError(400, 'The body is a JSON object of column values.');
132
+ const names = new Set(cols.map((c) => c.name));
133
+ const keys = Object.keys(body as object);
134
+ const unknown = keys.filter((k) => !names.has(k));
135
+ if (unknown.length) throw new RestError(400, `Unknown column(s): ${unknown.slice(0, 5).join(', ')}.`);
136
+ return keys;
137
+ }
138
+
139
+ async function handle(req: FastifyRequest, reply: FastifyReply) {
140
+ const p = req.params as { alias: string; object: string; '*'?: string };
141
+ const keyRaw = (p['*'] ?? '').replace(/\/+$/, '');
142
+ const a = await loadApp(p.alias);
143
+ const o = a ? await objectOf(a, p.object) : undefined;
144
+ if (!a || !o || !(await restServicesOn(a.id))) return reply.code(404).send({ error: 'Not found' });
145
+ const method = req.method;
146
+ const allowed = o.methods.filter((m) => (keyRaw ? m !== 'POST' : m === 'GET' || m === 'POST'));
147
+ if (!o.methods.includes(method) || !allowed.includes(method)) return reply.code(405).header('allow', allowed.join(', ')).send({ error: `${method} is not allowed here.` });
148
+ try {
149
+ const who = await caller(req, a, o.auth === 'public');
150
+ const refusal = availabilityRefusal(a, { username: who.user === 'nobody' ? null : who.user, developer: false });
151
+ if (refusal) throw new RestError(refusal.status, refusal.message ?? english(refusal.key));
152
+ if (!a.db_role) throw new RestError(403, 'The application has no database role: AutoREST needs one.');
153
+ const result = await runtime.tx(async (c) => {
154
+ await c.query(
155
+ `select set_config('pgkiln.app_id', $1, true), set_config('pgkiln.app_user', $2, true), set_config('pgkiln.session_id', '', true),
156
+ set_config('request.jwt.claims', $3, true), set_config('statement_timeout', '30s', true)`,
157
+ [String(a.id), who.user, who.claims ? JSON.stringify(who.claims) : ''],
158
+ );
159
+ if (o.roles.length) {
160
+ const ok = (await c.query('select bool_or(meta.has_role(r)) as ok from unnest($1::text[]) r', [o.roles])).rows[0].ok;
161
+ if (!ok) throw new RestError(403, 'You do not have a role this endpoint needs.');
162
+ }
163
+ await c.query(`set local role ${ident(a.db_role!)}`);
164
+ await checkRole(c, o);
165
+ const cols = await describeColumns(c, o.schema_name, o.object_name, a.db_role!);
166
+ const readable = cols.filter((x) => x.readable);
167
+ const list = readable.map((x) => `t.${ident(x.name)}`).join(', ');
168
+ const returning = list ? ` returning ${list}` : '';
169
+ const rel = qualified(o);
170
+ const key = keyColumns(cols);
171
+ const params: unknown[] = [];
172
+ const param = (v: unknown) => `$${params.push(v)}`;
173
+ let where = '';
174
+ if (keyRaw) {
175
+ const values = key.length ? splitKey(keyRaw, key.length) : null;
176
+ if (!values) throw new RestError(404, 'Not found');
177
+ where = key.map((k, i) => `t.${ident(k.name)} = ${param(values[i])}`).join(' and ');
178
+ }
179
+
180
+ if (method === 'GET' && !keyRaw) {
181
+ if (!readable.length) throw new RestError(403, 'Not allowed.');
182
+ const q = req.query as Record<string, string | string[]>;
183
+ const conds: string[] = [];
184
+ for (const [k, raw] of Object.entries(q)) {
185
+ if (RESERVED.has(k)) continue;
186
+ const col = readable.find((x) => x.name === k);
187
+ if (!col) throw new RestError(400, `Unknown parameter ${k.slice(0, 63)}: filter on a column, or use limit, offset, q, order.`);
188
+ conds.push(`t.${ident(col.name)} = ${param(String(one(raw)))}`);
189
+ }
190
+ const search = one(q.q);
191
+ if (search) {
192
+ const text = readable.filter((x) => x.cat === 'S');
193
+ if (!text.length) throw new RestError(400, 'q= searches text columns, and this object has none you may read.');
194
+ const v = param(`%${likeEscape(String(search))}%`);
195
+ conds.push(`(${text.map((x) => `t.${ident(x.name)} ilike ${v}`).join(' or ')})`);
196
+ }
197
+ const order = orderBy(one(q.order) as string | undefined, cols);
198
+ if (order === null) throw new RestError(400, 'order= is a list of readable columns, each optionally followed by .asc or .desc.');
199
+ const limitRaw = one(q.limit);
200
+ const offsetRaw = one(q.offset);
201
+ if ((limitRaw !== undefined && !/^\d{1,9}$/.test(String(limitRaw))) || (offsetRaw !== undefined && !/^\d{1,12}$/.test(String(offsetRaw))))
202
+ throw new RestError(400, 'limit and offset are whole numbers.');
203
+ const limit = Math.max(1, Math.min(MAX_PAGE, limitRaw !== undefined ? Number(limitRaw) : o.page_size));
204
+ const offset = offsetRaw !== undefined ? Number(offsetRaw) : 0;
205
+ const rows = (await c.query(
206
+ `select ${list} from ${rel} t${conds.length ? ` where ${conds.join(' and ')}` : ''} order by ${order} limit ${limit + 1} offset ${offset}`,
207
+ params,
208
+ )).rows;
209
+ return { status: 200, body: { items: rows.slice(0, limit), offset, limit, has_more: rows.length > limit } };
210
+ }
211
+ if (method === 'GET') {
212
+ if (!readable.length) throw new RestError(403, 'Not allowed.');
213
+ const row = (await c.query(`select ${list} from ${rel} t where ${where} limit 1`, params)).rows[0];
214
+ if (!row) throw new RestError(404, 'Not found');
215
+ return { status: 200, body: row };
216
+ }
217
+ if (method === 'POST') {
218
+ const names = bodyColumns(req.body, cols);
219
+ const sql = names.length
220
+ ? `insert into ${rel} as t (${names.map(ident).join(', ')}) select ${names.map((n) => `b.${ident(n)}`).join(', ')} from json_populate_record(null::${rel}, ${param(JSON.stringify(req.body))}::json) b${returning}`
221
+ : `insert into ${rel} as t default values${returning}`;
222
+ const row = (await c.query(sql, params)).rows[0];
223
+ const keyOk = key.length && key.every((k) => k.readable) && row;
224
+ return { status: 201, body: row ?? {}, location: keyOk ? key.map((k) => encodeURIComponent(String(row[k.name] instanceof Date ? row[k.name].toISOString() : row[k.name]))).join(',') : undefined };
225
+ }
226
+ if (method === 'PUT') {
227
+ const names = bodyColumns(req.body, cols);
228
+ if (!names.length) throw new RestError(400, 'The body names no columns to change.');
229
+ const b = param(JSON.stringify(req.body));
230
+ const res = await c.query(
231
+ `update ${rel} as t set ${names.map((n) => `${ident(n)} = b.${ident(n)}`).join(', ')} from json_populate_record(null::${rel}, ${b}::json) b where ${where}${returning}`,
232
+ params,
233
+ );
234
+ if (!res.rowCount) throw new RestError(404, 'Not found');
235
+ return { status: 200, body: res.rows[0] ?? {} };
236
+ }
237
+ // DELETE
238
+ const res = await c.query(`delete from ${rel} t where ${where}`, params);
239
+ if (!res.rowCount) throw new RestError(404, 'Not found');
240
+ return { status: 204, body: undefined };
241
+ });
242
+ reply.header('cache-control', 'no-store');
243
+ if ('location' in result && result.location) reply.header('location', `/a/${encodeURIComponent(a.alias)}/autorest/${o.alias}/${result.location}`);
244
+ return result.body === undefined ? reply.code(204).send() : reply.code(result.status).send(result.body);
245
+ } catch (e) {
246
+ if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
247
+ const err = e as pg.DatabaseError;
248
+ const message = await publicError({ app: a, page: { page_no: 0 } as never, user: 'api', ip: clientIp(req) }, e, `AutoREST ${method} ${o.alias}`);
249
+ const status = err.code === '42501' ? 403 : err.code && /^(P0001|22|23|42804|428C9)/.test(err.code) ? 400 : 500;
250
+ return reply.code(status).send({ error: status === 403 ? 'Not allowed.' : message });
251
+ }
252
+ }
253
+
254
+ /** JSON Schema of a column type (for the OpenAPI description). */
255
+ function jsonType(c: Column): Record<string, unknown> {
256
+ if (/^(smallint|integer|bigint)$/.test(c.type)) return { type: 'integer' };
257
+ if (c.cat === 'N') return { type: 'number' };
258
+ if (c.cat === 'B') return { type: 'boolean' };
259
+ if (c.type === 'date') return { type: 'string', format: 'date' };
260
+ if (c.cat === 'D') return { type: 'string', format: 'date-time' };
261
+ if (c.cat === 'S') return { type: 'string' };
262
+ if (c.cat === 'A') return { type: 'array', items: {} };
263
+ return {};
264
+ }
265
+
266
+ /** The OpenAPI 3 description of an application's AutoREST objects. */
267
+ export function autoRestOpenApi(a: Pick<App, 'alias' | 'name'>, objects: { o: RestObject; cols: Column[] }[]) {
268
+ const paths: Record<string, Record<string, unknown>> = {};
269
+ const schemas: Record<string, unknown> = {};
270
+ const errors = (o: RestObject) => (o.auth === 'public' ? {} : { 401: { description: 'No valid token' }, 403: { description: 'Not allowed' } });
271
+ for (const { o, cols } of objects) {
272
+ const readable = cols.filter((c) => c.readable);
273
+ const name = o.alias.replace(/[^A-Za-z0-9_.-]/g, '_');
274
+ schemas[name] = { type: 'object', properties: Object.fromEntries(readable.map((c) => [c.name, { ...jsonType(c), description: c.type }])) };
275
+ const ref = { $ref: `#/components/schemas/${name}` };
276
+ const sec = o.auth === 'public' ? { security: [] } : {};
277
+ const body = { required: true, content: { 'application/json': { schema: ref } } };
278
+ const coll: Record<string, unknown> = {};
279
+ if (o.methods.includes('GET'))
280
+ coll.get = {
281
+ summary: `Rows of ${o.schema_name}.${o.object_name}`, ...sec,
282
+ parameters: [
283
+ { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: MAX_PAGE, default: o.page_size } },
284
+ { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0 } },
285
+ { name: 'q', in: 'query', description: 'Rows whose text columns contain this', schema: { type: 'string' } },
286
+ { name: 'order', in: 'query', description: 'Columns, each optionally .asc or .desc, comma separated', schema: { type: 'string' } },
287
+ ...readable.filter((c) => !RESERVED.has(c.name)).map((c) => ({ name: c.name, in: 'query', description: `${c.name} equals`, schema: { type: 'string' } })),
288
+ ],
289
+ responses: { 200: { description: '{items, offset, limit, has_more}', content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: ref }, offset: { type: 'integer' }, limit: { type: 'integer' }, has_more: { type: 'boolean' } } } } } }, 400: { description: 'A filter is not valid' }, ...errors(o) },
290
+ };
291
+ if (o.methods.includes('POST'))
292
+ coll.post = { summary: `New row in ${o.schema_name}.${o.object_name}`, ...sec, requestBody: body, responses: { 201: { description: 'The new row', content: { 'application/json': { schema: ref } } }, 400: { description: 'Invalid values' }, ...errors(o) } };
293
+ if (Object.keys(coll).length) paths[`/${o.alias}/`] = coll;
294
+ const key = keyColumns(cols);
295
+ if (!key.length) continue;
296
+ const item: Record<string, unknown> = { parameters: [{ name: 'key', in: 'path', required: true, description: `${key.map((k) => k.name).join(', ')}${key.length > 1 ? ' (comma separated)' : ''}`, schema: { type: 'string' } }] };
297
+ const notFound = { 404: { description: 'Not found' } };
298
+ if (o.methods.includes('GET')) item.get = { summary: 'One row by its key', ...sec, responses: { 200: { description: 'The row', content: { 'application/json': { schema: ref } } }, ...notFound, ...errors(o) } };
299
+ if (o.methods.includes('PUT')) item.put = { summary: 'Change the columns the body names', ...sec, requestBody: body, responses: { 200: { description: 'The changed row', content: { 'application/json': { schema: ref } } }, ...notFound, ...errors(o) } };
300
+ if (o.methods.includes('DELETE')) item.delete = { summary: 'Delete the row', ...sec, responses: { 204: { description: 'Deleted' }, ...notFound, ...errors(o) } };
301
+ if (Object.keys(item).length > 1) paths[`/${o.alias}/{key}`] = item;
302
+ }
303
+ return {
304
+ openapi: '3.0.3',
305
+ info: { title: `${a.name} AutoREST`, description: 'Tables and views published with SQL Workshop › RESTful Services', version: '1' },
306
+ servers: [{ url: `/a/${a.alias}/autorest` }],
307
+ components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }, schemas },
308
+ security: [{ bearer: [] }],
309
+ paths,
310
+ };
311
+ }
312
+
313
+ export async function autoRestRoutes(app: FastifyInstance) {
314
+ // without a token: the public objects only; with a valid token of the application: all of them
315
+ app.get<{ Params: { alias: string } }>('/a/:alias/autorest/openapi.json', async (req, reply) => {
316
+ const a = await loadApp(req.params.alias);
317
+ if (!a || !(await restServicesOn(a.id))) return reply.code(404).send({ error: english('error.not_found') });
318
+ let all = false;
319
+ if (req.headers.authorization) {
320
+ try {
321
+ all = (await caller(req, a, false)).user !== 'nobody';
322
+ } catch (e) {
323
+ if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
324
+ throw e;
325
+ }
326
+ }
327
+ const rows = (await runtime.query<RestObject>('select * from meta.rest_object where app_id = $1 and enabled order by alias', [a.id])).rows.filter((o) => all || o.auth === 'public');
328
+ const objects = a.db_role
329
+ ? await Promise.all(rows.map(async (o) => ({ o, cols: await describeColumns(owner, o.schema_name, o.object_name, a.db_role!) })))
330
+ : [];
331
+ return reply.header('cache-control', 'no-store').send(autoRestOpenApi(a, objects.filter((x) => x.cols.length)));
332
+ });
333
+ const methods = [...AUTOREST_METHODS] as never;
334
+ app.route({ method: methods, url: '/a/:alias/autorest/:object', handler: handle });
335
+ app.route({ method: methods, url: '/a/:alias/autorest/:object/*', handler: handle });
336
+ }
@@ -94,19 +94,19 @@ export function matchHandler(handlers: Handler[], method: string, path: string)
94
94
  return methodMismatch ? 'method' : null;
95
95
  }
96
96
 
97
- class RestError extends Error {
97
+ export class RestError extends Error {
98
98
  constructor(readonly status: number, message: string) {
99
99
  super(message);
100
100
  }
101
101
  }
102
102
 
103
- interface Caller {
103
+ export interface Caller {
104
104
  user: string;
105
105
  claims: Record<string, unknown> | null;
106
106
  }
107
107
 
108
108
  /** The caller of a request: the bearer token checked like meta.api_check does. */
109
- async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Promise<Caller> {
109
+ export async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Promise<Caller> {
110
110
  const auth = String(req.headers.authorization ?? '');
111
111
  if (!/^bearer /i.test(auth)) {
112
112
  if (publicHandler) return { user: 'nobody', claims: null };
@@ -136,7 +136,7 @@ async function caller(req: FastifyRequest, a: App, publicHandler: boolean): Prom
136
136
  const bindValue = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
137
137
 
138
138
  /** (093) The workspace preference "RESTful services": off, the workspace's REST modules don't exist. */
139
- async function restServicesOn(appId: number) {
139
+ export async function restServicesOn(appId: number) {
140
140
  const r = await owner.one<{ on: boolean }>('select coalesce((select w.rest_services from meta.workspace w where w.id = meta.app_workspace($1)), true) as on', [appId]);
141
141
  return r?.on !== false;
142
142
  }
package/src/session.ts CHANGED
@@ -32,6 +32,8 @@ export interface Session {
32
32
  /** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
33
33
  workspaces?: BuilderWorkspace[];
34
34
  workspace?: BuilderWorkspace | null;
35
+ /** Builder only (sprint 99): an administrator of the installation (the Administration menu's Database users) */
36
+ instanceAdmin?: boolean;
35
37
  /** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */
36
38
  devbar_hash?: string | null;
37
39
  /** (085) when the session started, in ms since the epoch (its maximum length counts from here) */