diff --git a/CLAUDE.md b/CLAUDE.md index 3d649f754..0ade8a2ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,12 +323,18 @@ There are **two kinds of permission**, granted separately and checked in differe kind a name belongs to decides how it may be enforced, so it is the first thing to establish about any permission you touch. -**Global permissions** are held site-wide, bound to no path: `access:admin`, `manage:users`, -`manage:groups`, `manage:navigation`, `manage:theme`, `manage:sites`, `manage:system`. That list is -the whole of it — the one offered by the group editor (`GroupEditOverlay.vue`). They live on a -group's `permissions` column, are flattened onto `req.session.permissions` at login -(`models/users.ts` → `updateSession`), and are what the per-route `config.permissions` hook -checks. `manage:system` bypasses every check everywhere. +**Global permissions** are held site-wide, bound to no path: `access:admin`, `read:users`, +`manage:users`, `read:groups`, `manage:groups`, `read:audit`, `manage:navigation`, `manage:theme`, +`manage:sites`, `manage:system`. That is the list as it stands — the one offered by the group editor +(`GroupEditOverlay.vue`). They live on a group's `permissions` column, are flattened onto +`req.session.permissions` at login (`models/users.ts` → `updateSession`), and are what the per-route +`config.permissions` hook checks. `manage:system` bypasses every check everywhere. + +**Adding a global permission is the maintainer's call, not yours.** The list is not frozen, but a new +name reshapes who can do what across the whole instance and every existing group silently lacks it — +so propose it and wait for a yes before writing any code that names it. Until then, express what a +route needs with the permissions that already exist. This is about *adding* to the list; using one +that is already on it needs no permission from anybody. **Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`, `review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`, @@ -357,8 +363,10 @@ Consequences worth knowing: - **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely required (`reviewerFor` in `api/approvals.ts` is the worked example). -- **Never invent a permission name.** Both lists above are closed; `can('browse:fileman')` and - friends matched nothing and silently hid the controls they guarded. +- **Never invent a permission name.** Nothing validates one, so a name that is not on the lists above + simply never matches: `can('browse:fileman')` and friends silently hid the controls they guarded. + Adding a genuinely new global permission is allowed but is the maintainer's decision — ask first, + as above; never introduce one on your own. ### Backend patterns @@ -722,6 +730,55 @@ store; no SVG is ever written into content. - Picking an icon calls `POST /_api/icons/materialize`, which is what guarantees the wiki can serve it afterwards without the Iconify API. +### Audit log + +Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind` +(`page` / `asset` / `auth` / `profile` / `admin`), `action`, and a `meta` blob. Read at +`/_admin/audit` behind the `read:audit` permission; the retention setting behind `manage:system`, +because shortening it destroys evidence and that is not the same authority as looking. + +- **Written from API route handlers**, through `audit(req, kind, action, meta)` in `helpers/audit.ts` + and only ever AFTER the work succeeded. That is what excludes the scheduler by construction: a page + the git sync imports reaches `pages.adoptStoredPage` by a path that never passes through a route, + so it leaves no row. Don't move an audit call into a model to save a few lines — the model is + reachable from a job, and the log would start claiming a person did it. +- **The exceptions are the auth events with no session yet**, which record themselves in + `models/users.ts`: a login, a registration, an email confirmation, a password reset from a link, a + forced password change. The account is only identified there, from a credential or a token, and the + login case additionally converges six routes (local, provider, passkey, and the 2FA and + password-change continuations) on one place. A logout is the mirror image and passes its own actor, + since the session is destroyed before the entry is written. +- **`AUDIT_ACTIONS` in `models/auditLog.ts` is the closed list**, grouped by kind, and `AuditAction` + is its union — so `npm run typecheck` refuses an action that is not in it. Each key is also its + translation key (`admin.audit.actions.`) and what `GET /audit/actions` serves the filter + from, so adding an action means adding the string too. Keys are unique ACROSS kinds, which is why + `forcedPasswordChange` (demanded at sign-in) and `changePassword` (from one's own profile) are + named apart. +- **What is recorded and what is not.** Every mutating route, plus successful logins. Not reads — + page views would bury everything else. Not FAILED logins: that endpoint is open to whoever can + reach the wiki, so recording them would let anybody outside fill the table on demand + (`models/rateLimits.ts` is what answers that). `requestPasswordReset` is recorded only when a link + was actually sent, for the same reason. +- **`meta` carries identity, never payload and never content.** A page edit records the `pageHistory` + version its change produced — which is why `createPage` / `updatePage` / `movePage` / `deletePage` + return a `PageChange` rather than a page — instead of copying the before and after into a second + table. Configuration routes record which fields were touched, not what they were set to; `security` + is the one exception, recorded in full because none of it is a secret and its values are exactly + what gets asked about later. `sanitizeMeta` redacts secret-shaped keys as a backstop, not as the + rule. +- **`meta.actor` is a copy of the email, display name and IP as they stood.** `userId` is + `on delete: set null`, so an entry outlives the account that made it — the copy is the whole reason + the row is still readable, and the admin area reads the user column from it rather than from the + users table, so a rename does not rewrite history. +- **Rows are purged, not kept for ever.** `purgeAuditLog` runs daily off `SYSTEM_SCHEDULE`, against + the `audit.retentionDays` setting (`0` keeps everything). **The floor is `MIN_RETENTION_DAYS`, 30**, + and it is not a preference: retention is the one setting whose whole effect is to destroy this + table, and `manage:system` — the permission that changes it — belongs to exactly the person the + table exists to record. Left free, acting and then setting retention to a day would purge the + record before anybody had reason to look. So the API refuses anything between 1 and 30, and + `retentionDays()` reads such a value AS 30 rather than honouring it — the second check is what + closes the config-file and direct-database routes round the first. + ### GraphQL is being removed An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** — there is no GraphQL diff --git a/backend/api/apiKeys.ts b/backend/api/apiKeys.ts index ffc6864d6..422c0a8f6 100644 --- a/backend/api/apiKeys.ts +++ b/backend/api/apiKeys.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance } from 'fastify' import type { KeyExpiration } from '../models/apiKeys.ts' @@ -121,6 +122,14 @@ async function routes(app: FastifyInstance) { groups: req.body.groups }) + // -> Never the token: it exists once, in the response above, and this log is not a second copy + await audit(req, 'admin', 'createApiKey', { + apiKeyId: id, + name: req.body.name, + expiration: req.body.expiration, + groups: req.body.groups + }) + return { ok: true, message: 'API key created successfully.', @@ -181,6 +190,8 @@ async function routes(app: FastifyInstance) { await WIKI.models.apiKeys.revokeKey(key.id) + await audit(req, 'admin', 'revokeApiKey', { apiKeyId: key.id, name: key.name }) + return { ok: true, message: 'API key revoked successfully.' diff --git a/backend/api/approvals.ts b/backend/api/approvals.ts index f46531db6..c69bcd106 100644 --- a/backend/api/approvals.ts +++ b/backend/api/approvals.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import { CustomError } from '../helpers/common.ts' import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor } from './pages.ts' import type { ApprovalPageRef, ApprovalRulePatch, ReviewerScope } from '../models/approvals.ts' @@ -264,6 +265,13 @@ async function routes(app: FastifyInstance) { } const rule = await WIKI.models.approvals.createRule(req.params.siteId, req.body) + + await audit(req, 'admin', 'createApprovalRule', { + ruleId: rule.id, + siteId: req.params.siteId, + name: rule.name + }) + return { ok: true, rule @@ -345,6 +353,14 @@ async function routes(app: FastifyInstance) { if (!rule) { return reply.notFound('Approval rule does not exist.') } + + await audit(req, 'admin', 'updateApprovalRule', { + ruleId: rule.id, + siteId: req.params.siteId, + name: rule.name, + changedFields: Object.keys(req.body) + }) + return { ok: true, rule @@ -391,6 +407,12 @@ async function routes(app: FastifyInstance) { if (!(await WIKI.models.approvals.deleteRule(req.params.siteId, req.params.ruleId))) { return reply.notFound('Approval rule does not exist.') } + + await audit(req, 'admin', 'deleteApprovalRule', { + ruleId: req.params.ruleId, + siteId: req.params.siteId + }) + return reply.code(204).send() } ) @@ -538,6 +560,19 @@ async function routes(app: FastifyInstance) { if (!applied) { return reply.notFound('This edit suggestion does not exist.') } + + // -> Approving writes the suggestion onto the page through `updatePage`, so the edit itself is + // in that page's history under the reviewer's name — this entry records the decision + await audit(req, 'page', 'approvePageEdit', { + submissionId: req.params.submissionId, + pageId: submission.page.id, + siteId: req.params.siteId, + locale: submission.page.locale, + path: submission.page.path, + submittedBy: submission.author.name, + submittedByGuest: submission.author.isGuest + }) + return { ok: true, message: 'Edit suggestion approved.' @@ -585,6 +620,17 @@ async function routes(app: FastifyInstance) { return reply.notFound('This edit suggestion does not exist.') } await WIKI.models.approvals.rejectSubmission(req.params.siteId, req.params.submissionId) + + await audit(req, 'page', 'rejectPageEdit', { + submissionId: req.params.submissionId, + pageId: submission.page.id, + siteId: req.params.siteId, + locale: submission.page.locale, + path: submission.page.path, + submittedBy: submission.author.name, + submittedByGuest: submission.author.isGuest + }) + return { ok: true, message: 'Edit suggestion declined.' @@ -847,6 +893,20 @@ async function routes(app: FastifyInstance) { guestEmail }) + /* + Recorded even for a guest, who has no account for `userId` to point at: the entry is the only + record that somebody outside the wiki sent this in, and the name and email they gave are on + `meta` rather than on `meta.actor` — those two are the account's, and there is no account. + */ + await audit(req, 'page', 'submitPageEdit', { + submissionId: submission.id, + pageId: page.id, + siteId: req.params.siteId, + locale: page.locale, + path: page.path, + ...(actor ? {} : { guestName, guestEmail }) + }) + return { ok: true, submission: { id: submission.id, updatedAt: submission.updatedAt } diff --git a/backend/api/assets.ts b/backend/api/assets.ts index 8be9eefa9..1819a0b14 100644 --- a/backend/api/assets.ts +++ b/backend/api/assets.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' +import { audit } from '../helpers/audit.ts' import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts' import { INLINE_EXTS } from '../models/assets.ts' @@ -181,6 +182,17 @@ async function routes(app: FastifyInstance) { authorId }) + // -> The stored name, not the one that was sent: an upload conflict may have renamed the file + // or replaced one that was already there, and the entry has to say what actually happened + await audit(req, 'asset', 'uploadAsset', { + assetId: asset.id, + siteId: req.params.siteId, + locale, + folderPath: destination, + fileName: asset.fileName, + fileSize: asset.fileSize + }) + return { ok: true, message: 'Asset uploaded successfully.', @@ -386,6 +398,19 @@ async function routes(app: FastifyInstance) { if (!asset) { return reply.notFound('This asset does not exist.') } + + await audit(req, 'asset', 'updateAsset', { + assetId: asset.id, + siteId: req.params.siteId, + locale: destination.locale, + folderPath: destination.folderPath, + fileName: destination.fileName, + previousLocale: existing.locale, + previousFolderPath: existing.folderPath, + previousFileName: existing.fileName, + isRelocated + }) + return { ok: true, message: isRelocated ? 'Asset moved successfully.' : 'Asset renamed successfully.', @@ -432,6 +457,15 @@ async function routes(app: FastifyInstance) { ) { return reply.notFound('This asset does not exist.') } + + await audit(req, 'asset', 'deleteAsset', { + assetId: req.params.assetId, + siteId: req.params.siteId, + locale: doomed.locale, + folderPath: doomed.folderPath, + fileName: doomed.fileName + }) + return reply.code(204).send() } ) diff --git a/backend/api/auditLog.ts b/backend/api/auditLog.ts new file mode 100644 index 000000000..19b2387a3 --- /dev/null +++ b/backend/api/auditLog.ts @@ -0,0 +1,350 @@ +import { Readable } from 'node:stream' +import { validate as uuidValidate } from 'uuid' +import type { FastifyInstance, FastifyReply } from 'fastify' +import { audit } from '../helpers/audit.ts' +import { AUDIT_ACTIONS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../models/auditLog.ts' + +/** Most rows one request may ask for, so that a filter matching everything cannot be a denial of service. */ +const MAX_PAGE_SIZE = 100 + +/** Most accounts one request may name, so the `userId` list cannot be an unbounded `IN`. */ +const MAX_USER_FILTER = 50 + +/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */ +function splitList(value?: string): string[] { + return ( + value + ?.split(',') + .map((v) => v.trim()) + .filter(Boolean) ?? [] + ) +} + +/** The filter half of the querystring, shared by the list and the export. */ +interface AuditQuery { + userId?: string + kind?: string + action?: string + from?: string + to?: string +} + +/** + * The filters as the model takes them, or the refusal to send back. + * + * The user IDs are validated rather than passed through: they go into an `IN` against a uuid column, + * so one malformed value makes postgres raise and the request fail as a 500 rather than as the bad + * request it is. + */ +function filtersFrom( + query: AuditQuery, + reply: FastifyReply +): { userIds: string[]; kind?: string; action?: string; from?: string; to?: string } | null { + const userIds = splitList(query.userId) + if (userIds.length > MAX_USER_FILTER) { + reply.badRequest(`At most ${MAX_USER_FILTER} users can be filtered for at once.`) + return null + } + if (userIds.some((id) => !uuidValidate(id))) { + reply.badRequest('The userId filter must be one user ID, or several separated by commas.') + return null + } + return { userIds, kind: query.kind, action: query.action, from: query.from, to: query.to } +} + +/** The filter querystring both routes declare. */ +const filterProperties = { + userId: { + type: 'string', + description: + 'Only entries these accounts made — one ID, or several comma-separated, OR-ed against each other. At most 50.\n\nAn account that has since been deleted can no longer be filtered for: its entries have no `userId` left. The name and email it had are still on `meta.actor`.' + }, + kind: { $ref: 'AuditKind#' }, + action: { $ref: 'AuditAction#' }, + from: { + type: 'string', + format: 'date-time', + description: 'Inclusive lower bound on the timestamp, RFC 3339.' + }, + to: { + type: 'string', + format: 'date-time', + description: 'Inclusive upper bound on the timestamp, RFC 3339.' + } +} as const + +/** + * Audit Log API Routes + * + * Read-only from the outside: nothing here writes an entry. Entries are written by `helpers/audit.ts` + * as a side effect of the routes that do the work, which is what keeps the log a record of what + * happened rather than a table anybody can post to. + */ +async function routes(app: FastifyInstance) { + /** + * LIST AUDIT ENTRIES + */ + app.get<{ + Querystring: { + userId?: string + kind?: string + action?: string + from?: string + to?: string + page?: number + limit?: number + } + }>( + '/', + { + config: { + permissions: ['read:audit'] + }, + schema: { + summary: 'List audit log entries', + description: + 'Newest first, one page at a time. Every filter is optional and they are AND-ed together.\n\nWhat is recorded: every action that CHANGES something, plus successful logins. Reads are not — page views would outnumber everything else and bury the log — and neither are failed login attempts, which would otherwise let anybody outside fill this table on demand. Actions taken by the scheduler are absent by construction: an entry is only ever written from a request.', + tags: ['Audit Log'], + querystring: { + type: 'object', + properties: { + ...filterProperties, + page: { type: 'integer', minimum: 1, default: 1 }, + limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 25 } + } + }, + response: { + 200: { + description: 'One page of audit entries', + type: 'object', + properties: { + total: { + type: 'integer', + description: 'How many entries match the filters, across every page.' + }, + entries: { + type: 'array', + items: { $ref: 'AuditEntry#' } + } + } + } + } + } + }, + async (req, reply) => { + reply.preventCache() + const filters = filtersFrom(req.query, reply) + if (!filters) { + return reply + } + return WIKI.models.auditLog.list(filters, req.query.page ?? 1, req.query.limit ?? 25) + } + ) + + /** + * EXPORT AUDIT ENTRIES + */ + app.get<{ Querystring: AuditQuery }>( + '/export', + { + config: { + permissions: ['read:audit'] + }, + schema: { + summary: 'Download the audit log as JSONL', + description: + 'Every entry matching the filters, newest first — not just the page on screen — as newline-delimited JSON: one entry per line, the same shape the list returns. A format that streams, so a year of history is a download rather than a request that has to be held in memory at both ends, and that `jq` and every log pipeline read without unwrapping an envelope first.\n\nThe export itself is recorded in the log, which is the one read that is: taking a copy of who did what is worth knowing about.', + tags: ['Audit Log'], + querystring: { + type: 'object', + properties: filterProperties + }, + response: { + 200: { + description: 'The matching entries, one JSON object per line', + content: { + 'application/x-ndjson': { + schema: { type: 'string' } + } + } + } + } + } + }, + async (req, reply) => { + const filters = filtersFrom(req.query, reply) + if (!filters) { + return reply + } + + /* + Recorded BEFORE the stream, deliberately. Everywhere else an entry is written once the work + succeeded, but a download has no moment of success the server sees — the connection can drop + halfway and the rows already sent are still gone. What is worth recording is that the export + was asked for and authorized. + */ + await audit(req, 'admin', 'exportAuditLog', { + filters: { + userIds: filters.userIds, + kind: filters.kind ?? null, + action: filters.action ?? null, + from: filters.from ?? null, + to: filters.to ?? null + } + }) + + const stamp = Temporal.Now.instant().toString({ smallestUnit: 'second' }).replace(/[:]/g, '-') + reply.header('Content-Type', 'application/x-ndjson; charset=utf-8') + reply.header('Content-Disposition', `attachment; filename="audit-log-${stamp}.jsonl"`) + reply.preventCache() + + // -> The entries this request just wrote are in the export too, which is correct: it is the log + // as it stands at the moment it was asked for + const entries = WIKI.models.auditLog.stream(filters) + return reply.send( + Readable.from( + (async function* () { + for await (const entry of entries) { + yield `${JSON.stringify(entry)}\n` + } + })() + ) + ) + } + ) + + /** + * LIST AUDIT ACTIONS + */ + app.get( + '/actions', + { + config: { + permissions: ['read:audit'] + }, + schema: { + summary: 'List every action the wiki records, by area', + description: + 'What the filter offers, and the full set of translation keys: each action is shown as `admin.audit.actions.`. Served from the code rather than from the table, so an action nothing has done yet is still offered.', + tags: ['Audit Log'], + response: { + 200: { + description: 'Action keys grouped by area', + type: 'array', + items: { + type: 'object', + properties: { + kind: { $ref: 'AuditKind#' }, + actions: { + type: 'array', + items: { type: 'string' } + } + } + } + } + } + } + }, + async () => { + return AUDIT_KINDS.map((kind) => ({ kind, actions: [...AUDIT_ACTIONS[kind]] })) + } + ) + + /** + * GET AUDIT CONFIG + */ + app.get( + '/config', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Get the audit log retention settings', + description: + 'Reading the log needs `read:audit`; changing how long it is kept is a system setting and needs `manage:system` — shortening the retention destroys evidence, which is not the same authority as looking at it.', + tags: ['Audit Log'], + response: { + 200: { $ref: 'AuditConfig#' } + } + } + }, + async (req, reply) => { + reply.preventCache() + const [total, oldestEntry] = await Promise.all([ + WIKI.models.auditLog.total(), + WIKI.models.auditLog.oldestEntry() + ]) + return { + retentionDays: WIKI.models.auditLog.retentionDays(), + total, + oldestEntry + } + } + ) + + /** + * UPDATE AUDIT CONFIG + */ + app.put<{ Body: { retentionDays: number } }>( + '/config', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Set how long audit log entries are kept', + description: `Takes effect the next time the daily \`purgeAuditLog\` task runs; nothing is deleted by saving. The change is itself recorded in the log. + +Either zero — keep everything for ever — or at least ${MIN_RETENTION_DAYS} days. Nothing between the two is accepted: retention is the one setting whose effect is to destroy this table, and the person who can change it is the person it exists to record, so a value short enough to outrun discovery is refused rather than offered.`, + tags: ['Audit Log'], + body: { + type: 'object', + properties: { + retentionDays: { + type: 'integer', + minimum: 0, + maximum: 36500, + description: `Days to keep. Zero keeps everything for ever; anything else is at least ${MIN_RETENTION_DAYS}.` + } + }, + required: ['retentionDays'] + }, + response: { + 200: { + description: 'Retention updated', + type: 'object', + properties: { + retentionDays: { type: 'integer' } + } + } + } + } + }, + async (req, reply) => { + /* + Checked here rather than in the JSON Schema: "zero or at least thirty" is expressible as an + `anyOf`, but what comes back from one is a validation error naming neither branch, and this + is a refusal whose reason is the whole point of it. + */ + if (req.body.retentionDays > 0 && req.body.retentionDays < MIN_RETENTION_DAYS) { + return reply.badRequest( + `Audit log retention must be at least ${MIN_RETENTION_DAYS} days, or zero to keep entries for ever.` + ) + } + + const previous = WIKI.models.auditLog.retentionDays() + await WIKI.models.settings.updateConfig('audit', { retentionDays: req.body.retentionDays }) + WIKI.config.audit = { ...WIKI.config.audit, retentionDays: req.body.retentionDays } + + await audit(req, 'admin', 'updateAuditConfig', { + retentionDays: req.body.retentionDays, + previousRetentionDays: previous + }) + + return { retentionDays: req.body.retentionDays } + } + ) +} + +export default routes diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index 6f80e79d7..3420db82c 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -1,4 +1,5 @@ import { nanoid } from 'nanoid' +import { audit } from '../helpers/audit.ts' import { maskSensitiveProps } from '../helpers/common.ts' import { limitAuthAttempts } from '../helpers/rateLimit.ts' import type { AuthStrategy } from '../models/authentication.ts' @@ -534,7 +535,7 @@ async function routes(app: FastifyInstance) { }, async (req, reply) => { try { - await WIKI.models.users.verifyUserEmail(req.body.token) + await WIKI.models.users.verifyUserEmail(req.body.token, req.ip) return { ok: true } } catch (err: any) { WIKI.models.flags.authDebug(`Email confirmation refused: ${err.message}`) @@ -686,7 +687,8 @@ async function routes(app: FastifyInstance) { try { await WIKI.models.users.resetPassword({ token: req.body.token, - newPassword: req.body.newPassword + newPassword: req.body.newPassword, + ip: req.ip }) return { ok: true } } catch (err: any) { @@ -1182,6 +1184,14 @@ async function routes(app: FastifyInstance) { email: user.email } }) + // -> Not through `audit()`: the session was destroyed above, so the request no longer knows + // who made it and the actor has to come from the copy taken before that + await WIKI.models.auditLog.record({ + kind: 'auth', + action: 'logout', + actor: { id: user.id, name: user.name, email: user.email, ip: req.ip }, + meta: { siteId: req.params.siteId } + }) } return { @@ -1340,6 +1350,14 @@ async function routes(app: FastifyInstance) { const id = await WIKI.models.authentication.createStrategy(req.body as any) + // -> The module and the display name, never the config: a strategy's config is where its client + // secret lives + await audit(req, 'admin', 'createAuthStrategy', { + strategyId: id, + module: req.body.module, + displayName: req.body.displayName + }) + return { ok: true, message: 'Authentication strategy created successfully.', @@ -1429,6 +1447,14 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to update the authentication strategy.') } + // -> Which fields were touched, not what they were set to, for the same reason as above + await audit(req, 'admin', 'updateAuthStrategy', { + strategyId: current.id, + module: current.module, + displayName: current.displayName, + changedFields: Object.keys(patch) + }) + return { ok: true, message: 'Authentication strategy updated successfully.' @@ -1477,6 +1503,13 @@ async function routes(app: FastifyInstance) { } await WIKI.models.authentication.deleteStrategy(req.params.strategyId) + + await audit(req, 'admin', 'deleteAuthStrategy', { + strategyId: req.params.strategyId, + module: strategy.module, + displayName: strategy.displayName + }) + return reply.code(204).send() } ) diff --git a/backend/api/blocks.ts b/backend/api/blocks.ts index 8455be953..55a00a1b2 100644 --- a/backend/api/blocks.ts +++ b/backend/api/blocks.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' /** @@ -181,6 +182,12 @@ async function routes(app: FastifyInstance) { try { const updated = await WIKI.models.blocks.setBlocksState(req.params.siteId, req.body.states) + + await audit(req, 'admin', 'updateBlock', { + siteId: req.params.siteId, + states: req.body.states + }) + return { ok: true, message: 'Blocks state updated successfully.', @@ -244,6 +251,13 @@ async function routes(app: FastifyInstance) { } await WIKI.models.blocks.deleteCustomBlock(req.params.siteId, req.params.blockId) + + await audit(req, 'admin', 'deleteBlock', { + siteId: req.params.siteId, + blockId: req.params.blockId, + name: block.name + }) + return reply.code(204).send() } ) diff --git a/backend/api/groups.ts b/backend/api/groups.ts index a610bfe19..a0ba3d035 100644 --- a/backend/api/groups.ts +++ b/backend/api/groups.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import { CustomError } from '../helpers/common.ts' import { SYSTEM_PERMISSION } from '../models/groups.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' @@ -129,6 +130,9 @@ async function routes(app: FastifyInstance) { try { const id = await WIKI.models.groups.createGroup(req.body.name) + + await audit(req, 'admin', 'createGroup', { groupId: id, name: req.body.name }) + return { ok: true, message: 'Group created successfully.', @@ -332,6 +336,19 @@ async function routes(app: FastifyInstance) { try { await WIKI.models.groups.updateGroup(group.id, patch) + /* + The permissions and the rules are recorded in full rather than as "changed", because they + ARE the answer to who may do what — and reconstructing the set somebody was granted at a + point in time is the question an audit log gets asked about a group. + */ + await audit(req, 'admin', 'updateGroup', { + groupId: group.id, + name: patch.name ?? group.name, + changedFields: Object.keys(patch), + ...(patch.permissions !== undefined ? { permissions: patch.permissions } : {}), + ...(patch.rules !== undefined ? { rules: patch.rules } : {}) + }) + return { ok: true, message: 'Group updated successfully.' @@ -391,6 +408,9 @@ async function routes(app: FastifyInstance) { try { await WIKI.models.groups.deleteGroup(group.id) + + await audit(req, 'admin', 'deleteGroup', { groupId: group.id, name: group.name }) + return reply.code(204).send() } catch (err: any) { WIKI.logger.warn(err) @@ -547,6 +567,14 @@ async function routes(app: FastifyInstance) { return reply.conflict('User is already assigned to this group.') } + await audit(req, 'admin', 'assignUserToGroup', { + groupId: group.id, + groupName: group.name, + targetUserId: user.id, + targetName: user.name, + targetEmail: user.email + }) + return { ok: true, message: 'User assigned to group successfully.' @@ -619,6 +647,15 @@ async function routes(app: FastifyInstance) { } await WIKI.models.groups.unassignUserFromGroup(group.id, req.params.userId) + + await audit(req, 'admin', 'unassignUserFromGroup', { + groupId: group.id, + groupName: group.name, + targetUserId: req.params.userId, + targetName: user?.name ?? null, + targetEmail: user?.email ?? null + }) + return reply.code(204).send() } ) diff --git a/backend/api/hooks.ts b/backend/api/hooks.ts index b1de207df..66014a3e8 100644 --- a/backend/api/hooks.ts +++ b/backend/api/hooks.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance } from 'fastify' import { EMITTED_EVENTS, HOOK_EVENTS } from '../models/hooks.ts' @@ -195,6 +196,14 @@ async function routes(app: FastifyInstance) { authHeader: req.body.authHeader }) + // -> No `authHeader`: a webhook's auth header is a credential for the endpoint it calls + await audit(req, 'admin', 'createHook', { + hookId: id, + name: req.body.name, + url: req.body.url, + events: req.body.events + }) + return { ok: true, message: 'Webhook created successfully.', @@ -272,6 +281,13 @@ async function routes(app: FastifyInstance) { await WIKI.models.hooks.updateHook(req.params.hookId, patch) + await audit(req, 'admin', 'updateHook', { + hookId: req.params.hookId, + name: patch.name, + url: patch.url, + changedFields: Object.keys(patch) + }) + return { ok: true, message: 'Webhook updated successfully.' @@ -312,6 +328,9 @@ async function routes(app: FastifyInstance) { if (!(await WIKI.models.hooks.deleteHook(req.params.hookId))) { return reply.notFound('Webhook does not exist.') } + + await audit(req, 'admin', 'deleteHook', { hookId: req.params.hookId }) + return reply.code(204).send() } ) diff --git a/backend/api/icons.ts b/backend/api/icons.ts index c9d277fb6..bc45692dc 100644 --- a/backend/api/icons.ts +++ b/backend/api/icons.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance } from 'fastify' /** @@ -88,6 +89,9 @@ async function routes(app: FastifyInstance) { async (req, reply) => { try { const set = await WIKI.models.icons.addSet(req.body.prefix.toLowerCase()) + + await audit(req, 'admin', 'addIconSet', { prefix: set.prefix, name: set.name }) + return { ok: true, message: `The ${set.name} icon set has been added.`, @@ -155,6 +159,9 @@ async function routes(app: FastifyInstance) { return reply.notFound('Icon set has not been added.') } await WIKI.models.icons.setSetState(prefix, req.body.isEnabled) + + await audit(req, 'admin', 'updateIconSet', { prefix, isEnabled: req.body.isEnabled }) + return { ok: true, message: `The ${prefix} icon set has been ${req.body.isEnabled ? 'enabled' : 'disabled'}.` @@ -211,6 +218,11 @@ async function routes(app: FastifyInstance) { return reply.notFound('Icon set has not been added.') } const deletedIcons = await WIKI.models.icons.deleteSet(prefix) + + // -> The icon count matters: deleting a set drops every icon stored for it, so anything a page + // still references from that prefix stops resolving + await audit(req, 'admin', 'deleteIconSet', { prefix, deletedIcons }) + return { ok: true, message: `The ${prefix} icon set has been deleted.`, @@ -285,9 +297,12 @@ async function routes(app: FastifyInstance) { } } }, - async (_req, reply) => { + async (req, reply) => { try { const refreshed = await WIKI.models.icons.refreshSets() + + await audit(req, 'admin', 'refreshIconSets', { refreshed }) + return { ok: true, message: `Refreshed ${refreshed} icon sets.`, @@ -477,6 +492,14 @@ async function routes(app: FastifyInstance) { }, async (req) => { const failed = await WIKI.models.icons.materializeIcons(req.body.icons) + + // -> Counts rather than the references themselves: picking an icon calls this, so a list of + // every name somebody browsed past would be noise rather than a record + await audit(req, 'admin', 'materializeIcons', { + requested: req.body.icons.length, + failed: failed.length + }) + return { ok: failed.length < 1, message: @@ -566,8 +589,11 @@ async function routes(app: FastifyInstance) { } } }, - async () => { + async (req) => { await WIKI.models.icons.purgeCache() + + await audit(req, 'admin', 'flushIconCache', {}) + return { ok: true, message: 'The icon cache has been purged.' diff --git a/backend/api/index.ts b/backend/api/index.ts index 612f12eff..df8872751 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -8,6 +8,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/apiKey.ts').then((m) => m.registerSchemas(app)) await import('./schemas/approval.ts').then((m) => m.registerSchemas(app)) await import('./schemas/asset.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/audit.ts').then((m) => m.registerSchemas(app)) await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app)) await import('./schemas/block.ts').then((m) => m.registerSchemas(app)) await import('./schemas/extension.ts').then((m) => m.registerSchemas(app)) @@ -29,6 +30,7 @@ async function routes(app: FastifyInstance) { app.register(import('./apiKeys.ts'), { prefix: '/api-keys' }) app.register(import('./approvals.ts')) app.register(import('./assets.ts')) + app.register(import('./auditLog.ts'), { prefix: '/audit' }) app.register(import('./authentication.ts')) app.register(import('./blocks.ts')) app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' }) diff --git a/backend/api/locales.ts b/backend/api/locales.ts index c0a6c54e7..222203c9a 100644 --- a/backend/api/locales.ts +++ b/backend/api/locales.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance } from 'fastify' /** @@ -46,7 +47,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Fetch the latest locales from the Wiki.js repository', description: - "Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.\n\n`en` is never fetched: it is the locale the interface is written in and ships with the wiki, loaded from `locales/en.json` on every boot. It counts as unchanged.", + 'Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.\n\n`en` is never fetched: it is the locale the interface is written in and ships with the wiki, loaded from `locales/en.json` on every boot. It counts as unchanged.', tags: ['Locales'], response: { 200: { @@ -69,8 +70,12 @@ async function routes(app: FastifyInstance) { } } }, - async () => { - return { ok: true, ...(await WIKI.models.locales.updateFromRemote()) } + async (req) => { + const result = await WIKI.models.locales.updateFromRemote() + + await audit(req, 'admin', 'fetchLocales', result) + + return { ok: true, ...result } } ) @@ -86,7 +91,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Download the strings of an available locale', description: - "Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.\n\nRefused for `en`, which ships with the wiki and is always installed.", + 'Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.\n\nRefused for `en`, which ships with the wiki and is always installed.', tags: ['Locales'], params: { type: 'object', @@ -112,6 +117,8 @@ async function routes(app: FastifyInstance) { } catch (err: any) { return reply.badRequest(err.message) } + await audit(req, 'admin', 'installLocale', { code: req.params.code }) + return { ok: true, message: 'Locale installed successfully.' } } ) @@ -175,6 +182,12 @@ async function routes(app: FastifyInstance) { } catch (err: any) { return reply.badRequest(err.message) } + await audit(req, 'admin', 'updateLocale', { + code: req.params.code, + customName: req.body?.customName ?? null, + customCode: req.body?.customCode ?? null + }) + return { ok: true, message: 'Aliases updated successfully.' } } ) diff --git a/backend/api/mail.ts b/backend/api/mail.ts index 0cea1d66f..10cff3f33 100644 --- a/backend/api/mail.ts +++ b/backend/api/mail.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance } from 'fastify' /** @@ -133,6 +134,10 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save mail configuration.') } + // -> Which settings were touched, never their values: `patch` carries the SMTP password and + // the DKIM private key + await audit(req, 'admin', 'updateMailConfig', { changedFields: Object.keys(patch) }) + return { ok: true, message: 'Mail configuration updated successfully.' @@ -204,6 +209,8 @@ async function routes(app: FastifyInstance) { baseUrl: WIKI.models.mail.baseUrl({ req, siteId }) } }) + await audit(req, 'admin', 'sendTestEmail', { recipient: req.body.recipient, siteId }) + return { ok: true, message: 'Test email sent successfully.' diff --git a/backend/api/navigation.ts b/backend/api/navigation.ts index 88afafd2f..f4bfd8844 100644 --- a/backend/api/navigation.ts +++ b/backend/api/navigation.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' import { NAVIGATION_MODES, type NavigationItem, type NavigationMode } from '../models/navigation.ts' @@ -214,6 +215,16 @@ async function routes(app: FastifyInstance) { mode: req.body.mode, items: req.body.items }) + // -> The mode and how many items, not the tree itself: a sidebar is hundreds of entries and + // the point of the record is that somebody changed the navigation of this page + await audit(req, 'admin', 'updatePageNavigation', { + siteId: req.params.siteId, + pageId: req.params.pageId, + mode: result.navigationMode, + navigationId: result.navigationId, + itemCount: req.body.items?.length ?? 0 + }) + return { ok: true, message: 'Navigation updated successfully.', diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 9a2c40464..a3cf2914b 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -2,6 +2,7 @@ import { validate as uuidValidate } from 'uuid' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { PageActor, PageInput } from '../models/pages.ts' import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts' +import { audit } from '../helpers/audit.ts' import { generatePathHash, normalizePagePath } from '../helpers/common.ts' import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts' @@ -176,6 +177,81 @@ async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: str * Pages API Routes */ async function routes(app: FastifyInstance) { + /** + * LIST RECENTLY EDITED PAGES + */ + app.get<{ Querystring: { limit?: number } }>( + '/pages/recent', + { + config: { + // -> `access:admin`, not a page permission: this fills a panel on the admin dashboard, which + // everyone who can open the admin area sees, and it is the same permission + // `users/recent-logins` fills the panel beside it with. Page rules are not consulted, so + // the answer is deliberately thin -- where a page is and when it was last written, and + // nothing of what it says. + permissions: ['access:admin'] + }, + schema: { + summary: 'List the most recently edited pages', + description: + 'What has been written lately, newest first, across every site. Ordered by the last write, which covers a creation as well as an edit — `isNew` says which of the two this row is.', + tags: ['Pages'], + querystring: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 } + } + }, + response: { + 200: { + description: 'The most recently edited pages, newest first', + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + siteId: { type: 'string', format: 'uuid' }, + locale: { type: 'string' }, + path: { type: 'string' }, + title: { type: 'string' }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + }, + isNew: { + type: 'boolean', + description: + 'Whether this is the page first appearing rather than a later edit of it.' + }, + url: { + type: 'string', + description: + "Where the page is, as a path on its own site — carrying a locale prefix only where that site's settings put one there." + }, + hostname: { + type: 'string', + nullable: true, + description: + 'The host that site answers on, for linking to a page on a site other than the one being browsed. Null for the catch-all site.' + }, + authorName: { + type: 'string', + nullable: true, + description: 'Who wrote the version that stands. Null once that account is gone.' + } + } + } + } + } + } + }, + async (req, reply) => { + reply.preventCache() + return WIKI.models.pages.getRecentlyEdited({ limit: req.query.limit ?? 10 }) + } + ) + /** * LIST PAGES */ @@ -676,6 +752,17 @@ async function routes(app: FastifyInstance) { request, and it is the reader's own deliberate action that starts it. */ req.session.unlockedPages = [...new Set([...(req.session.unlockedPages ?? []), page.id])] + + // -> The one read this log records, because it is a password being accepted rather than a page + // being looked at. A wrong password is not recorded, for the same reason a failed login is + // not: this endpoint is open to anybody who can reach the page. + await audit(req, 'page', 'unlockPage', { + pageId: page.id, + siteId: req.params.siteId, + locale: page.locale, + path: page.path + }) + return page } ) @@ -725,7 +812,21 @@ async function routes(app: FastifyInstance) { if (!mayOnPage(req, 'write:pages', { path: req.body.path, locale: req.body.locale })) { return reply.forbidden('You are not allowed to create a page here.') } - const page = await WIKI.models.pages.createPage(req.params.siteId, req.body, actor) + const { page, versionId } = await WIKI.models.pages.createPage( + req.params.siteId, + req.body, + actor + ) + + await audit(req, 'page', 'createPage', { + pageId: page.id, + siteId: req.params.siteId, + locale: page.locale, + path: page.path, + title: page.title, + versionId + }) + return { ok: true, message: 'Page created successfully.', @@ -780,15 +881,27 @@ async function routes(app: FastifyInstance) { if (!mayOnPage(req, 'write:pages', target)) { return reply.forbidden('You are not allowed to edit this page.') } - const page = await WIKI.models.pages.updatePage( + const change = await WIKI.models.pages.updatePage( req.params.siteId, req.params.pageId, req.body, actor ) - if (!page) { + if (!change) { return reply.notFound('This page does not exist.') } + const { page, versionId } = change + + // -> What changed is not repeated here: `versionId` points at the `pageHistory` row that holds + // the page as it now stands, which is the record of the edit itself + await audit(req, 'page', 'updatePage', { + pageId: page.id, + siteId: req.params.siteId, + locale: page.locale, + path: page.path, + title: page.title, + versionId + }) /* Anyone else editing this page right now is looking at the text that was just stored, so their editor should stop calling it unsaved. Told through the collaboration room rather than answered @@ -892,15 +1005,27 @@ async function routes(app: FastifyInstance) { ) { return reply.forbidden('You are not allowed to move this page there.') } - const page = await WIKI.models.pages.movePage( + const change = await WIKI.models.pages.movePage( req.params.siteId, req.params.pageId, req.body, actor ) - if (!page) { + if (!change) { return reply.notFound('This page does not exist.') } + const { page, versionId } = change + + await audit(req, 'page', 'movePage', { + pageId: page.id, + siteId: req.params.siteId, + locale: page.locale, + path: page.path, + previousLocale: target.locale, + previousPath: target.path, + versionId + }) + return { ok: true, message: 'Page moved successfully.', @@ -964,6 +1089,14 @@ async function routes(app: FastifyInstance) { if (!queued) { return reply.notFound('This page does not exist.') } + + await audit(req, 'page', 'renderPage', { + pageId: req.params.pageId, + siteId: req.params.siteId, + locale: target.locale, + path: target.path + }) + return reply.code(202).send({ ok: true, message: 'Page queued for rendering.' @@ -1008,9 +1141,26 @@ async function routes(app: FastifyInstance) { if (!mayOnPage(req, 'delete:pages', target)) { return reply.forbidden('You are not allowed to delete this page.') } - if (!(await WIKI.models.pages.deletePage(req.params.siteId, req.params.pageId, actor))) { + const deleted = await WIKI.models.pages.deletePage( + req.params.siteId, + req.params.pageId, + actor + ) + if (!deleted) { return reply.notFound('This page does not exist.') } + + // -> The version recorded here is the deletion itself, which is what recovering the page would + // be built from — so the entry says where to find the page that is no longer there + await audit(req, 'page', 'deletePage', { + pageId: req.params.pageId, + siteId: req.params.siteId, + locale: target.locale, + path: target.path, + title: target.title, + versionId: deleted.versionId + }) + return reply.code(204).send() } ) diff --git a/backend/api/scheduler.ts b/backend/api/scheduler.ts index d8e2757f7..d137bb7ec 100644 --- a/backend/api/scheduler.ts +++ b/backend/api/scheduler.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import type { FastifyInstance } from 'fastify' import { JOB_STATES, type JobState } from '../models/jobs.ts' @@ -89,6 +90,17 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('The scheduler could not queue the job.') } + /* + A scheduled task run by hand IS a user action, and is recorded as one — what the task itself + then does is not, since by the time it runs there is no request and no requester. This entry + is the link between the two: the job id is what `jobHistory` records the outcome against. + */ + await audit(req, 'admin', 'runScheduledTask', { + scheduleId: req.params.scheduleId, + task: entry.task, + jobId: id + }) + return { ok: true, message: 'Task queued successfully.', @@ -161,6 +173,9 @@ async function routes(app: FastifyInstance) { if (!cancelled) { return reply.notFound('No pending job with this ID.') } + + await audit(req, 'admin', 'cancelJob', { jobId: req.params.jobId }) + return reply.code(204).send() } ) @@ -284,6 +299,12 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('The scheduler could not queue the job.') } + await audit(req, 'admin', 'retryJob', { + task: entry.task, + retriedJobId: req.params.jobId, + jobId: id + }) + return { ok: true, message: 'Job queued successfully.', diff --git a/backend/api/schemas/audit.ts b/backend/api/schemas/audit.ts new file mode 100644 index 000000000..5d9dbd63f --- /dev/null +++ b/backend/api/schemas/audit.ts @@ -0,0 +1,91 @@ +import type { FastifyInstance } from 'fastify' +import { AUDIT_ACTION_KEYS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../../models/auditLog.ts' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * AUDIT ENTRY - One recorded action + */ + app.addSchema({ + $id: 'AuditEntry', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + ts: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + }, + kind: { + $ref: 'AuditKind#' + }, + action: { + type: 'string', + description: + 'What was done, camelCase. A translation key rather than a sentence — the interface looks it up as `admin.audit.actions.`.' + }, + clientIP: { + type: 'string', + description: 'Address the request came from.' + }, + userId: { + type: 'string', + format: 'uuid', + nullable: true, + description: + 'The account that acted, while it exists. Null once it is deleted, and null for an action nobody was signed in for — the name and email on `meta.actor` are what the entry is read by then.' + }, + meta: { + type: 'object', + additionalProperties: true, + description: + 'What the action touched, plus an `actor` block holding the email, display name and address the requester had at the time. Never carries a secret, and never the content of a change — a page edit records the `pageHistory` version its change produced instead.' + } + } + }) + + /** + * AUDIT KIND - The areas an action can belong to + */ + app.addSchema({ + $id: 'AuditKind', + type: 'string', + enum: [...AUDIT_KINDS] + }) + + /** + * AUDIT ACTION - Every action key the wiki records + */ + app.addSchema({ + $id: 'AuditAction', + type: 'string', + enum: AUDIT_ACTION_KEYS + }) + + /** + * AUDIT CONFIG - How long entries are kept + */ + app.addSchema({ + $id: 'AuditConfig', + type: 'object', + properties: { + retentionDays: { + type: 'integer', + minimum: 0, + description: `How many days of log to keep. Entries older than this are deleted daily by the \`purgeAuditLog\` task. Zero keeps everything for ever, and anything else is at least ${MIN_RETENTION_DAYS} — see the PUT for why there is a floor.` + }, + total: { + type: 'integer', + description: 'How many entries the log currently holds.' + }, + oldestEntry: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the oldest entry was recorded, or null when the log is empty.' + } + } + }) +} diff --git a/backend/api/sites.ts b/backend/api/sites.ts index ac3e2a320..3637dc4a5 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -1,4 +1,5 @@ import { validate as uuidValidate } from 'uuid' +import { audit } from '../helpers/audit.ts' import { CustomError, normalizePastedDestination } from '../helpers/common.ts' import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts' import { siteAssetKinds } from '../models/sites.ts' @@ -238,6 +239,13 @@ async function routes(app: FastifyInstance) { const result = await WIKI.models.sites.createSite(req.body.hostname, { title: req.body.title }) + + await audit(req, 'admin', 'createSite', { + siteId: result.id, + hostname: req.body.hostname, + title: req.body.title + }) + return { ok: true, message: 'Site created successfully.', @@ -494,6 +502,20 @@ async function routes(app: FastifyInstance) { isEnabled: req.body.isEnabled, ...(Object.keys(config).length < 1 ? {} : { config }) }) + + // -> The config is a large nested blob covering everything from the theme to the storage + // layout, so its top-level sections are what is recorded rather than the whole of it + await audit(req, 'admin', 'updateSite', { + siteId: req.params.siteId, + ...(req.body.hostname !== undefined ? { hostname: req.body.hostname } : {}), + ...(req.body.isEnabled !== undefined ? { isEnabled: req.body.isEnabled } : {}), + changedFields: [ + ...(req.body.hostname !== undefined ? ['hostname'] : []), + ...(req.body.isEnabled !== undefined ? ['isEnabled'] : []), + ...Object.keys(config).map((section) => `config.${section}`) + ] + }) + return { ok: true, message: 'Site updated successfully.' @@ -571,6 +593,11 @@ async function routes(app: FastifyInstance) { await WIKI.models.sites.setAsset(req.params.siteId, req.params.kind, data) + await audit(req, 'admin', 'updateSiteImage', { + siteId: req.params.siteId, + kind: req.params.kind + }) + return { ok: true, message: 'Image uploaded successfully.' @@ -631,6 +658,11 @@ async function routes(app: FastifyInstance) { await WIKI.models.sites.clearAsset(req.params.siteId, req.params.kind) + await audit(req, 'admin', 'deleteSiteImage', { + siteId: req.params.siteId, + kind: req.params.kind + }) + return { ok: true, message: 'Image cleared successfully.' @@ -668,10 +700,18 @@ async function routes(app: FastifyInstance) { } }, async (req, reply) => { + // -> Read before it goes, so that the entry can say which site this was rather than only which + // id it had + const doomed = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) try { if ((await WIKI.models.sites.countSites()) <= 1) { reply.conflict('Cannot delete the last site. At least 1 site must exist at all times.') } else if (await WIKI.models.sites.deleteSite(req.params.siteId)) { + await audit(req, 'admin', 'deleteSite', { + siteId: req.params.siteId, + hostname: doomed?.hostname ?? null, + title: doomed?.config?.title ?? null + }) reply.code(204) } else { reply.badRequest('Site does not exist.') diff --git a/backend/api/storage.ts b/backend/api/storage.ts index 6e909ba95..a6be1df94 100644 --- a/backend/api/storage.ts +++ b/backend/api/storage.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import { maskSensitiveProps } from '../helpers/common.ts' import { STORAGE_DIRECT_ACCESS_FALLBACKS, STORAGE_TARGET_STATUSES } from '../models/storage.ts' import type { FastifyInstance } from 'fastify' @@ -282,6 +283,22 @@ async function routes(app: FastifyInstance) { } } + /* + Which targets were written and which site-wide settings were touched — never the target + configs themselves, which are where the S3 secret keys and the SSH passwords live. A target's + module and title are enough to say what was changed. + */ + await audit(req, 'admin', 'updateStorage', { + siteId: req.params.siteId, + targets: patches.map(({ target, patch }) => ({ + targetId: target.id, + module: target.module, + title: target.title, + changedFields: Object.keys(patch).filter((key) => key !== 'id') + })), + changedFields: Object.keys(req.body).filter((key) => key !== 'targets') + }) + return { ok: true, message: 'Storage configuration updated successfully.', @@ -361,6 +378,21 @@ async function routes(app: FastifyInstance) { try { const message = await WIKI.models.storage.executeAction(target, req.params.action, actorId) + + /* + The action is recorded, not what it did. An import can create hundreds of pages and each of + those goes through `adoptStoredPage`, which is reached from a scheduled sync as well as from + here — so those pages have their own history versions but no audit entries of their own. + This is the entry that says a person asked for it. + */ + await audit(req, 'admin', 'runStorageAction', { + siteId: req.params.siteId, + targetId: target.id, + module: target.module, + title: target.title, + action: req.params.action + }) + return { ok: true, message: message ?? 'Action completed successfully.' diff --git a/backend/api/system.ts b/backend/api/system.ts index 7f9e296a7..f6668484d 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -11,6 +11,7 @@ import { users as usersTable } from '../db/schema.ts' import maintenance from '../core/maintenance.ts' +import { audit } from '../helpers/audit.ts' import { purgeTimeframes } from '../models/pageHistory.ts' import type { PurgeTimeframe } from '../models/pageHistory.ts' import type { FastifyInstance } from 'fastify' @@ -292,6 +293,8 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save the system flags.') } + await audit(req, 'admin', 'updateFlags', patch) + return { ok: true, message: 'System flags updated successfully.' @@ -369,6 +372,10 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save the security configuration.') } + // -> The security settings are the one configuration blob recorded in full: none of them is a + // secret, and what a CSP or a rate limit was set to is precisely what gets asked about later + await audit(req, 'admin', 'updateSecurity', patch) + return { ok: true, message: 'Security configuration updated successfully.' @@ -498,6 +505,13 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save the search configuration.') } + await audit(req, 'admin', 'updateSearchConfig', { + ...(req.body.termHighlighting !== undefined + ? { termHighlighting: req.body.termHighlighting } + : {}), + ...(req.body.dictOverrides !== undefined ? { dictOverrides: req.body.dictOverrides } : {}) + }) + return { ok: true, message: 'Search configuration updated successfully.' @@ -545,6 +559,9 @@ async function routes(app: FastifyInstance) { if (!added?.id) { return reply.internalServerError('The scheduler could not queue the rebuild.') } + + await audit(req, 'admin', 'rebuildSearchIndex', { jobId: added.id }) + return { ok: true, message: 'Search index rebuild queued successfully.', @@ -653,6 +670,12 @@ async function routes(app: FastifyInstance) { // to wonder why nothing changed. const restartRequired = WIKI.models.extensions.hasLoadFailed(definition) + await audit(req, 'admin', 'installExtension', { + extensionKey: req.params.extensionKey, + title: definition.title, + restartRequired + }) + return { ok: true, message: restartRequired @@ -746,6 +769,8 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save the API state.') } + await audit(req, 'admin', 'updateApiState', { isEnabled: req.body.isEnabled }) + return { ok: true, message: req.body.isEnabled ? 'API enabled successfully.' : 'API disabled successfully.', @@ -837,6 +862,8 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save the metrics endpoint state.') } + await audit(req, 'admin', 'updateMetricsState', { isEnabled: req.body.isEnabled }) + return { ok: true, message: req.body.isEnabled @@ -934,9 +961,12 @@ async function routes(app: FastifyInstance) { } } }, - async () => { + async (req) => { const count = maintenance.disconnectWebsockets() WIKI.events.outbound.emit('disconnectWebsockets') + + await audit(req, 'admin', 'disconnectWebsockets', { count }) + return { ok: true, message: `Closed ${count} websocket connection(s) on this instance.`, @@ -975,9 +1005,12 @@ async function routes(app: FastifyInstance) { } } }, - async () => { + async (req) => { await maintenance.flushCaches() WIKI.events.outbound.emit('flushCaches') + + await audit(req, 'admin', 'flushCache', {}) + return { ok: true, message: 'The cache has been flushed.' @@ -1059,6 +1092,9 @@ async function routes(app: FastifyInstance) { if (invalidatedKeys === null) { return reply.internalServerError('Failed to save the new certificates.') } + + await audit(req, 'admin', 'regenerateCertificates', { invalidatedKeys }) + return { ok: true, message: `Certificates regenerated successfully. ${invalidatedKeys} API key(s) will have to be reissued.`, @@ -1101,8 +1137,11 @@ async function routes(app: FastifyInstance) { } } }, - async () => { + async (req) => { const count = await WIKI.models.apiKeys.purgeRevoked() + + await audit(req, 'admin', 'purgeApiKeys', { count }) + return { ok: true, message: `Purged ${count} revoked API key(s).`, @@ -1151,6 +1190,9 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save the new session secret.') } + // -> Before the session goes, since that is where the entry's actor comes from + await audit(req, 'admin', 'invalidateSessions', { count }) + /* This request's own session, which the rows above no longer include but which would come straight back without this: @fastify/session writes the session it is holding as the response @@ -1215,6 +1257,11 @@ async function routes(app: FastifyInstance) { }, async (req) => { const count = await WIKI.models.pageHistory.purge(req.body.olderThan) + + // -> Worth recording precisely because of what it destroys: the versions this took away are + // what page audit entries older than it point at + await audit(req, 'admin', 'purgePageHistory', { olderThan: req.body.olderThan, count }) + return { ok: true, message: `Purged ${count} page version(s).`, @@ -1283,6 +1330,8 @@ async function routes(app: FastifyInstance) { id: req.session.user!.id, permissions: req.session.permissions ?? [] }) + await audit(req, 'admin', 'purgeSampleContent', { siteId: req.body.siteId, count }) + return { ok: true, message: `Purged ${count} page(s) tagged ${SAMPLE_CONTENT_TAG}.`, @@ -1323,7 +1372,7 @@ async function routes(app: FastifyInstance) { } } }, - async () => { + async (req) => { const renderJob = await WIKI.scheduler.addJob({ task: 'checkVersion', maxRetries: 0, @@ -1332,6 +1381,9 @@ async function routes(app: FastifyInstance) { // NOTE: `addJob` resolves to undefined if enqueueing failed, in which case this throws — // preserving the existing behavior. await renderJob!.promise + + await audit(req, 'admin', 'checkForUpdate', { latest: WIKI.config.update.version }) + return { current: WIKI.version, latest: WIKI.config.update.version, diff --git a/backend/api/tree.ts b/backend/api/tree.ts index 40bc16c33..263587bb7 100644 --- a/backend/api/tree.ts +++ b/backend/api/tree.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts' +import { audit } from '../helpers/audit.ts' import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts' import { actorFrom } from './pages.ts' @@ -560,6 +561,14 @@ async function routes(app: FastifyInstance) { pathName: req.body.pathName, title: req.body.title }) + await audit(req, 'page', 'createFolder', { + folderId: folder.id, + siteId: req.params.siteId, + locale, + path: target, + title: req.body.title + }) + return { ok: true, message: 'Folder created successfully.', @@ -618,6 +627,15 @@ async function routes(app: FastifyInstance) { title: req.body.title, actorId: req.session.user?.id }) + await audit(req, 'page', 'updateFolder', { + folderId: req.params.folderId, + siteId: req.params.siteId, + locale: existing.locale, + path: folderPathOf(folder), + previousPath: folderPathOf(existing), + title: folder.title + }) + return { ok: true, message: 'Folder renamed successfully.', @@ -710,6 +728,17 @@ async function routes(app: FastifyInstance) { locale: destinationLocale, actorId: req.session.user?.id }) + // -> One entry for a move that took a whole branch with it. The pages underneath moved rather + // than changed, so they have no history versions of their own to point at. + await audit(req, 'page', 'moveFolder', { + folderId: req.params.folderId, + siteId: req.params.siteId, + locale: destinationLocale, + path: folderPathOf(folder), + previousLocale: existing.locale, + previousPath: folderPathOf(existing) + }) + return { ok: true, message: 'Folder moved successfully.', @@ -810,6 +839,16 @@ async function routes(app: FastifyInstance) { locale: destinationLocale, actor }) + await audit(req, 'page', 'duplicateFolder', { + folderId: folder.id, + siteId: req.params.siteId, + locale: destinationLocale, + path: folderPathOf(folder), + sourceFolderId: req.params.folderId, + sourceLocale: existing.locale, + sourcePath: folderPathOf(existing) + }) + return { ok: true, message: 'Folder duplicated successfully.', @@ -878,6 +917,14 @@ async function routes(app: FastifyInstance) { folderId: req.params.folderId, hue: req.body.hue }) + await audit(req, 'page', 'setFolderColor', { + folderId: req.params.folderId, + siteId: req.params.siteId, + locale: existing.locale, + path: folderPathOf(existing), + hue: req.body.hue + }) + return { ok: true, message: 'Folder colour set successfully.', @@ -928,6 +975,18 @@ async function routes(app: FastifyInstance) { // asset actually live await WIKI.models.pages.deleteOrphaned(req.params.siteId, removed.pages, actor) await WIKI.models.assets.deleteOrphaned(req.params.siteId, removed.assets, actor.id) + + // -> Counts rather than ids: a folder deletion can take hundreds of pages with it, and each of + // those already has its own history version recording the deletion + await audit(req, 'page', 'deleteFolder', { + folderId: req.params.folderId, + siteId: req.params.siteId, + locale: existing.locale, + path: folderPathOf(existing), + deletedPages: removed.pages.length, + deletedAssets: removed.assets.length + }) + return reply.code(204).send() } ) diff --git a/backend/api/users.ts b/backend/api/users.ts index 5e1d340f4..d5da69adc 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts' import { detectImageMime, imageMimeTypes } from '../helpers/images.ts' import type { FastifyInstance, FastifyRequest } from 'fastify' @@ -356,6 +357,12 @@ async function routes(app: FastifyInstance) { cvd: profile.cvd } + // -> The fields that were touched, not the values: `patch` carries whatever the profile form + // sends, and a field added to it later should not start appearing in the log by itself + await audit(req, 'profile', 'updateProfile', { + changedFields: Object.keys(patch) + }) + return { ok: true, message: 'Profile updated successfully.', @@ -417,6 +424,8 @@ async function routes(app: FastifyInstance) { // -> The account menu reads `hasAvatar` off the session on every page load req.session.user = { ...req.session.user!, hasAvatar: true } + await audit(req, 'profile', 'updateAvatar', {}) + return { ok: true, message: 'Avatar uploaded successfully.' @@ -463,6 +472,8 @@ async function routes(app: FastifyInstance) { await WIKI.models.users.clearAvatar(userId) req.session.user = { ...req.session.user!, hasAvatar: false } + await audit(req, 'profile', 'deleteAvatar', {}) + return { ok: true, message: 'Avatar cleared successfully.' @@ -597,6 +608,9 @@ async function routes(app: FastifyInstance) { // -> The session outlived the user it points at return reply.unauthorized() } + + await audit(req, 'profile', 'updateEditorSettings', { editor: req.params.editor }) + return { ok: true, config } } ) @@ -730,6 +744,8 @@ async function routes(app: FastifyInstance) { rethrowAsBadRequest(err) } + await audit(req, 'profile', 'changePassword', { strategyId: req.body.strategyId }) + return { ok: true, message: 'Password changed successfully.' @@ -789,6 +805,11 @@ async function routes(app: FastifyInstance) { rethrowAsBadRequest(err) } + await audit(req, 'profile', 'togglePasswordLogin', { + strategyId: req.body.strategyId, + isEnabled: req.body.isEnabled + }) + return { ok: true, message: req.body.isEnabled ? 'Password login enabled.' : 'Password login disabled.' @@ -923,6 +944,8 @@ async function routes(app: FastifyInstance) { rethrowAsBadRequest(err) } + await audit(req, 'profile', 'enableTfa', { strategyId: req.body.strategyId }) + return { ok: true, message: '2FA enabled successfully.' @@ -967,6 +990,8 @@ async function routes(app: FastifyInstance) { rethrowAsBadRequest(err) } + await audit(req, 'profile', 'disableTfa', { strategyId: req.params.strategyId }) + return reply.code(204).send() } ) @@ -1074,6 +1099,11 @@ async function routes(app: FastifyInstance) { registrationResponse: req.body.registrationResponse as any, pending: req.session.passkeyRegistration }) + await audit(req, 'profile', 'registerPasskey', { + passkeyId: passkey.id, + name: passkey.name + }) + return { ok: true, passkey @@ -1123,6 +1153,9 @@ async function routes(app: FastifyInstance) { if (!(await WIKI.models.passkeys.remove(userId, req.params.passkeyId))) { return reply.notFound('You have no passkey with this ID.') } + + await audit(req, 'profile', 'deletePasskey', { passkeyId: req.params.passkeyId }) + return reply.code(204).send() } ) @@ -1218,6 +1251,8 @@ async function routes(app: FastifyInstance) { return reply.internalServerError('Failed to save user defaults.') } + await audit(req, 'admin', 'updateUserDefaults', { changedFields: Object.keys(patch) }) + return { ok: true, message: 'User defaults updated successfully.' @@ -1447,6 +1482,13 @@ async function routes(app: FastifyInstance) { }) } catch (err: any) { WIKI.logger.warn(`Welcome email for new user ${id} failed: ${err.message}`) + await audit(req, 'admin', 'createUser', { + targetUserId: id, + name: req.body.name, + email: req.body.email, + groups: req.body.groups ?? [], + welcomeEmailSent: false + }) return { ok: true, message: 'User created successfully.', @@ -1455,6 +1497,17 @@ async function routes(app: FastifyInstance) { } } } + + // -> Who the account is for, and which groups it was put in — never the password it was + // given, which is the one thing about a new account that must not be recoverable from here + await audit(req, 'admin', 'createUser', { + targetUserId: id, + name: req.body.name, + email: req.body.email, + groups: req.body.groups ?? [], + welcomeEmailSent: Boolean(req.body.sendWelcomeEmail) + }) + return { ok: true, message: 'User created successfully.', @@ -1651,6 +1704,21 @@ async function routes(app: FastifyInstance) { if (req.body.auth !== undefined) { await WIKI.models.users.setUserAuthFlags(req.params.userId, req.body.auth) } + + // -> Group membership is listed because who is in which group IS the permission model, so a + // change to it is the one thing here worth being able to read back without a diff + await audit(req, 'admin', 'updateUser', { + targetUserId: user.id, + targetName: user.name, + targetEmail: user.email, + changedFields: [ + ...Object.keys(patch), + ...(req.body.groups !== undefined ? ['groups'] : []), + ...(req.body.auth !== undefined ? ['auth'] : []) + ], + ...(req.body.groups !== undefined ? { groups: req.body.groups } : {}) + }) + return { ok: true, message: 'User updated successfully.' @@ -1733,6 +1801,12 @@ async function routes(app: FastifyInstance) { if (!updated) { return reply.notFound('User does not exist.') } + + await audit(req, 'admin', 'resetUserPassword', { + targetUserId: req.params.userId, + mustChangePassword: req.body.mustChangePassword ?? false + }) + return { ok: true, message: 'User password updated successfully.' @@ -1804,6 +1878,11 @@ async function routes(app: FastifyInstance) { siteId: req.body?.siteId, req }) + await audit(req, 'admin', 'sendWelcomeEmail', { + targetUserId: req.params.userId, + siteId: req.body?.siteId ?? null + }) + return { ok: true, message: 'Welcome email sent successfully.' @@ -1882,6 +1961,18 @@ async function routes(app: FastifyInstance) { try { await WIKI.models.users.deleteUser(user.id) + + /* + The deleted account's own name and email are on `meta` rather than on `meta.actor`, which + belongs to whoever pressed the button. This is also the entry that explains why every OTHER + entry that user left behind now has a null `userId`. + */ + await audit(req, 'admin', 'deleteUser', { + targetUserId: user.id, + targetName: user.name, + targetEmail: user.email + }) + return reply.code(204).send() } catch (err: any) { // -> Pages and assets reference users without a cascade, so a user who authored content diff --git a/backend/api/watching.ts b/backend/api/watching.ts index e1e716c32..71fd7f569 100644 --- a/backend/api/watching.ts +++ b/backend/api/watching.ts @@ -1,3 +1,4 @@ +import { audit } from '../helpers/audit.ts' import { actorFrom, mayOnPage, unlockedFor } from './pages.ts' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' @@ -95,6 +96,14 @@ async function routes(app: FastifyInstance) { pageId: page.id, userId }) + + await audit(req, 'page', 'watchPage', { + pageId: page.id, + siteId: req.params.siteId, + locale: page.locale, + path: page.path + }) + return { ok: true, isWatching: true } } ) @@ -135,6 +144,14 @@ async function routes(app: FastifyInstance) { it — and there is nothing to protect anyway: this only ever deletes the caller's own row. */ await WIKI.models.pageWatching.unwatch({ pageId: req.params.pageId, userId }) + + // -> No path or locale: the page was deliberately not loaded above, so the id is all this + // request ever knew about it + await audit(req, 'page', 'unwatchPage', { + pageId: req.params.pageId, + siteId: req.params.siteId + }) + return { ok: true, isWatching: false } } ) diff --git a/backend/base.yml b/backend/base.yml index 2fe17a238..3160c835d 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -56,6 +56,14 @@ defaults: # DB defaults api: isEnabled: false + audit: + # How many days of audit log to keep. Rows older than this are deleted by the `purgeAuditLog` + # task, which runs daily. Set to 0 to keep everything for ever. + # + # Either 0 or at least 30: a shorter window would let somebody act and have the record of it + # purged before anybody had reason to look. A value between the two is read as 30 rather than + # honoured, wherever it came from. + retentionDays: 90 mail: senderName: '' senderEmail: '' diff --git a/backend/db/migrations/20260906032152_scarlett/migration.sql b/backend/db/migrations/20260906032152_scarlett/migration.sql new file mode 100644 index 000000000..09d7d664c --- /dev/null +++ b/backend/db/migrations/20260906032152_scarlett/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "auditLog" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "ts" timestamp DEFAULT now() NOT NULL, + "kind" varchar(16) NOT NULL, + "action" varchar(64) NOT NULL, + "clientIP" varchar(45) DEFAULT '' NOT NULL, + "meta" jsonb DEFAULT '{}' NOT NULL, + "userId" uuid +); +--> statement-breakpoint +CREATE INDEX "auditLog_ts_idx" ON "auditLog" ("ts" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "auditLog_userId_idx" ON "auditLog" ("userId","ts" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "auditLog_kind_idx" ON "auditLog" ("kind","ts" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "auditLog_action_idx" ON "auditLog" ("action","ts" DESC NULLS LAST);--> statement-breakpoint +ALTER TABLE "auditLog" ADD CONSTRAINT "auditLog_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/backend/db/migrations/20260906032152_scarlett/snapshot.json b/backend/db/migrations/20260906032152_scarlett/snapshot.json new file mode 100644 index 000000000..c222504da --- /dev/null +++ b/backend/db/migrations/20260906032152_scarlett/snapshot.json @@ -0,0 +1,6097 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "3425541e-bdcc-4010-955b-dda671165ac7", + "prevIds": [ + "c4277c08-bb67-491e-9169-97038b9a50da" + ], + "ddl": [ + { + "values": [ + "document", + "image", + "other" + ], + "name": "assetKind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "success", + "error" + ], + "name": "hookState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "active", + "completed", + "failed", + "interrupted" + ], + "name": "jobHistoryState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "draft", + "published", + "scheduled" + ], + "name": "pagePublishState", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "inherit", + "override", + "overrideExact", + "hide", + "hideExact" + ], + "name": "treeNavigationMode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "folder", + "page", + "asset" + ], + "name": "treeType", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "apiKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "approvalRules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "assets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auditLog", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "authentication", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "hooks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "iconSets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "icons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobLock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobSchedule", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "locales", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageEditSubmissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageHistory", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageRenderQueue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pageWatching", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rateLimits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "siteAssets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sites", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "storage", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tree", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userAvatars", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userGroups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "userKeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "keyShort", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "expiration", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRevoked", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "apiKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'START'", + "generated": null, + "identity": null, + "name": "match", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "submitterGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "reviewerGroups", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "approvalRules" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileExt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "assetKind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'other'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'application/octet-stream'", + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileSize", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "assets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "clientIP", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "auditLog" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "displayName", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "registration", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "allowedEmailRegex", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "autoEnrollGroups", + "entityType": "columns", + "schema": "public", + "table": "authentication" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "block", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isCustom", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnFirstLogin", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "redirectOnLogout", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "events", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "includeMetadata", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "includeContent", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "acceptUntrusted", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authHeader", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "hookState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "hooks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "info", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refreshedAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "iconSets" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "16", + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "left", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "top", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "rotate", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "vFlip", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "icons" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jobHistoryState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "wasScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastErrorMessage", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "executedBy", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "jobHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastCheckedBy", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastCheckedAt", + "entityType": "columns", + "schema": "public", + "table": "jobLock" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cron", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobSchedule" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "useWorker", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "retries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waitUntil", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isScheduled", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "jobs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nativeName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(8)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "script", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isRTL", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "strings", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "completeness", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "items", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "patch", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseHash", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestName", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guestEmail", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'updated'", + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "versionDate", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageHistory" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowScripts", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowStyles", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requestedById", + "entityType": "columns", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "pageWatching" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alias", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "pagePublishState", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "publishState", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishStartDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishEndDate", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "relations", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "localeGroupId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "render", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "searchContent", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "toc", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editor", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentType", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isBrowsable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isSearchable", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"pages\".\"publishState\" != 'draft' AND \"pages\".\"isSearchable\"", + "type": "stored" + }, + "identity": null, + "name": "isSearchableComputed", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "ratingScore", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "ratingCount", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "scripts", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "historyData", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "creatorId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ownerId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "pages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hits", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "windowStartedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bannedUntil", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "rateLimits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "siteAssets" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "sites" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isEnabled", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "contentTypes", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assetDelivery", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "config", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "storage" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usageCount", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "ltree", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folderPath", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileName", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeType", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tree", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "treeNavigationMode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'inherit'", + "generated": null, + "identity": null, + "name": "navigationMode", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "navigationId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "ARRAY[]", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "siteId", + "entityType": "columns", + "schema": "public", + "table": "tree" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "userAvatars" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupId", + "entityType": "columns", + "schema": "public", + "table": "userGroups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "validUntil", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "userKeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "passkeys", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "prefs", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasAvatar", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isActive", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isSystem", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isVerified", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastLoginAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "approvalRules_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "assets_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_kind_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ts", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auditLog_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "locales_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "locales" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"authorId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageEditSubmissions_page_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_pageId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "versionDate", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageHistory_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageRenderQueue_createdAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_user_site_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pageWatching_page_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_authorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "creatorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_creatorId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ownerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_ownerId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ts", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_ts_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "pages_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isSearchableComputed", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_isSearchableComputed_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "localeGroupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pages_localeGroupId_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "rateLimits_updatedAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "rateLimits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "module", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "storage_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_folderpath_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "folderPath", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gist", + "concurrently": false, + "name": "tree_folderpath_gist_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_fileName_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tree", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_locale_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationMode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationMode_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "navigationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_navigationId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "tree_tags_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tree_siteId_idx", + "entityType": "indexes", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_groupId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userGroups_composite_idx", + "entityType": "indexes", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "userKeys_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "userKeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastLoginAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_lastLoginAt_idx", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "approvalRules_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "approvalRules" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "assets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "assets" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "auditLog_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "auditLog" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "blocks_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "prefix" + ], + "schemaTo": "public", + "tableTo": "iconSets", + "columnsTo": [ + "prefix" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "icons_prefix_iconSets_prefix_fkey", + "entityType": "fks", + "schema": "public", + "table": "icons" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "navigation_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "navigation" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageEditSubmissions_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageEditSubmissions_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageEditSubmissions" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageHistory_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageHistory_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageHistory" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageRenderQueue_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageRenderQueue_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "requestedById" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "pageRenderQueue_requestedById_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageRenderQueue" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "schemaTo": "public", + "tableTo": "pages", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_pageId_pages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pageWatching_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pageWatching_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pageWatching" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_authorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "creatorId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_creatorId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "ownerId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_ownerId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "pages_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "pages" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "sessions_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "siteAssets_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "siteAssets" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "storage_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "storage" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tags_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": false, + "columns": [ + "siteId" + ], + "schemaTo": "public", + "tableTo": "sites", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tree_siteId_sites_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tree" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "groupId" + ], + "schemaTo": "public", + "tableTo": "groups", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "userGroups_groupId_groups_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userGroups" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "userKeys_userId_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "userKeys" + }, + { + "columns": [ + "prefix", + "name" + ], + "nameExplicit": false, + "name": "icons_pkey", + "entityType": "pks", + "schema": "public", + "table": "icons" + }, + { + "columns": [ + "siteId", + "kind" + ], + "nameExplicit": false, + "name": "siteAssets_pkey", + "entityType": "pks", + "schema": "public", + "table": "siteAssets" + }, + { + "columns": [ + "userId", + "groupId" + ], + "nameExplicit": false, + "name": "userGroups_pkey", + "entityType": "pks", + "schema": "public", + "table": "userGroups" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "apiKeys_pkey", + "schema": "public", + "table": "apiKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "approvalRules_pkey", + "schema": "public", + "table": "approvalRules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "assets_pkey", + "schema": "public", + "table": "assets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "auditLog_pkey", + "schema": "public", + "table": "auditLog", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "authentication_pkey", + "schema": "public", + "table": "authentication", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pkey", + "schema": "public", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "groups_pkey", + "schema": "public", + "table": "groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "hooks_pkey", + "schema": "public", + "table": "hooks", + "entityType": "pks" + }, + { + "columns": [ + "prefix" + ], + "nameExplicit": false, + "name": "iconSets_pkey", + "schema": "public", + "table": "iconSets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobHistory_pkey", + "schema": "public", + "table": "jobHistory", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "jobLock_pkey", + "schema": "public", + "table": "jobLock", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobSchedule_pkey", + "schema": "public", + "table": "jobSchedule", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "jobs_pkey", + "schema": "public", + "table": "jobs", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "locales_pkey", + "schema": "public", + "table": "locales", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "navigation_pkey", + "schema": "public", + "table": "navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageEditSubmissions_pkey", + "schema": "public", + "table": "pageEditSubmissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageHistory_pkey", + "schema": "public", + "table": "pageHistory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageRenderQueue_pkey", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pageWatching_pkey", + "schema": "public", + "table": "pageWatching", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pages_pkey", + "schema": "public", + "table": "pages", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rateLimits_pkey", + "schema": "public", + "table": "rateLimits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pkey", + "schema": "public", + "table": "settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sites_pkey", + "schema": "public", + "table": "sites", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "storage_pkey", + "schema": "public", + "table": "storage", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tree_pkey", + "schema": "public", + "table": "tree", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userAvatars_pkey", + "schema": "public", + "table": "userAvatars", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userKeys_pkey", + "schema": "public", + "table": "userKeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "pageId" + ], + "nullsNotDistinct": false, + "name": "pageRenderQueue_pageId_key", + "schema": "public", + "table": "pageRenderQueue", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "hostname" + ], + "nullsNotDistinct": false, + "name": "sites_hostname_key", + "schema": "public", + "table": "sites", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/db/relations.ts b/backend/db/relations.ts index 3b4925a6c..0076cf75d 100644 --- a/backend/db/relations.ts +++ b/backend/db/relations.ts @@ -16,5 +16,14 @@ export const relations = defineRelations(schema, (r) => ({ from: r.userKeys.userId, to: r.users.id }) + }, + auditLog: { + // -> Optional, and stays that way: the row outlives the account it points at, and reading the + // log after a deletion falls back to the name and email kept on `meta.actor` + user: r.one.users({ + from: r.auditLog.userId, + to: r.users.id, + optional: true + }) } })) diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 741c3a758..6f6999001 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -114,6 +114,70 @@ export const assets = pgTable( (table) => [index('assets_siteId_idx').on(table.siteId)] ) +// AUDIT LOG --------------------------- +/** + * What somebody did, one row per action. + * + * Only actions a *person* took: every row is written from an API route handler, which is the one + * place the requester's identity and address are both in hand, and is by construction never reached + * by the scheduler or a storage sync. A job that creates a page therefore leaves no row here, which + * is the point — an audit log is a record of who did something, and "the wiki did it" is not an + * answer anybody audits. + * + * Reads are not recorded. Page views would outnumber everything else by orders of magnitude and + * bury the log, and failed logins are left out on purpose: a credential-stuffing run would otherwise + * fill the table on demand from the outside. A login that *succeeded* is recorded, since that is the + * event with consequences. + * + * Nothing here duplicates what another table already keeps. A page edit records the `pageHistory` + * version its change produced and nothing about the change itself — the before and after live there, + * and copying them would make this table enormous as well as wrong the moment the two disagreed. + */ +export const auditLog = pgTable( + 'auditLog', + { + id: uuid().primaryKey().defaultRandom(), + ts: timestamp().notNull().defaultNow(), + /** + * Which part of the wiki the action belongs to: `page`, `asset`, `auth`, `profile` or `admin`. + * A varchar rather than an enum, for the same reason `pageHistory.action` is one — naming + * another area later should not need a migration. `AUDIT_KINDS` in `models/auditLog.ts` is the + * list that decides. + */ + kind: varchar({ length: 16 }).notNull(), + /** + * What was done, camelCase — `createPage`, `editSite`, `login`. Deliberately a key rather than a + * sentence: it is what the interface looks a translation up by, and what a filter matches on. + * `AUDIT_ACTIONS` in `models/auditLog.ts` is the full list. + */ + action: varchar({ length: 64 }).notNull(), + /** Where the request came from. 45 characters is the longest an IPv6 address can be written. */ + clientIP: varchar({ length: 45 }).notNull().default(''), + /** + * The context of the action: which page, site or account it touched, and always an `actor` block + * carrying the email, display name and address the requester had AT THE TIME. That copy is the + * point of it — `userId` goes null when the account is deleted, and a log that then said only + * "somebody" would have lost exactly what it existed to record. + * + * Never anything secret. `sanitizeMeta` in `helpers/audit.ts` is the backstop, but the rule is + * that a route does not put a password, a token or a module's sensitive prop in here to begin + * with. + */ + meta: jsonb().notNull().default({}), + // -> Set null rather than cascade: deleting an account must not delete the record of what it + // did. The name and email on `meta.actor` are what the row is read by afterwards. + userId: uuid().references(() => users.id, { onDelete: 'set null' }) + }, + (table) => [ + // -> The unfiltered view: newest first, which is the only order this table is ever read in + index('auditLog_ts_idx').on(table.ts.desc()), + // -> One index per filter, each carrying `ts` so that narrowing by it still comes back ordered + index('auditLog_userId_idx').on(table.userId, table.ts.desc()), + index('auditLog_kind_idx').on(table.kind, table.ts.desc()), + index('auditLog_action_idx').on(table.action, table.ts.desc()) + ] +) + // AUTHENTICATION ---------------------- export const authentication = pgTable('authentication', { id: uuid().primaryKey().defaultRandom(), diff --git a/backend/helpers/audit.ts b/backend/helpers/audit.ts new file mode 100644 index 000000000..82f41a143 --- /dev/null +++ b/backend/helpers/audit.ts @@ -0,0 +1,135 @@ +import type { FastifyRequest } from 'fastify' +import type { AuditAction, AuditActor, AuditKind } from '../models/auditLog.ts' + +/** + * Key names whose value never belongs in the audit log. + * + * This is a backstop, not the rule. The rule is that a route hands `audit()` the identity of what it + * touched — an id, a path, a name — and not the payload it touched it with, which is why nearly every + * call site passes a handful of scalars. What this catches is the case where somebody later widens + * one of those calls to `req.body` and does not notice that the body carries a module's sensitive + * prop. + * + * Two lists rather than one, because the useful patterns come in two lengths. A long name can be + * matched anywhere in the key — `token` has to catch `continuationToken` — while a short one has to + * match the whole key or it starts eating innocent fields: `pass` as a substring redacts `passkeyId`, + * and `key` as a substring redacts every foreign key in here. + */ +const SENSITIVE_SUBSTRINGS = [ + 'password', + 'passwd', + 'secret', + 'token', + 'credential', + 'privatekey', + 'apikey', + 'accesskey', + 'authorization', + 'authheader', + 'securitycode' +] + +/** Short, generic names that are only sensitive when they are the whole key. */ +const SENSITIVE_EXACT = new Set([ + 'pass', + 'key', + 'salt', + 'hash', + 'otp', + 'session', + 'certs', + 'cookie' +]) + +/** What a redacted value is replaced with, so that the shape of the meta is still readable. */ +const REDACTED = '[redacted]' + +/** How deep `sanitizeMeta` walks before it stops descending. */ +const MAX_DEPTH = 6 + +function isSensitiveKey(key: string): boolean { + const lowered = key.toLowerCase() + return ( + SENSITIVE_EXACT.has(lowered) || + SENSITIVE_SUBSTRINGS.some((pattern) => lowered.includes(pattern)) + ) +} + +/** + * Replace the value of any sensitive-looking key, however deeply nested. + * + * Returns a new structure; the caller's object is never modified. Anything that is not a plain object + * or array is passed through as-is, so a `Date` or a `Buffer` reaching here stays what it was and is + * left for `JSON.stringify` to deal with on its way into the jsonb column. + */ +export function sanitizeMeta(value: unknown, depth = 0): any { + if (depth > MAX_DEPTH) { + return REDACTED + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeMeta(entry, depth + 1)) + } + if (value === null || typeof value !== 'object' || !isPlainObject(value)) { + return value + } + const cleaned: Record = {} + for (const [key, entry] of Object.entries(value)) { + cleaned[key] = isSensitiveKey(key) ? REDACTED : sanitizeMeta(entry, depth + 1) + } + return cleaned +} + +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** Who is making this request, as the audit log records them. */ +function actorFromRequest(req: FastifyRequest): AuditActor { + const user = req.session?.authenticated ? req.session.user : null + return { + id: user?.id ?? null, + name: user?.name ?? null, + email: user?.email ?? null, + ip: req.ip + } +} + +/** + * Record one action in the audit log, as the request that made it. + * + * Called from API route handlers, AFTER the action has succeeded — an audit log says what happened, + * and a row written before the work would claim something that a later `throw` never did. Being + * called from a route is also what keeps the scheduler out of the log: a page the git sync imports + * reaches the same model method by a path that never comes through here. + * + * This is how nearly everything is recorded. The exceptions are the auth events whose actor is not + * on the session yet — a login, a registration, a password reset from an emailed link — where the + * account is only identified deep inside `models/users.ts` by a credential or a token; those call + * `auditLog.record` directly, with the account they have just resolved. A logout is the mirror image: + * the session is destroyed before the entry is written, so it too passes its own actor. + * + * Awaiting it is optional and mostly pointless — `auditLog.record` swallows its own failures — but + * every call site does, so that a route's last statement is not a floating promise the response can + * outrun. + * + * A request authenticated by an API key rather than a session is recorded with no user: a key acts + * with the permissions of its groups, not as a person, and `meta.apiKeyId` is what identifies it. + * + * @param kind Which area of the wiki the action belongs to + * @param action What was done, as a key from `AUDIT_ACTIONS` + * @param meta The identity of what was touched. Never a payload, and never a secret. + */ +export async function audit( + req: FastifyRequest, + kind: AuditKind, + action: AuditAction, + meta: Record = {} +): Promise { + await WIKI.models.auditLog.record({ + kind, + action, + actor: actorFromRequest(req), + meta: req.apiKey ? { ...meta, apiKeyId: req.apiKey.id } : meta + }) +} diff --git a/backend/locales/en.json b/backend/locales/en.json index db65d8c6e..51afa0412 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -117,7 +117,151 @@ "admin.approval.tagsRequired": "At least one tag is required.", "admin.approval.title": "Approvals", "admin.approval.updateSuccess": "Rule updated successfully.", + "admin.audit.accountGone": "This account has since been deleted", + "admin.audit.actions.addIconSet": "Added an icon set", + "admin.audit.actions.approvePageEdit": "Approved an edit suggestion", + "admin.audit.actions.assignUserToGroup": "Added a user to a group", + "admin.audit.actions.cancelJob": "Cancelled a pending job", + "admin.audit.actions.changePassword": "Changed their own password", + "admin.audit.actions.checkForUpdate": "Checked for an update", + "admin.audit.actions.createApiKey": "Created an API key", + "admin.audit.actions.createApprovalRule": "Created an approval rule", + "admin.audit.actions.createAuthStrategy": "Added an authentication strategy", + "admin.audit.actions.createFolder": "Created a folder", + "admin.audit.actions.createGroup": "Created a group", + "admin.audit.actions.createHook": "Created a webhook", + "admin.audit.actions.createPage": "Created a page", + "admin.audit.actions.createSite": "Created a site", + "admin.audit.actions.createUser": "Created a user", + "admin.audit.actions.deleteApprovalRule": "Deleted an approval rule", + "admin.audit.actions.deleteAsset": "Deleted a file", + "admin.audit.actions.deleteAuthStrategy": "Deleted an authentication strategy", + "admin.audit.actions.deleteAvatar": "Removed their avatar", + "admin.audit.actions.deleteBlock": "Deleted a custom block", + "admin.audit.actions.deleteFolder": "Deleted a folder", + "admin.audit.actions.deleteGroup": "Deleted a group", + "admin.audit.actions.deleteHook": "Deleted a webhook", + "admin.audit.actions.deleteIconSet": "Deleted an icon set", + "admin.audit.actions.deletePage": "Deleted a page", + "admin.audit.actions.deletePasskey": "Removed a passkey", + "admin.audit.actions.deleteSite": "Deleted a site", + "admin.audit.actions.deleteSiteImage": "Removed a site image", + "admin.audit.actions.deleteUser": "Deleted a user", + "admin.audit.actions.disableTfa": "Turned 2FA off", + "admin.audit.actions.disconnectWebsockets": "Closed the websocket connections", + "admin.audit.actions.duplicateFolder": "Duplicated a folder", + "admin.audit.actions.enableTfa": "Turned 2FA on", + "admin.audit.actions.exportAuditLog": "Exported the audit log", + "admin.audit.actions.fetchLocales": "Fetched the locale list", + "admin.audit.actions.flushCache": "Flushed the caches", + "admin.audit.actions.flushIconCache": "Purged the icon cache", + "admin.audit.actions.forcedPasswordChange": "Changed a password when required to at sign-in", + "admin.audit.actions.installExtension": "Installed an extension", + "admin.audit.actions.installLocale": "Installed a locale", + "admin.audit.actions.invalidateSessions": "Ended every session", + "admin.audit.actions.login": "Signed in", + "admin.audit.actions.logout": "Signed out", + "admin.audit.actions.materializeIcons": "Stored icons for offline use", + "admin.audit.actions.moveFolder": "Moved a folder", + "admin.audit.actions.movePage": "Moved or renamed a page", + "admin.audit.actions.purgeApiKeys": "Purged the revoked API keys", + "admin.audit.actions.purgePageHistory": "Purged page history", + "admin.audit.actions.purgeSampleContent": "Purged the sample content", + "admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index", + "admin.audit.actions.refreshIconSets": "Refreshed the icon sets", + "admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates", + "admin.audit.actions.register": "Registered an account", + "admin.audit.actions.registerPasskey": "Registered a passkey", + "admin.audit.actions.rejectPageEdit": "Declined an edit suggestion", + "admin.audit.actions.renderPage": "Queued a page for rendering", + "admin.audit.actions.requestPasswordReset": "Requested a password reset", + "admin.audit.actions.resetPassword": "Reset a password from an emailed link", + "admin.audit.actions.resetUserPassword": "Set a user's password", + "admin.audit.actions.retryJob": "Retried a job", + "admin.audit.actions.revokeApiKey": "Revoked an API key", + "admin.audit.actions.runScheduledTask": "Ran a scheduled task", + "admin.audit.actions.runStorageAction": "Ran a storage action", + "admin.audit.actions.sendTestEmail": "Sent a test email", + "admin.audit.actions.sendWelcomeEmail": "Sent a welcome email", + "admin.audit.actions.setFolderColor": "Changed a folder colour", + "admin.audit.actions.submitPageEdit": "Suggested an edit", + "admin.audit.actions.togglePasswordLogin": "Turned password sign-in on or off", + "admin.audit.actions.unassignUserFromGroup": "Removed a user from a group", + "admin.audit.actions.unlockPage": "Unlocked a password-protected page", + "admin.audit.actions.unwatchPage": "Stopped watching a page", + "admin.audit.actions.updateApiState": "Turned the API on or off", + "admin.audit.actions.updateApprovalRule": "Updated an approval rule", + "admin.audit.actions.updateAsset": "Renamed or moved a file", + "admin.audit.actions.updateAuditConfig": "Changed the audit log retention", + "admin.audit.actions.updateAuthStrategy": "Updated an authentication strategy", + "admin.audit.actions.updateAvatar": "Changed their avatar", + "admin.audit.actions.updateBlock": "Changed the blocks of a site", + "admin.audit.actions.updateEditorSettings": "Changed their editor settings", + "admin.audit.actions.updateFlags": "Changed the system flags", + "admin.audit.actions.updateFolder": "Renamed a folder", + "admin.audit.actions.updateGroup": "Updated a group", + "admin.audit.actions.updateHook": "Updated a webhook", + "admin.audit.actions.updateIconSet": "Enabled or disabled an icon set", + "admin.audit.actions.updateLocale": "Changed a locale alias", + "admin.audit.actions.updateMailConfig": "Updated the mail configuration", + "admin.audit.actions.updateMetricsState": "Turned the metrics endpoint on or off", + "admin.audit.actions.updatePage": "Edited a page", + "admin.audit.actions.updatePageNavigation": "Changed the navigation of a page", + "admin.audit.actions.updateProfile": "Updated their profile", + "admin.audit.actions.updateSearchConfig": "Changed the search configuration", + "admin.audit.actions.updateSecurity": "Changed the security configuration", + "admin.audit.actions.updateSite": "Updated a site", + "admin.audit.actions.updateSiteImage": "Uploaded a site image", + "admin.audit.actions.updateStorage": "Updated the storage configuration", + "admin.audit.actions.updateUser": "Updated a user", + "admin.audit.actions.updateUserDefaults": "Changed the user defaults", + "admin.audit.actions.uploadAsset": "Uploaded a file", + "admin.audit.actions.verifyEmail": "Confirmed an email address", + "admin.audit.actions.watchPage": "Started watching a page", + "admin.audit.allActions": "Any action", + "admin.audit.allKinds": "Any area", + "admin.audit.anonymous": "Not signed in", + "admin.audit.anyUser": "Anyone", + "admin.audit.clearFilters": "Clear filters", + "admin.audit.export": "Export", + "admin.audit.exportFailed": "Could not export the audit log.", + "admin.audit.field.action": "Action", + "admin.audit.field.clientIP": "IP address", + "admin.audit.field.from": "From", + "admin.audit.field.kind": "Area", + "admin.audit.field.meta": "Context", + "admin.audit.field.timestamp": "Timestamp", + "admin.audit.field.to": "To", + "admin.audit.field.user": "User", + "admin.audit.kinds.admin": "Admin", + "admin.audit.kinds.asset": "File", + "admin.audit.kinds.auth": "Sign-in", + "admin.audit.kinds.page": "Page", + "admin.audit.kinds.profile": "Profile", + "admin.audit.loadActionsFailed": "Could not load the list of actions.", + "admin.audit.loadFailed": "Could not load the audit log.", + "admin.audit.none": "Nothing has been recorded yet.", + "admin.audit.noneMatching": "No events match these filters.", + "admin.audit.pickUserTitle": "Filter by user", + "admin.audit.retention": "Retention", + "admin.audit.retention180": "6 months", + "admin.audit.retention30": "30 days", + "admin.audit.retention365": "1 year", + "admin.audit.retention730": "2 years", + "admin.audit.retention90": "90 days", + "admin.audit.retentionFloorHint": "There is no shorter option than 30 days: a window short enough to outrun discovery would let somebody purge the record of what they had just done.", + "admin.audit.retentionForever": "Forever", + "admin.audit.retentionHint": "How long entries are kept. Anything older is deleted once a day. Saving changes nothing on its own — the next daily run is what applies it.", + "admin.audit.retentionLoadFailed": "Could not load the retention settings.", + "admin.audit.retentionPeriod": "Keep entries for", + "admin.audit.retentionSaveFailed": "Could not save the retention settings.", + "admin.audit.retentionSaveSuccess": "Retention updated successfully.", + "admin.audit.retentionStats": "The log currently holds {count} entries, the oldest from {oldest}.", + "admin.audit.subtitle": "A log of all events by users across the wiki for auditing purposes", "admin.audit.title": "Audit Log", + "admin.audit.userFilter": "User: {name}", + "admin.audit.userFilterMany": "User: {count} selected", + "admin.audit.viewDetails": "Details", "admin.auth.activeStrategies": "Active Strategies", "admin.auth.addPending": "{strategy} added. It is created when you press Apply.", "admin.auth.addStrategy": "Add Strategy", @@ -204,7 +348,9 @@ "admin.dashboard.lastLoginsNone": "No logins recorded yet.", "admin.dashboard.mostPopularPages": "Most Popular Pages", "admin.dashboard.pages": "Pages", - "admin.dashboard.recentPages": "Recent Pages", + "admin.dashboard.recentPages": "Recently Edited", + "admin.dashboard.recentPagesAuthorGone": "Deleted user", + "admin.dashboard.recentPagesNone": "No pages have been written yet.", "admin.dashboard.subtitle": "Wiki.js", "admin.dashboard.title": "Dashboard", "admin.dashboard.users": "Users", @@ -1050,6 +1196,7 @@ "admin.users.dateFormat": "Date Format", "admin.users.dateFormatHint": "How dates should be formatted when displayed to the user.", "admin.users.defaults": "Manage User Defaults", + "admin.users.defaultsButton": "User Defaults", "admin.users.defaultsSaveSuccess": "User defaults saved successfully.", "admin.users.delete": "Delete User", "admin.users.deleteConfirmForeignNotice": "Note that you cannot delete a user that already created content. You must instead either deactivate the user or delete all content that was created by that user.", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts new file mode 100644 index 000000000..342b903cd --- /dev/null +++ b/backend/models/auditLog.ts @@ -0,0 +1,415 @@ +import { and, count, desc, eq, gte, inArray, lte, lt, or, sql } from 'drizzle-orm' +import { auditLog as auditLogTable } from '../db/schema.ts' +import { sanitizeMeta } from '../helpers/audit.ts' + +/** + * The areas of the wiki an action can belong to. + * + * Closed in practice but stored as a varchar, so naming another one is a code change rather than a + * migration. The admin area's filter is built from this list, and `admin.audit.kinds.` is the + * translation of each. + */ +export const AUDIT_KINDS = ['page', 'asset', 'auth', 'profile', 'admin'] as const +export type AuditKind = (typeof AUDIT_KINDS)[number] + +/** + * Every action the wiki records, by area. + * + * This is the authority: the admin area's action filter is built from it, `admin.audit.actions.` + * is how each one is translated, and the documentation site lists it. A route recording an action + * that is not here is a bug — nothing enforces it at runtime (an audit write must never fail a + * request), but `npm run typecheck` does, since `AuditAction` is the union of these. + * + * Keys are camelCase and unique ACROSS areas, so that one translation key means one thing. Where two + * areas would otherwise collide the more specific one is qualified — `forcedPasswordChange` is the + * one a login demands, `changePassword` the one a user makes from their own profile. + */ +export const AUDIT_ACTIONS = { + page: [ + 'createPage', + 'updatePage', + 'movePage', + 'deletePage', + 'renderPage', + 'unlockPage', + 'watchPage', + 'unwatchPage', + 'submitPageEdit', + 'approvePageEdit', + 'rejectPageEdit', + 'createFolder', + 'updateFolder', + 'moveFolder', + 'duplicateFolder', + 'setFolderColor', + 'deleteFolder' + ], + asset: ['uploadAsset', 'updateAsset', 'deleteAsset'], + auth: [ + 'login', + 'logout', + 'register', + 'verifyEmail', + 'requestPasswordReset', + 'resetPassword', + 'forcedPasswordChange' + ], + profile: [ + 'updateProfile', + 'updateAvatar', + 'deleteAvatar', + 'updateEditorSettings', + 'changePassword', + 'togglePasswordLogin', + 'enableTfa', + 'disableTfa', + 'registerPasskey', + 'deletePasskey' + ], + admin: [ + 'createApiKey', + 'revokeApiKey', + 'createApprovalRule', + 'updateApprovalRule', + 'deleteApprovalRule', + 'createAuthStrategy', + 'updateAuthStrategy', + 'deleteAuthStrategy', + 'updateBlock', + 'deleteBlock', + 'createGroup', + 'updateGroup', + 'deleteGroup', + 'assignUserToGroup', + 'unassignUserFromGroup', + 'createHook', + 'updateHook', + 'deleteHook', + 'addIconSet', + 'updateIconSet', + 'deleteIconSet', + 'refreshIconSets', + 'materializeIcons', + 'flushIconCache', + 'fetchLocales', + 'installLocale', + 'updateLocale', + 'updateMailConfig', + 'sendTestEmail', + 'updatePageNavigation', + 'runScheduledTask', + 'cancelJob', + 'retryJob', + 'createSite', + 'updateSite', + 'deleteSite', + 'updateSiteImage', + 'deleteSiteImage', + 'updateStorage', + 'runStorageAction', + 'updateFlags', + 'updateSecurity', + 'updateSearchConfig', + 'rebuildSearchIndex', + 'installExtension', + 'updateApiState', + 'updateMetricsState', + 'disconnectWebsockets', + 'flushCache', + 'regenerateCertificates', + 'purgeApiKeys', + 'invalidateSessions', + 'purgePageHistory', + 'purgeSampleContent', + 'checkForUpdate', + 'createUser', + 'updateUser', + 'resetUserPassword', + 'sendWelcomeEmail', + 'deleteUser', + 'updateUserDefaults', + 'updateAuditConfig', + 'exportAuditLog' + ] +} as const satisfies Record + +export type AuditAction = (typeof AUDIT_ACTIONS)[AuditKind][number] + +/** Every action key, flat and sorted — what the admin area's filter offers. */ +export const AUDIT_ACTION_KEYS: string[] = Object.values(AUDIT_ACTIONS).flat().toSorted() + +/** + * Who did it, as the row keeps it. + * + * The name and email are a copy taken at the time rather than a reference, because `userId` is set to + * null when the account is deleted and the row has to stay readable afterwards. Null for all three on + * an action nobody was signed in for — a registration, a password reset from an emailed link. + */ +export interface AuditActor { + id: string | null + name: string | null + email: string | null + ip: string +} + +/** One row as the API answers with it. */ +export interface AuditEntry { + id: string + ts: Date + kind: string + action: string + clientIP: string + meta: Record + userId: string | null +} + +/** One page of the log, with the total matching the filters. */ +export interface AuditLogPage { + total: number + entries: AuditEntry[] +} + +/** What `list()` narrows by. Every field is optional; together they are AND-ed. */ +export interface AuditLogFilters { + /** + * Accounts to narrow to, OR-ed against each other — "what did any of these people do". + * + * A list rather than one id because the question an audit log gets asked is usually about a group + * of people, and because the picker behind it selects a set. Empty means everybody. + */ + userIds?: string[] + kind?: string + action?: string + /** Inclusive lower bound on `ts`, as an ISO instant. */ + from?: string + /** Inclusive upper bound on `ts`, as an ISO instant. */ + to?: string +} + +/** How long rows are kept, in days. Zero means forever. */ +export const DEFAULT_RETENTION_DAYS = 90 + +/** + * The shortest retention that may be configured, in days. Zero — keep for ever — is the only value + * below it. + * + * A floor rather than a preference, because retention is the one setting whose whole effect is to + * destroy this table, and the person who can change it is the person the table exists to record. A + * `manage:system` holder who could set it to a day would have a way to act, wait, and have the + * record of what they did purged before anybody had reason to look — so the floor is what makes the + * log outlive the window in which its subject would want it gone. + * + * Thirty days is not a claim that a month is enough; it is the point below which the setting stops + * being a retention policy and starts being a way to cover tracks. Longer is a choice, shorter is + * not offered. + */ +export const MIN_RETENTION_DAYS = 30 + +/** + * Audit log model + * + * Rows are written by `helpers/audit.ts` from API route handlers and read by `api/auditLog.ts`. + * Nothing else writes here — see the table's comment in `db/schema.ts` for why that boundary is the + * feature rather than an implementation detail. + */ +class AuditLog { + /** + * Record one action. + * + * Never throws. An audit row is a record of a request that has already succeeded, and losing one is + * not a reason to fail the request that was the point — the same trade `pageHistory.record` makes. + * A failure is logged at error level rather than warn, because unlike a missing history entry a + * missing audit entry is a gap in a record somebody is relying on. + */ + async record({ + kind, + action, + actor, + meta = {} + }: { + kind: AuditKind + action: AuditAction + actor: AuditActor + meta?: Record + }): Promise { + try { + await WIKI.db.insert(auditLogTable).values({ + kind, + action, + clientIP: actor.ip, + userId: actor.id, + /* + Sanitized here rather than at each caller, so that every path in — the route helper and the + handful of auth events that record themselves — goes through the same backstop. + + `actor` is applied AFTER it, and last, so a route cannot overwrite it by accident with a + meta key of its own and the copy of the account cannot be redacted by a key name. + */ + meta: { + ...sanitizeMeta(meta), + actor: { name: actor.name, email: actor.email, ip: actor.ip } + } + }) + } catch (err: any) { + WIKI.logger.error(`Failed to record audit entry ${kind}/${action}: ${err.message}`) + } + } + + /** + * The filters as one WHERE, or nothing at all when none were given. + * + * Shared by `list` and `stream` rather than written twice: an export that did not match the screen + * it was taken from would be the worst kind of wrong, since nothing about the file would say so. + */ + whereFor(filters: AuditLogFilters) { + const conditions = [] + if (filters.userIds && filters.userIds.length > 0) { + // -> `inArray` even for one, so the single and the many cases are the same query + conditions.push(inArray(auditLogTable.userId, filters.userIds)) + } + if (filters.kind) { + conditions.push(eq(auditLogTable.kind, filters.kind)) + } + if (filters.action) { + conditions.push(eq(auditLogTable.action, filters.action)) + } + if (filters.from) { + conditions.push(gte(auditLogTable.ts, new Date(filters.from))) + } + if (filters.to) { + conditions.push(lte(auditLogTable.ts, new Date(filters.to))) + } + return conditions.length > 0 ? and(...conditions) : undefined + } + + /** + * Every entry matching the filters, newest first, a batch at a time. + * + * A generator rather than an array because this backs the export, and the whole point of exporting + * an audit log is that it is long: an instance with a year of history can hold millions of rows, + * and materialising those to answer one request would take the wiki down with it. The caller + * streams what this yields straight to the response. + * + * Paged by keyset — "older than the last row I sent" — rather than by OFFSET, which re-walks and + * discards everything before it on each batch and turns one export into a quadratic scan. `id` + * breaks ties, since two entries can share a timestamp and an unstable order would drop or repeat + * rows across batch boundaries. + */ + async *stream(filters: AuditLogFilters, batchSize = 1000): AsyncGenerator { + const where = this.whereFor(filters) + let cursor: { ts: Date; id: string } | null = null + + for (;;) { + const keyset = cursor + ? or( + lt(auditLogTable.ts, cursor.ts), + and(eq(auditLogTable.ts, cursor.ts), lt(auditLogTable.id, cursor.id)) + ) + : undefined + const rows = (await WIKI.db + .select() + .from(auditLogTable) + .where(keyset ? and(where, keyset) : where) + .orderBy(desc(auditLogTable.ts), desc(auditLogTable.id)) + .limit(batchSize)) as AuditEntry[] + + for (const row of rows) { + yield row + } + // -> A short batch is the last one; a full one may or may not be, so it costs one empty query + if (rows.length < batchSize) { + return + } + const last = rows[rows.length - 1]! + cursor = { ts: last.ts, id: last.id } + } + } + + /** + * One page of the log, newest first. + * + * No join to `users`: every row already carries the name and email the account had at the time, and + * that is what should be shown. Reading the current name instead would quietly rewrite history + * every time somebody was renamed, and would show nothing at all once they were deleted. + * + * @param filters What to narrow by + * @param page 1-based page number + * @param limit Rows per page + */ + async list(filters: AuditLogFilters, page: number, limit: number): Promise { + const where = this.whereFor(filters) + + const [totals, entries] = await Promise.all([ + WIKI.db.select({ total: count() }).from(auditLogTable).where(where), + WIKI.db + .select() + .from(auditLogTable) + .where(where) + .orderBy(desc(auditLogTable.ts)) + .limit(limit) + .offset((page - 1) * limit) + ]) + + return { + total: totals[0]?.total ?? 0, + entries: entries as AuditEntry[] + } + } + + /** + * How long rows are kept, in days. Zero means forever. + * + * The floor is applied on the way OUT as well as on the way in. `api/auditLog.ts` refuses to store + * anything under it, so a value below it can only have arrived another way — a hand-edited + * `config.yml`, a direct write to the settings table — and honouring it there would leave the one + * route round the rule that the rule exists to close. Anything between 1 and the floor is read as + * the floor rather than as itself; a value that is not a whole number of days at all falls back to + * the default. + */ + retentionDays(): number { + const configured = WIKI.config.audit?.retentionDays + if (!Number.isInteger(configured) || configured < 0) { + return DEFAULT_RETENTION_DAYS + } + if (configured === 0) { + return 0 + } + return Math.max(configured, MIN_RETENTION_DAYS) + } + + /** + * Delete rows older than the configured retention. + * + * @returns How many rows went, or 0 when retention is off + */ + async purge(): Promise { + const days = this.retentionDays() + if (days < 1) { + return 0 + } + // -> `Instant` takes exact time units only, so days are expressed as hours + const cutoff = Temporal.Now.instant().subtract({ hours: days * 24 }) + const deleted = await WIKI.db + .delete(auditLogTable) + .where(lt(auditLogTable.ts, new Date(cutoff.epochMilliseconds))) + .returning({ id: auditLogTable.id }) + return deleted.length + } + + /** How many rows the log holds, for the retention card in the admin area. */ + async total(): Promise { + const rows = await WIKI.db.select({ total: count() }).from(auditLogTable) + return rows[0]?.total ?? 0 + } + + /** The instant of the oldest row, or null when the log is empty. */ + async oldestEntry(): Promise { + const rows = await WIKI.db + .select({ ts: sql`min(${auditLogTable.ts})` }) + .from(auditLogTable) + const oldest = rows[0]?.ts + return oldest ? new Date(oldest).toISOString() : null + } +} + +export const auditLog = new AuditLog() diff --git a/backend/models/index.ts b/backend/models/index.ts index 5ff16fb3e..871613e15 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -1,6 +1,7 @@ import { apiKeys } from './apiKeys.ts' import { approvals } from './approvals.ts' import { assets } from './assets.ts' +import { auditLog } from './auditLog.ts' import { authentication } from './authentication.ts' import { blocks } from './blocks.ts' import { extensions } from './extensions.ts' @@ -32,6 +33,7 @@ export default { apiKeys, approvals, assets, + auditLog, authentication, blocks, extensions, diff --git a/backend/models/jobs.ts b/backend/models/jobs.ts index 2d3d3453d..2ca17480b 100644 --- a/backend/models/jobs.ts +++ b/backend/models/jobs.ts @@ -37,6 +37,9 @@ export const SYSTEM_SCHEDULE: SystemScheduleEntry[] = [ { task: 'checkVersion', cron: '0 0 * * *' }, { task: 'cleanJobHistory', cron: '5 0 * * *' }, // { task: 'refreshAutocomplete', cron: '0 */6 * * *' }, + // -> Daily, off the hour: the retention is expressed in days, so nothing is gained by looking + // more often than once a day + { task: 'purgeAuditLog', cron: '20 0 * * *' }, { task: 'purgeRateLimits', cron: '10 * * * *' }, { task: 'updateLocales', cron: '0 0 * * *' }, // -> Every minute, and the task decides which sites are actually due: the interval is a per-site diff --git a/backend/models/pages.ts b/backend/models/pages.ts index f3cd45d6f..15453098e 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, ne, notInArray, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm' import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts' import { CustomError, @@ -209,11 +209,49 @@ export interface PageInput { } /** Who is saving, and what they are allowed to put in a page. */ +/** One row of the admin dashboard's recently-edited panel. */ +export interface RecentPage { + id: string + siteId: string + locale: string + path: string + title: string + updatedAt: Date + /** Whether this is the page appearing for the first time rather than a later edit of it. */ + isNew: boolean + /** + * Where the page is, as a path on its own site — locale prefix included only where that site's + * settings put one there. + */ + url: string + /** + * The host that site answers on, so a caller looking at one site can still link to a page on + * another. Null for the catch-all site, which has no host of its own to name. + */ + hostname: string | null + /** Who wrote the version that stands. Null once that account is deleted. */ + authorName: string | null +} + export interface PageActor { id: string permissions: string[] } +/** + * A page as it stands after a change, together with the history version that change produced. + * + * The version is returned rather than kept to itself because the audit log references it: an audit + * entry says a page was edited and points at the `pageHistory` row holding what the edit actually + * was, instead of copying the before and after into a second table. Null when history could not be + * recorded — `pageHistory.record` swallows its own failures, since losing a version is not a reason + * to fail the edit. + */ +export interface PageChange { + page: Page + versionId: string | null +} + function hasPermission(actor: PageActor, permission: string): boolean { return actor.permissions.includes('manage:system') || actor.permissions.includes(permission) } @@ -709,6 +747,74 @@ class Pages { * @param withPassword Whether to include the password value. For whoever may edit the page — not for * a reader who just entered it, who needs it no more after that. */ + /** + * The pages touched most recently, newest first — what the admin dashboard's panel is built from. + * + * Across every site, like the rest of that dashboard: it answers "what has been happening here", + * and an instance-wide question should not be filtered to whichever site the admin area happens to + * be pointed at. + * + * Ordered by `updatedAt`, which covers both halves of "edited or created": a page's creation sets + * it and every save moves it, so one column is the whole of the answer. + */ + async getRecentlyEdited({ limit = 10 }: { limit?: number } = {}): Promise { + const rows = await WIKI.db + .select({ + id: pagesTable.id, + siteId: pagesTable.siteId, + locale: pagesTable.locale, + path: pagesTable.path, + title: pagesTable.title, + createdAt: pagesTable.createdAt, + updatedAt: pagesTable.updatedAt, + authorName: usersTable.name + }) + .from(pagesTable) + // -> Left, so a page whose author has since been deleted is still listed, without a name + .leftJoin(usersTable, eq(usersTable.id, pagesTable.authorId)) + .orderBy(desc(pagesTable.updatedAt)) + .limit(limit) + + return rows.map((row) => ({ + id: row.id, + siteId: row.siteId, + locale: row.locale, + path: row.path, + title: row.title, + updatedAt: row.updatedAt, + /* + Creating a page sets both stamps to the same moment and every later save moves only + `updatedAt`, so equality is what tells the two apart. Compared in milliseconds because these + are `Date`s, which have no `valueOf` ordering worth relying on for equality. + */ + isNew: row.createdAt.getTime() === row.updatedAt.getTime(), + url: this.urlFor(row.siteId, row.locale, row.path), + // -> `*` is the catch-all rather than a host; a link to it is whatever host you are already on + hostname: + WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*' + ? WIKI.sites[row.siteId].hostname + : null, + authorName: row.authorName + })) + } + + /** + * Where a page lives, as a path. + * + * Built here rather than by the caller because the rule is per SITE — a site brackets its URLs by + * locale or it does not — and a client looking at one site has no way to know how another one is + * configured. Mirrors `localeUrlPrefix` in the frontend's site store and the redirect in + * `index.ts`, which is what corrects a URL that arrives without the prefix. + */ + urlFor(siteId: string, locale: string, path: string): string { + const locales = WIKI.sites[siteId]?.config?.locales + const prefix = + locales?.forcePrefix || locale !== locales?.primary + ? `/${WIKI.models.locales.shortCodeFor(locale)}` + : '' + return `${prefix}/${path}` + } + async getPage({ siteId, id, @@ -846,7 +952,7 @@ class Pages { * * @param actor Who is saving it. Their permissions decide what survives sanitizing. */ - async createPage(siteId: string, input: PageInput, actor: PageActor): Promise { + async createPage(siteId: string, input: PageInput, actor: PageActor): Promise { if (!WIKI.sites[siteId]) { throw new CustomError('pageInvalidSite', 'This site does not exist.', 404) } @@ -970,7 +1076,7 @@ class Pages { throw err } - await WIKI.models.pageHistory.record({ + const versionId = await WIKI.models.pageHistory.record({ siteId, pageId: page.id, action: 'created', @@ -991,7 +1097,7 @@ class Pages { metadata: { title: page.title, description: page.description, editor } }) - return (await this.getPage({ siteId, id: page.id })) as Page + return { page: (await this.getPage({ siteId, id: page.id })) as Page, versionId } } /** @@ -1002,7 +1108,7 @@ class Pages { id: string, patch: Partial, actor: PageActor - ): Promise { + ): Promise { const results = await WIKI.db .select() .from(pagesTable) @@ -1121,7 +1227,7 @@ class Pages { const updated = (await this.getPage({ siteId, id })) as Page - await WIKI.models.pageHistory.record({ + const versionId = await WIKI.models.pageHistory.record({ siteId, pageId: id, action: 'updated', @@ -1162,7 +1268,7 @@ class Pages { metadata: { title: updated.title, description: updated.description } }) - return updated + return { page: updated, versionId } } /** @@ -1173,7 +1279,7 @@ class Pages { id: string, { path, locale, title }: { path: string; locale?: string; title?: string }, actor: PageActor - ): Promise { + ): Promise { // -> With the source, which the move itself does not need: it is what the copy kept by a storage // target is rewritten from once the page has landed at its new path const page = await this.getPage({ siteId, id, withContent: true }) @@ -1190,7 +1296,8 @@ class Pages { const newLocale = locale || page.locale const isRelocated = newPath !== page.path || newLocale !== page.locale if (!isRelocated && (title === undefined || title === page.title)) { - return page + // -> Nothing moved and nothing was renamed, so there is no version recording a change either + return { page, versionId: null } } if (isRelocated) { @@ -1259,7 +1366,7 @@ class Pages { // -> Recorded as its own kind of change rather than an edit: a move is what breaks inbound links, // and a history list has to be able to say so - await WIKI.models.pageHistory.record({ + const versionId = await WIKI.models.pageHistory.record({ siteId, pageId: id, action: 'moved', @@ -1293,21 +1400,22 @@ class Pages { siteId, authorId: actor.id }) - return moved + return { page: moved, versionId } } /** * Delete a page and its tree entry. * - * @returns Whether a page was deleted + * @returns The page as it last stood and the version recording its deletion, or null when there + * was no such page */ - async deletePage(siteId: string, id: string, actor: PageActor): Promise { + async deletePage(siteId: string, id: string, actor: PageActor): Promise { const page = await this.getPage({ siteId, id }) if (!page) { - return false + return null } // -> Before the row goes, and this version is what recovering the page would be built from - await WIKI.models.pageHistory.record({ + const versionId = await WIKI.models.pageHistory.record({ siteId, pageId: id, action: 'deleted', @@ -1335,7 +1443,7 @@ class Pages { siteId, authorId: actor.id }) - return true + return { page, versionId } } /** @@ -1681,7 +1789,7 @@ class Pages { // -> The one difference between the two directions, and deliberately the only one: a page that is // there is *saved*, through the same method an editor saves through, so it gets a history entry // and a re-render and a mirrored copy without any of that being reimplemented here - const page = existing[0] + const change = existing[0] ? await this.updatePage( siteId, existing[0].id, @@ -1713,9 +1821,10 @@ class Pages { actor ) // -> Only if the page went away between the two statements above - if (!page) { + if (!change) { return null } + const page = change.page // -> A restore should not report every page as written today. Applied after the fact because the // two dates are not something an API client may set, only something a file can carry back. diff --git a/backend/models/settings.ts b/backend/models/settings.ts index 2eb33be36..0afaec44c 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -51,6 +51,12 @@ class Settings { isEnabled: false } }, + { + key: 'audit', + value: { + retentionDays: 90 + } + }, { key: 'auth', value: { diff --git a/backend/models/users.ts b/backend/models/users.ts index 6dd41873a..a3421ccfb 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -1515,6 +1515,24 @@ class Users { } }) + /* + The one audit entry not written from a route handler, and for the same reason the stamp above + is not: every way of signing in — local, a provider, a passkey, and the 2FA and forced password + change continuations — converges here, and recording it at each of those routes instead would + be six copies of one event that would drift apart. + + A login that FAILED is deliberately not recorded anywhere. This endpoint is open to whoever can + reach the wiki, so a credential-stuffing run would otherwise be able to fill the table from the + outside; `models/rateLimits.ts` is what answers that, and the wiki's own log is where a refused + attempt shows up. + */ + await WIKI.models.auditLog.record({ + kind: 'auth', + action: 'login', + actor: { id: user.id, name: user.name, email: user.email, ip: context.ip ?? '' }, + meta: { strategyId, siteId: context.siteId ?? null } + }) + return { authenticated: true, nextAction: 'redirect', @@ -1741,6 +1759,15 @@ class Users { user.auth[strategyId].mustChangePwd = false await WIKI.db.update(usersTable).set({ auth: user.auth }).where(eq(usersTable.id, user.id)) + // -> Recorded separately from the `login` the call below writes: two things happened, and a + // password that a login insisted be changed is the one worth being able to find on its own + await WIKI.models.auditLog.record({ + kind: 'auth', + action: 'forcedPasswordChange', + actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' }, + meta: { siteId, strategyId } + }) + return this.afterLoginChecks( user, strategyId, @@ -1853,6 +1880,19 @@ class Users { strategyId: strategy.id }) + /* + Called from both of the paths below rather than here, because the account is undone again if + the verification email cannot be sent — and an audit entry for an account that no longer + exists, pointing at a deleted row, is worse than no entry. + */ + const recordRegistration = () => + WIKI.models.auditLog.record({ + kind: 'auth', + action: 'register', + actor: { id: userId, name: name.trim(), email: address, ip: ip ?? '' }, + meta: { siteId, strategyId: strategy.id, mustVerify } + }) + if (mustVerify) { const token = await this.generateToken({ kind: 'verifyEmail', @@ -1887,6 +1927,7 @@ class Users { WIKI.models.flags.authDebug( `Registered user ${userId} <${address}> on site ${siteId} from ${ip}, pending email verification` ) + await recordRegistration() return { nextAction: 'verifyEmail', redirect: '/' @@ -1912,6 +1953,7 @@ class Users { WIKI.models.flags.authDebug( `Registered user ${userId} <${address}> on site ${siteId} from ${ip}, signing them in` ) + await recordRegistration() return this.afterLoginChecks(user, strategy.id, { ip, siteId }, {}, req) } @@ -1963,7 +2005,7 @@ class Users { * * @throws `ERR_INVALID_VALIDATION_TOKEN`, `ERR_EXPIRED_VALIDATION_TOKEN`, `ERR_INVALID_USER` */ - async verifyUserEmail(token: string): Promise { + async verifyUserEmail(token: string, ip?: string): Promise { const { user } = await this.validateToken({ kind: 'verifyEmail', token }) if (!user) { throw new Error('ERR_INVALID_USER') @@ -1975,6 +2017,12 @@ class Users { .where(eq(usersTable.id, user.id)) } WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> confirmed their email address`) + await WIKI.models.auditLog.record({ + kind: 'auth', + action: 'verifyEmail', + actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' }, + meta: {} + }) } /** @@ -2045,6 +2093,17 @@ class Users { WIKI.models.flags.authDebug( `Password reset requested from ${ip} for user ${user.id} <${user.email}>, link sent` ) + /* + Only when a link was actually sent. The early return above covers an address nobody holds, and + recording those would turn an endpoint open to the internet into a way of writing arbitrary + addresses into the audit log. + */ + await WIKI.models.auditLog.record({ + kind: 'auth', + action: 'requestPasswordReset', + actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' }, + meta: { siteId, strategyId: strategy.id } + }) } /** @@ -2065,10 +2124,12 @@ class Users { */ async resetPassword({ token, - newPassword + newPassword, + ip }: { token: string newPassword: string + ip?: string }): Promise { if (!newPassword || newPassword.length < 8) { throw new Error('ERR_PASSWORD_TOO_SHORT') @@ -2092,6 +2153,12 @@ class Users { .set({ auth, isVerified: true, updatedAt: sql`now()` }) .where(eq(usersTable.id, user.id)) WIKI.models.flags.authDebug(`User ${user.id} <${user.email}> reset their password`) + await WIKI.models.auditLog.record({ + kind: 'auth', + action: 'resetPassword', + actor: { id: user.id, name: user.name, email: user.email, ip: ip ?? '' }, + meta: { strategyId } + }) } updateSession(user: any, req: any): void { diff --git a/backend/tasks/simple/purge-audit-log.ts b/backend/tasks/simple/purge-audit-log.ts new file mode 100644 index 000000000..c1ad461c9 --- /dev/null +++ b/backend/tasks/simple/purge-audit-log.ts @@ -0,0 +1,13 @@ +export async function task(): Promise { + WIKI.logger.info('Purging expired audit log entries...') + + try { + const purged = await WIKI.models.auditLog.purge() + + WIKI.logger.info(`Purged ${purged} expired audit log entries: [ COMPLETED ]`) + } catch (err: any) { + WIKI.logger.error('Purging expired audit log entries: [ FAILED ]') + WIKI.logger.error(err.message) + throw err + } +} diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 7e9030cdf..a164f1c54 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 274 icons. + 277 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -53,11 +53,13 @@ export const BUNDLED_ICONS = { "la:ellipsis-h": {"body":"","width":32,"height":32}, "la:ellipsis-v": {"body":"","width":32,"height":32}, "la:envelope": {"body":"","width":32,"height":32}, + "la:eraser": {"body":"","width":32,"height":32}, "la:exclamation-triangle": {"body":"","width":32,"height":32}, "la:external-link-alt": {"body":"","width":32,"height":32}, "la:external-link-square-alt": {"body":"","width":32,"height":32}, "la:eye": {"body":"","width":32,"height":32}, "la:file-alt": {"body":"","width":32,"height":32}, + "la:file-download": {"body":"","width":32,"height":32}, "la:file-export": {"body":"","width":32,"height":32}, "la:file-image": {"body":"","width":32,"height":32}, "la:file-import": {"body":"","width":32,"height":32}, @@ -74,6 +76,7 @@ export const BUNDLED_ICONS = { "la:heart": {"body":"","width":32,"height":32}, "la:history": {"body":"","width":32,"height":32}, "la:home": {"body":"","width":32,"height":32}, + "la:hourglass-half": {"body":"","width":32,"height":32}, "la:icons": {"body":"","width":32,"height":32}, "la:id-card": {"body":"","width":32,"height":32}, "la:image": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/AuditEntryDialog.vue b/frontend/src/components/AuditEntryDialog.vue new file mode 100644 index 000000000..5e967e11b --- /dev/null +++ b/frontend/src/components/AuditEntryDialog.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/src/components/AuditRetentionMenu.vue b/frontend/src/components/AuditRetentionMenu.vue new file mode 100644 index 000000000..ad06bca6b --- /dev/null +++ b/frontend/src/components/AuditRetentionMenu.vue @@ -0,0 +1,161 @@ + + + diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index 1e2ff0a73..dfb510dfe 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -748,6 +748,13 @@ const permissions = [ restrictedForSystem: true, disabled: false }, + { + permission: 'read:audit', + hint: 'Can read the audit log, i.e. the record of what everybody on this wiki has done.', + warning: false, + restrictedForSystem: true, + disabled: false + }, { permission: 'manage:navigation', hint: 'Can manage site navigation', diff --git a/frontend/src/components/shared/WInput.vue b/frontend/src/components/shared/WInput.vue index 2dc1e907e..2f4d21235 100644 --- a/frontend/src/components/shared/WInput.vue +++ b/frontend/src/components/shared/WInput.vue @@ -379,11 +379,23 @@ const hasValue = computed(() => String(props.modelValue ?? '').length > 0) way -- it asks the caller to pin the label up whenever there is a start adornment. A placeholder likewise: it renders in the resting position the moment the field is empty. + + And so do the date and time types, which are never visually empty: the browser draws its own format + hint (`mm/dd/yyyy, --:--`) in the resting position whatever the value is, and there is no attribute + that suppresses it. An empty one would otherwise be a label printed over a format hint. */ const hasLeadingAdornment = computed(() => Boolean(slots.prepend || props.prefix)) +/** Types whose control paints its own format hint, so the resting position is never free. */ +const SELF_LABELLING_TYPES = ['date', 'datetime-local', 'month', 'time', 'week'] + const isFloating = computed( - () => hasFocus.value || hasValue.value || Boolean(props.placeholder) || hasLeadingAdornment.value + () => + hasFocus.value || + hasValue.value || + Boolean(props.placeholder) || + hasLeadingAdornment.value || + SELF_LABELLING_TYPES.includes(props.type) ) const floatColorClass = computed(() => { diff --git a/frontend/src/css/tailwind.css b/frontend/src/css/tailwind.css index 7b26b0a0e..44acdeff2 100644 --- a/frontend/src/css/tailwind.css +++ b/frontend/src/css/tailwind.css @@ -446,6 +446,21 @@ --w-input-ring-hover: var(--color-white); } + /* + The picker button on a date or time field, in dark mode. + + The browser draws it itself, as a black glyph, and gives it no colour to set -- so on a dark + field it is black on near-black and effectively invisible. A filter is the only lever there is. + `invert(0.75)` rather than a full inversion: black inverted all the way is pure white, brighter + than the field's own text, where 0.75 lands on the light grey the rest of the control uses. + + Chromium and Safari only; Firefox draws no indicator for these types, so there is nothing there + for the rule to match. + */ + body.body--dark .w-input-control input::-webkit-calendar-picker-indicator { + filter: invert(0.75); + } + /* The Material notched outline, for an outlined field that carries a label: at rest the label stands in the middle of the field, and on focus or once there is a value it rises into the top border. diff --git a/frontend/src/helpers/sampleContent.js b/frontend/src/helpers/sampleContent.js index a705ad587..77c1a55a1 100644 --- a/frontend/src/helpers/sampleContent.js +++ b/frontend/src/helpers/sampleContent.js @@ -775,8 +775,9 @@ There are two kinds, granted separately and checked in different places. ## Global permissions -Held site-wide, bound to no path. \`access:admin\`, \`manage:users\`, \`manage:groups\`, -\`manage:navigation\`, \`manage:theme\`, \`manage:sites\`, \`manage:system\`. That list is the whole of it. +Held site-wide, bound to no path. \`access:admin\`, \`read:users\`, \`manage:users\`, \`read:groups\`, +\`manage:groups\`, \`read:audit\`, \`manage:navigation\`, \`manage:theme\`, \`manage:sites\`, +\`manage:system\`. That list is the whole of it. \`manage:system\` bypasses every check everywhere. diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 84db143bd..860aceb23 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -285,11 +285,21 @@ - @@ -367,7 +424,8 @@ const { t } = useI18n() const state = reactive({ loading: 0, - lastLogins: [] + lastLogins: [], + recentPages: [] }) // COMPUTED @@ -431,10 +489,43 @@ async function loadLastLogins() { } } +// -> Same bargain as the panel beside it: its own state, its own failure +async function loadRecentPages() { + try { + state.recentPages = await API_CLIENT.get('pages/recent').json() + } catch (err) { + notify({ + type: 'negative', + message: 'Failed to load the recently edited pages.', + caption: err.message + }) + } +} + +/** + * Whether a page belongs to the site this admin area is being browsed on. + * + * Only then can the router take the reader there — every other site is a different host, and a + * route this app pushes would resolve against the wrong one. + */ +function isCurrentSite(pg) { + return !pg.hostname || pg.siteId === siteStore.id +} + +/** A page on another site, as an absolute URL on that site's own host. */ +function externalPageUrl(pg) { + return `${window.location.protocol}//${pg.hostname}${pg.url}` +} + +/** The two panels this page fills itself, in parallel — neither waits on the other. */ +function loadPanels() { + return Promise.all([loadLastLogins(), loadRecentPages()]) +} + async function load() { state.loading++ try { - await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites(), loadLastLogins()]) + await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites(), loadPanels()]) } catch (err) { notify({ type: 'negative', @@ -445,8 +536,8 @@ async function load() { state.loading-- } -// -> The store is already filled by the layout; this is the one thing on the page that has to ask -onMounted(loadLastLogins) +// -> The store is already filled by the layout; these two panels are what this page has to ask for +onMounted(loadPanels) function newSite() { dialog({ diff --git a/frontend/src/pages/AdminIcons.vue b/frontend/src/pages/AdminIcons.vue index b7e14a6b3..338050562 100644 --- a/frontend/src/pages/AdminIcons.vue +++ b/frontend/src/pages/AdminIcons.vue @@ -17,7 +17,7 @@ class="acrylic-btn mr-2" flat icon="la:broom" - color="purple" + :color="dark.isActive ? `indigo-4` : `indigo`" :label="t(`admin.icons.purgeCache`)" @click="purgeCache"> {{ t('admin.icons.purgeCacheHint') }} diff --git a/frontend/src/pages/AdminLocale.vue b/frontend/src/pages/AdminLocale.vue index ee6e268c1..4181e2699 100644 --- a/frontend/src/pages/AdminLocale.vue +++ b/frontend/src/pages/AdminLocale.vue @@ -5,7 +5,9 @@
-
{{ t('admin.locale.title') }}
+
+ {{ t('admin.locale.title') }} +
{{ t('admin.locale.subtitle') }}
@@ -15,7 +17,7 @@ class="mr-2 acrylic-btn" flat icon="la:cloud-download-alt" - color="purple" + :color="dark.isActive ? `indigo-4` : `indigo`" :label="t(`admin.locale.fetch`)" @click="fetchLocales"> {{ t(`admin.locale.fetchHint`) }} diff --git a/frontend/src/pages/AdminSearch.vue b/frontend/src/pages/AdminSearch.vue index 06bb1453e..dee6bd051 100644 --- a/frontend/src/pages/AdminSearch.vue +++ b/frontend/src/pages/AdminSearch.vue @@ -7,7 +7,9 @@ src="/_assets/icons/fluent-find-and-replace-animated.svg" />
-
{{ t('admin.search.title') }}
+
+ {{ t('admin.search.title') }} +
{{ t('admin.search.subtitle') }}
@@ -18,7 +20,7 @@ flat icon="mdi:database-refresh" :label="t(`admin.searchRebuildIndex`)" - color="purple" + :color="dark.isActive ? `indigo-4` : `indigo`" @click="rebuild" :loading="state.rebuildLoading" /> @@ -100,6 +102,7 @@ import { onMounted, reactive } from 'vue' import { useI18n } from 'vue-i18n' +import { useDark } from '@/composables/dark' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' import { loading } from '@/composables/loading' @@ -109,6 +112,10 @@ import { useSiteStore } from '@/stores/site' import UtilCodeEditor from '@/components/UtilCodeEditor.vue' import { apiErrorMessage } from '@/helpers/apiError' +// COMPOSABLES + +const dark = useDark() + // STORES const siteStore = useSiteStore() diff --git a/frontend/src/pages/AdminUsers.vue b/frontend/src/pages/AdminUsers.vue index 2c922cbaf..393143a17 100644 --- a/frontend/src/pages/AdminUsers.vue +++ b/frontend/src/pages/AdminUsers.vue @@ -39,14 +39,18 @@ :loading="state.loading > 0"> {{ t(`common.actions.refresh`) }} + - {{ t(`admin.users.defaults`) }} + :label="t(`admin.users.defaultsButton`)"> import('@/pages/AdminUsers.vue') }, // -> System { path: 'api', component: () => import('@/pages/AdminApi.vue') }, + { path: 'audit', component: () => import('@/pages/AdminAudit.vue') }, { path: 'extensions', component: () => import('@/pages/AdminExtensions.vue') }, { path: 'icons', component: () => import('@/pages/AdminIcons.vue') }, { path: 'instances', component: () => import('@/pages/AdminInstances.vue') }, diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index 66d3c4614..818dad523 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -30,6 +30,29 @@ function formatDatePart(zoned, dateFormat) { } } +/** + * The zone this user's clock is in: their stored preference where it is still a real zone, and this + * browser's otherwise. + * + * A preference can outlive the zone it names — the IANA database retires and renames them — and an + * empty one is the normal state for an account that never chose. Either way a table full of dates + * must not throw, so both fall back to where the reader actually is. + * + * @param timezone This user's stored zone, which may be empty or no longer exist. + */ +function resolveZone(timezone) { + if (!timezone) { + return Temporal.Now.timeZoneId() + } + try { + // -> The only way to ask whether a zone id is real is to use it + Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(timezone) + return timezone + } catch { + return Temporal.Now.timeZoneId() + } +} + /** * The moment as this user's clock shows it, whatever form the API sent it in. * @@ -43,13 +66,7 @@ function toUserZone(date, timezone) { } else if (date instanceof Date) { instant = date.toTemporalInstant() } - // -> A preference set before the zone list changed, or none at all, falls back to this browser's - // zone rather than throwing in the middle of a table - try { - return instant.toZonedDateTimeISO(timezone || Temporal.Now.timeZoneId()) - } catch { - return instant.toZonedDateTimeISO(Temporal.Now.timeZoneId()) - } + return instant.toZonedDateTimeISO(resolveZone(timezone)) } /** @@ -218,6 +235,16 @@ export const useUserStore = defineStore('user', { time: formatTimePart(zoned, this.timeFormat) }) }, + /** + * The IANA zone every date on screen is rendered in — this user's preference, or this browser's + * where they have none or theirs no longer exists. + * + * Resolved rather than read off `timezone` directly, so that what is displayed as the zone is the + * one the timestamps beside it were actually converted to. The two are the same call. + */ + timezoneId() { + return resolveZone(this.timezone) + }, /** * Format the DATE alone, in this user's pattern and zone. For a line with no room for a time, or * where the time says nothing worth reading -- the day an update was released, say.