kempo 4.2.39 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +7 -0
  2. package/app-public/default.template.html +1 -1
  3. package/dist/admin/nav.fragment.html +5 -0
  4. package/dist/admin/realtime/index.page.html +81 -0
  5. package/dist/admin/settings/[owner]/[name]/index.page.html +52 -1
  6. package/dist/admin/settings/index.page.html +12 -1
  7. package/dist/kempo/api/realtime/WS.js +2 -0
  8. package/dist/kempo/api/realtime/WS.js.map +1 -0
  9. package/dist/kempo/api/realtime/connections/GET.js +2 -0
  10. package/dist/kempo/api/realtime/connections/GET.js.map +1 -0
  11. package/dist/kempo/icons/credit_card.svg +1 -0
  12. package/dist/kempo/icons/sensors.svg +1 -0
  13. package/dist/kempo/realtime.js +2 -0
  14. package/dist/kempo/realtime.js.map +1 -0
  15. package/dist/kempo/sdk.js +1 -1
  16. package/dist/kempo/sdk.js.map +1 -1
  17. package/middleware/kempo.js +30 -15
  18. package/package.json +2 -2
  19. package/scripts/init-db.js +4 -1
  20. package/server/db/schema.js +30 -1
  21. package/server/sdk.js +5 -0
  22. package/server/utils/extensions/contentDirs.js +27 -0
  23. package/server/utils/realtime/Hub.js +676 -0
  24. package/server/utils/realtime/channels.js +247 -0
  25. package/server/utils/realtime/closeConnection.js +4 -0
  26. package/server/utils/realtime/constants.js +20 -0
  27. package/server/utils/realtime/duration.js +18 -0
  28. package/server/utils/realtime/getHub.js +15 -0
  29. package/server/utils/realtime/index.js +14 -0
  30. package/server/utils/realtime/listConnections.js +4 -0
  31. package/server/utils/realtime/listSubscribers.js +4 -0
  32. package/server/utils/realtime/pruneMessages.js +45 -0
  33. package/server/utils/realtime/publish.js +86 -0
  34. package/server/utils/realtime/sendToConnection.js +4 -0
  35. package/server/utils/routing/serveUnmatched.js +8 -1
  36. package/server/utils/settings/getPublicSettings.js +4 -2
  37. package/server/utils/settings/getSettingWithMetadata.js +11 -4
  38. package/server/utils/settings/helpers.js +8 -2
  39. package/server/utils/settings/listSettings.js +9 -1
  40. package/server/utils/settings/secretCrypto.js +41 -0
  41. package/server/utils/settings/setSetting.js +31 -10
@@ -0,0 +1,247 @@
1
+ import { join, resolve, sep } from 'path';
2
+ import { pathToFileURL } from 'url';
3
+ import userHasPermission from '../permissions/userHasPermission.js';
4
+ import { getEnabledExtensions } from '../extensions/scopeCache.js';
5
+ import parseDuration from './duration.js';
6
+
7
+ /*
8
+ Channels
9
+
10
+ A channel is named `<owner>:<name>`. The owner is an extension's package name (or `kempo` for core),
11
+ so two extensions cannot collide on a name and a channel always says who is responsible for it.
12
+
13
+ Channels are closed by default: one must be declared with a permission or an authorize function
14
+ before anyone can subscribe or publish. The one exception is a user's own `user:<id>` channel, which
15
+ is implicit and only that user may subscribe to.
16
+
17
+ A channel comes from one of two places. Code registers it with `registerChannel`, which only works
18
+ for something loaded at startup because nothing else runs before the first subscriber connects.
19
+ An extension instead declares it in its kempo-config.json under `realtime.channels`; that snapshot
20
+ is stored in the extension table when the extension is installed, so it is available to every
21
+ process with no file access and no load-order dependency, and disabling the extension closes its
22
+ channels.
23
+
24
+ A channel also says how it behaves:
25
+
26
+ scope "cluster" (default) delivers through Postgres to subscribers on every process.
27
+ "process" delivers in memory to subscribers on the process that publishes, never
28
+ touching the database, for traffic too fast for the bus. It cannot persist.
29
+ onMessage Who handles a message a client sends to the channel. A function when registered in
30
+ code; for an extension, a path inside its own package, loaded once and kept in
31
+ memory, since a lookup per message would put the database on the hot path.
32
+ dropIfBackedUp Deliveries to a client that is already behind are skipped, for data where only the
33
+ newest value matters.
34
+ */
35
+
36
+ /*
37
+ State lives on a global keyed by Symbol.for rather than in module scope: kempo can be resolved twice
38
+ in one process (a symlinked checkout during development, a hoisted copy beside a nested one), and a
39
+ registration made through one copy would otherwise be invisible to the other.
40
+ */
41
+ const REGISTRY = Symbol.for('kempo.realtime.channels');
42
+
43
+ if(!globalThis[REGISTRY]){
44
+ globalThis[REGISTRY] = new Map();
45
+ }
46
+
47
+ const registry = () => globalThis[REGISTRY];
48
+
49
+ export const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
50
+ export const USER_CHANNEL_PREFIX = 'user:';
51
+
52
+ const RESERVED_OWNERS = new Set(['user']);
53
+ const OWNER_PATTERN = /^(@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/;
54
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
55
+
56
+ /*
57
+ Registration
58
+ */
59
+
60
+ export const registerChannel = ({ owner, name, permission, authorize, persist = false, retention, scope = 'cluster', onMessage, dropIfBackedUp = false } = {}) => {
61
+ if(typeof owner !== 'string' || !OWNER_PATTERN.test(owner) || RESERVED_OWNERS.has(owner)){
62
+ return [{ code: 400, msg: 'Owner must be an extension package name (or "kempo" for core)' }, null];
63
+ }
64
+ if(typeof name !== 'string' || !NAME_PATTERN.test(name)){
65
+ return [{ code: 400, msg: 'Channel name may contain letters, numbers, dots, dashes and underscores' }, null];
66
+ }
67
+ if(!permission && typeof authorize !== 'function'){
68
+ return [{ code: 400, msg: 'A channel needs a permission or an authorize function, channels are closed by default' }, null];
69
+ }
70
+ if(permission !== undefined && (typeof permission !== 'string' || !permission)){
71
+ return [{ code: 400, msg: 'Permission must be a permission name' }, null];
72
+ }
73
+ if(authorize !== undefined && typeof authorize !== 'function'){
74
+ return [{ code: 400, msg: 'authorize must be a function' }, null];
75
+ }
76
+
77
+ const retentionMs = retention === undefined ? DEFAULT_RETENTION_MS : parseDuration(retention);
78
+ if(retentionMs === null){
79
+ return [{ code: 400, msg: 'Retention must be a positive number of milliseconds or a string like "24h"' }, null];
80
+ }
81
+ if(scope !== 'cluster' && scope !== 'process'){
82
+ return [{ code: 400, msg: 'Scope must be "cluster" or "process"' }, null];
83
+ }
84
+ if(persist && scope === 'process'){
85
+ return [{ code: 400, msg: 'A channel with scope "process" cannot persist, since nothing is stored' }, null];
86
+ }
87
+ if(onMessage !== undefined && typeof onMessage !== 'function'){
88
+ return [{ code: 400, msg: 'onMessage must be a function' }, null];
89
+ }
90
+
91
+ const channel = `${owner}:${name}`;
92
+ if(registry().has(channel)){
93
+ return [{ code: 409, msg: `Channel "${channel}" is already registered` }, null];
94
+ }
95
+
96
+ registry().set(channel, {
97
+ channel,
98
+ owner,
99
+ name,
100
+ permission: permission || null,
101
+ authorize: authorize || null,
102
+ persist: Boolean(persist),
103
+ retentionMs,
104
+ scope,
105
+ onMessage: onMessage || null,
106
+ handlerPath: null,
107
+ dropIfBackedUp: Boolean(dropIfBackedUp),
108
+ source: 'code'
109
+ });
110
+
111
+ return [null, { channel }];
112
+ };
113
+
114
+ export const unregisterChannel = (channel) => registry().delete(channel);
115
+
116
+ /*
117
+ Resolution
118
+ */
119
+
120
+ export const resolveChannel = async (channel) => {
121
+ if(typeof channel !== 'string' || !channel.includes(':')) return null;
122
+
123
+ if(channel.startsWith(USER_CHANNEL_PREFIX)){
124
+ const userId = channel.slice(USER_CHANNEL_PREFIX.length);
125
+ if(!userId) return null;
126
+ return { channel, owner: 'user', name: userId, userId, persist: false, retentionMs: DEFAULT_RETENTION_MS, scope: 'cluster', onMessage: null, handlerPath: null, dropIfBackedUp: false, source: 'implicit' };
127
+ }
128
+
129
+ const registered = registry().get(channel);
130
+ if(registered) return registered;
131
+
132
+ const separator = channel.indexOf(':');
133
+ const owner = channel.slice(0, separator);
134
+ const name = channel.slice(separator + 1);
135
+
136
+ const extensions = await getEnabledExtensions();
137
+ const declared = extensions.find(extension => extension.name === owner)?.kempo?.realtime?.channels;
138
+ if(!Array.isArray(declared)) return null;
139
+
140
+ const entry = declared.find(candidate => candidate && candidate.name === name);
141
+ // A declared channel with no permission is treated as undeclared, since channels are closed by default
142
+ if(!entry || typeof entry.permission !== 'string' || !entry.permission) return null;
143
+
144
+ const retentionMs = entry.retention === undefined ? DEFAULT_RETENTION_MS : parseDuration(entry.retention);
145
+ if(retentionMs === null) return null;
146
+
147
+ /*
148
+ Anything about the declaration that cannot be honoured closes the channel rather than quietly
149
+ opening it with different behaviour than the extension asked for.
150
+ */
151
+ const scope = entry.scope === undefined ? 'cluster' : entry.scope;
152
+ if(scope !== 'cluster' && scope !== 'process') return null;
153
+ if(entry.persist && scope === 'process') return null;
154
+
155
+ let handlerPath = null;
156
+ if(entry.onMessage !== undefined){
157
+ handlerPath = resolveHandlerPath(owner, entry.onMessage);
158
+ if(!handlerPath) return null;
159
+ }
160
+
161
+ return {
162
+ channel,
163
+ owner,
164
+ name,
165
+ permission: entry.permission,
166
+ authorize: null,
167
+ persist: Boolean(entry.persist),
168
+ retentionMs,
169
+ scope,
170
+ onMessage: null,
171
+ handlerPath,
172
+ dropIfBackedUp: Boolean(entry.dropIfBackedUp),
173
+ source: 'extension'
174
+ };
175
+ };
176
+
177
+ /*
178
+ Message handlers
179
+
180
+ An extension names its handler as a path inside its own package, like a hook callback. The path is
181
+ resolved against the package and refused if it leaves it, so a manifest cannot point the server at
182
+ arbitrary code elsewhere on disk.
183
+ */
184
+ const resolveHandlerPath = (owner, relative) => {
185
+ if(typeof relative !== 'string' || !relative.startsWith('./')) return null;
186
+ const root = join(process.cwd(), 'node_modules', owner);
187
+ const full = resolve(root, relative);
188
+ return full.startsWith(root + sep) ? full : null;
189
+ };
190
+
191
+ const HANDLERS = Symbol.for('kempo.realtime.handlers');
192
+
193
+ if(!globalThis[HANDLERS]){
194
+ globalThis[HANDLERS] = new Map();
195
+ }
196
+
197
+ export const clearMessageHandlerCache = () => globalThis[HANDLERS].clear();
198
+
199
+ /*
200
+ Loaded once and kept, so handling a message never touches the database or the disk. Returns null for a
201
+ channel that accepts no messages; throws if a declared handler cannot be loaded, which the caller
202
+ reports rather than treating as "no handler".
203
+ */
204
+ export const loadMessageHandler = async (config) => {
205
+ if(typeof config.onMessage === 'function') return config.onMessage;
206
+ if(!config.handlerPath) return null;
207
+
208
+ let handler = globalThis[HANDLERS].get(config.handlerPath);
209
+ if(!handler){
210
+ const module = await import(pathToFileURL(config.handlerPath).href);
211
+ handler = module.default || module;
212
+ if(typeof handler !== 'function'){
213
+ throw new Error(`${config.handlerPath} does not export a function`);
214
+ }
215
+ globalThis[HANDLERS].set(config.handlerPath, handler);
216
+ }
217
+ return handler;
218
+ };
219
+
220
+ /*
221
+ Authorization
222
+
223
+ Checked when a client subscribes. A permission granted or revoked afterwards does not affect a
224
+ subscription that already exists; a session ending does, and is enforced separately.
225
+ */
226
+
227
+ export const authorizeSubscription = async ({ config, user }) => {
228
+ if(!user?.id) return [{ code: 401, msg: 'Authentication required' }, null];
229
+
230
+ if(config.source === 'implicit') return [null, config.userId === user.id];
231
+
232
+ if(config.permission){
233
+ const [error, allowed] = await userHasPermission(user.id, config.permission);
234
+ if(error) return [error, null];
235
+ if(!allowed) return [null, false];
236
+ }
237
+
238
+ if(config.authorize){
239
+ try {
240
+ if(!await config.authorize({ user, channel: config.channel })) return [null, false];
241
+ } catch(error) {
242
+ return [{ code: 500, msg: 'Channel authorization failed' }, null];
243
+ }
244
+ }
245
+
246
+ return [null, true];
247
+ };
@@ -0,0 +1,4 @@
1
+ import getHub from './getHub.js';
2
+
3
+ // Only reaches a connection held by this process. To end a user's access everywhere, delete their sessions.
4
+ export default ({ connectionId, code, reason } = {}) => getHub().closeConnection({ connectionId, code, reason });
@@ -0,0 +1,20 @@
1
+ // The one Postgres NOTIFY channel every kempo process listens on. Kempo channel names travel inside the payload.
2
+ export const BUS_CHANNEL = 'kempo_realtime';
3
+
4
+ /*
5
+ NOTIFY payloads must be under 8000 bytes. A message on a channel that does not persist travels in the
6
+ notification itself, so it is capped a little under that; a persisted message sends only its id.
7
+ */
8
+ export const MAX_INLINE_BYTES = 7900;
9
+ export const MAX_PERSISTED_BYTES = 1000000;
10
+
11
+ export const LOCK_PREFIX = 'kempo:realtime:';
12
+
13
+ // In the 4000-4999 range reserved for applications. The browser client treats it as "do not reconnect".
14
+ export const SESSION_CLOSE_CODE = 4401;
15
+
16
+ // The user already holds as many connections as they are allowed. Retrying would be refused again, so the client stops.
17
+ export const TOO_MANY_CONNECTIONS_CODE = 4429;
18
+
19
+ // 1008, policy violation: the connection was sending far faster than a client should
20
+ export const RATE_LIMIT_CLOSE_CODE = 1008;
@@ -0,0 +1,18 @@
1
+ const UNITS = { ms: 1, s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
2
+
3
+ /*
4
+ Accepts a number of milliseconds or a string such as "30s", "15m", "24h" or "7d". Returns null for
5
+ anything else, including zero and negative values, so a caller can tell "invalid" from "no limit".
6
+ */
7
+ export default (value) => {
8
+ if(typeof value === 'number'){
9
+ return Number.isFinite(value) && value > 0 ? value : null;
10
+ }
11
+ if(typeof value !== 'string') return null;
12
+
13
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/);
14
+ if(!match) return null;
15
+
16
+ const milliseconds = Number(match[1]) * UNITS[match[2]];
17
+ return milliseconds > 0 ? milliseconds : null;
18
+ };
@@ -0,0 +1,15 @@
1
+ import Hub from './Hub.js';
2
+
3
+ /*
4
+ One hub per process, held on a Symbol.for global for the same reason as the channel registry: the
5
+ route that accepts a socket and an extension that publishes must reach the same instance even if
6
+ kempo was resolved twice.
7
+ */
8
+ const HUB = Symbol.for('kempo.realtime.hub');
9
+
10
+ export default () => {
11
+ if(!globalThis[HUB]){
12
+ globalThis[HUB] = new Hub();
13
+ }
14
+ return globalThis[HUB];
15
+ };
@@ -0,0 +1,14 @@
1
+ /*
2
+ The realtime surface for kempo (CMS) extensions and application code, exposed as `realtime` from
3
+ `server/sdk.js`. Everything else in this directory is the machinery behind it.
4
+
5
+ Whatever is described as "this process" reaches only the connections held by the process it runs in:
6
+ sockets belong to the process that accepted them.
7
+ */
8
+ export { registerChannel } from './channels.js';
9
+ export { default as publish } from './publish.js';
10
+ export { default as sendToConnection } from './sendToConnection.js';
11
+ export { default as closeConnection } from './closeConnection.js';
12
+ export { default as listSubscribers } from './listSubscribers.js';
13
+ export { default as listConnections } from './listConnections.js';
14
+ export { default as pruneMessages } from './pruneMessages.js';
@@ -0,0 +1,4 @@
1
+ import getHub from './getHub.js';
2
+
3
+ // Only this process's connections: sockets are held by the process that accepted them.
4
+ export default () => getHub().listConnections();
@@ -0,0 +1,4 @@
1
+ import getHub from './getHub.js';
2
+
3
+ // The subscribers of a channel that this process holds; other processes hold their own.
4
+ export default ({ channel } = {}) => getHub().listSubscribers({ channel });
@@ -0,0 +1,45 @@
1
+ import { and, eq, lt, sql } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import { realtimeMessage, realtimeChannel } from '../../db/schema.js';
4
+ import { resolveChannel, DEFAULT_RETENTION_MS } from './channels.js';
5
+
6
+ /*
7
+ Deletes persisted messages older than their channel's retention and records how far each channel has
8
+ been pruned, which is what lets a client that asks for a `since` below that point be told it missed
9
+ messages instead of quietly getting an incomplete history.
10
+
11
+ A channel that can no longer be resolved (its extension was uninstalled or disabled) falls back to
12
+ the default retention, so orphaned rows still age out.
13
+ */
14
+ export default async ({ now = new Date() } = {}) => {
15
+ try {
16
+ const rows = await db.selectDistinct({ channel: realtimeMessage.channel }).from(realtimeMessage);
17
+ let deleted = 0;
18
+
19
+ for(const { channel } of rows){
20
+ const config = await resolveChannel(channel);
21
+ const cutoff = new Date(now.getTime() - (config?.retentionMs ?? DEFAULT_RETENTION_MS));
22
+
23
+ const removed = await db
24
+ .delete(realtimeMessage)
25
+ .where(and(eq(realtimeMessage.channel, channel), lt(realtimeMessage.createdAt, cutoff)))
26
+ .returning({ id: realtimeMessage.id });
27
+
28
+ if(!removed.length) continue;
29
+ deleted += removed.length;
30
+
31
+ const prunedThrough = Math.max(...removed.map(row => row.id));
32
+ await db
33
+ .insert(realtimeChannel)
34
+ .values({ channel, prunedThrough })
35
+ .onConflictDoUpdate({
36
+ target: realtimeChannel.channel,
37
+ set: { prunedThrough: sql`greatest(${realtimeChannel.prunedThrough}, ${prunedThrough})` }
38
+ });
39
+ }
40
+
41
+ return [null, { deleted }];
42
+ } catch(error) {
43
+ return [{ code: 500, msg: 'Failed to prune realtime messages' }, null];
44
+ }
45
+ };
@@ -0,0 +1,86 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import { realtimeMessage } from '../../db/schema.js';
4
+ import { resolveChannel } from './channels.js';
5
+ import getHub from './getHub.js';
6
+ import { BUS_CHANNEL, MAX_INLINE_BYTES, MAX_PERSISTED_BYTES, LOCK_PREFIX } from './constants.js';
7
+
8
+ /*
9
+ Every message travels through Postgres NOTIFY, including to subscribers held by this same process,
10
+ so there is a single delivery path and a publish behaves the same whichever process it runs in.
11
+
12
+ A channel that persists writes the message to a table first and notifies with only its id. The row
13
+ and the notification are one transaction, and NOTIFY is delivered on commit, so no listener can be
14
+ told about a row it cannot yet read. The transaction also takes a lock keyed on the channel: ids are
15
+ handed out at insert but become visible at commit, so without it two publishes could commit out of
16
+ order and a client replaying from the earlier id would skip the later-committing row for good.
17
+ */
18
+ export default async ({ channel, data } = {}) => {
19
+ if(!channel){
20
+ return [{ code: 400, msg: 'Channel is required' }, null];
21
+ }
22
+ if(data === undefined || data === null){
23
+ return [{ code: 400, msg: 'Data is required' }, null];
24
+ }
25
+
26
+ const config = await resolveChannel(channel);
27
+ if(!config){
28
+ return [{ code: 404, msg: `Channel "${channel}" is not registered` }, null];
29
+ }
30
+
31
+ let serialized;
32
+ try {
33
+ serialized = JSON.stringify(data);
34
+ } catch(error) {
35
+ return [{ code: 400, msg: 'Data must be JSON-serializable' }, null];
36
+ }
37
+ if(serialized === undefined){
38
+ return [{ code: 400, msg: 'Data must be JSON-serializable' }, null];
39
+ }
40
+
41
+ /*
42
+ A channel with scope "process" never touches the database: the message is delivered in memory to the
43
+ subscribers this process holds. That is what makes it fast enough for traffic the bus cannot carry, and
44
+ it means a publish made in another process does not reach them, which is why the scope is a choice
45
+ the channel makes and not a default.
46
+ */
47
+ if(config.scope === 'process'){
48
+ if(Buffer.byteLength(serialized) > MAX_PERSISTED_BYTES){
49
+ return [{ code: 413, msg: `Message is larger than the ${MAX_PERSISTED_BYTES} byte limit` }, null];
50
+ }
51
+ return [null, { id: null, delivered: getHub().deliverLocal({ channel, data }) }];
52
+ }
53
+
54
+ if(config.persist){
55
+ if(Buffer.byteLength(serialized) > MAX_PERSISTED_BYTES){
56
+ return [{ code: 413, msg: `Message is larger than the ${MAX_PERSISTED_BYTES} byte limit` }, null];
57
+ }
58
+
59
+ try {
60
+ const id = await db.transaction(async transaction => {
61
+ await transaction.execute(sql`select pg_advisory_xact_lock(hashtext(${LOCK_PREFIX + channel}))`);
62
+ const [row] = await transaction
63
+ .insert(realtimeMessage)
64
+ .values({ channel, data, createdAt: new Date() })
65
+ .returning({ id: realtimeMessage.id });
66
+ await transaction.execute(sql`select pg_notify(${BUS_CHANNEL}, ${JSON.stringify({ c: channel, i: row.id })})`);
67
+ return row.id;
68
+ });
69
+ return [null, { id }];
70
+ } catch(error) {
71
+ return [{ code: 500, msg: 'Failed to publish message' }, null];
72
+ }
73
+ }
74
+
75
+ const payload = JSON.stringify({ c: channel, d: data });
76
+ if(Buffer.byteLength(payload) > MAX_INLINE_BYTES){
77
+ return [{ code: 413, msg: `Message is too large for a channel that does not persist (limit ${MAX_INLINE_BYTES} bytes); send less, or register the channel with persist: true` }, null];
78
+ }
79
+
80
+ try {
81
+ await db.execute(sql`select pg_notify(${BUS_CHANNEL}, ${payload})`);
82
+ return [null, { id: null }];
83
+ } catch(error) {
84
+ return [{ code: 500, msg: 'Failed to publish message' }, null];
85
+ }
86
+ };
@@ -0,0 +1,4 @@
1
+ import getHub from './getHub.js';
2
+
3
+ // Connection ids belong to the process that holds the connection, so this only reaches one held by this process.
4
+ export default ({ connectionId, data } = {}) => getHub().sendToConnection({ connectionId, data });
@@ -1,6 +1,7 @@
1
1
  import { join } from 'path';
2
2
  import { renderExternalPage } from 'kempo-server/templating';
3
3
  import triggerHook from '../hooks/triggerHook.js';
4
+ import { extensionPublicDirs } from '../extensions/contentDirs.js';
4
5
 
5
6
  /*
6
7
  Imported only when a handler actually serves a file. `kempo-server/serve-static-file` is a newer
@@ -102,7 +103,13 @@ export default async (request, response, publicDir) => {
102
103
  because nothing served a body. The content type is applied last so it cannot be clobbered.
103
104
  */
104
105
  try {
105
- const html = await renderExternalPage(join(publicDir, 'CATCH.page.html'), publicDir, resolveDir);
106
+ /*
107
+ The 404 is an ordinary public page, so it gets the same extension-contributed content every
108
+ other public page does — a site-wide banner pushed by an extension should not vanish on the
109
+ one page a lost visitor actually lands on.
110
+ */
111
+ const contentDirs = await extensionPublicDirs();
112
+ const html = await renderExternalPage(join(publicDir, 'CATCH.page.html'), publicDir, resolveDir, {}, {}, 10, contentDirs, contentDirs);
106
113
  response.writeHead(404, { ...draft.headers, 'Content-Type': 'text/html; charset=utf-8' });
107
114
  response.end(html);
108
115
  } catch {
@@ -1,14 +1,16 @@
1
1
  import db from '../../db/index.js';
2
2
  import { setting } from '../../db/schema.js';
3
- import { eq } from 'drizzle-orm';
3
+ import { eq, and, ne } from 'drizzle-orm';
4
4
  import { convertValue } from './helpers.js';
5
5
 
6
6
  export default async () => {
7
7
  try {
8
+ // isPublic should never be true on a secret (setSetting forces it false), but this is the
9
+ // one unauthenticated endpoint in the settings system, so exclude 'secret' defensively too.
8
10
  const results = await db
9
11
  .select()
10
12
  .from(setting)
11
- .where(eq(setting.isPublic, true));
13
+ .where(and(eq(setting.isPublic, true), ne(setting.type, 'secret')));
12
14
 
13
15
  const settings = {};
14
16
  for(const s of results){
@@ -2,12 +2,13 @@ import db from '../../db/index.js';
2
2
  import { setting } from '../../db/schema.js';
3
3
  import { eq } from 'drizzle-orm';
4
4
  import { convertValue } from './helpers.js';
5
+ import { SECRET_MASK } from './secretCrypto.js';
5
6
 
6
7
  export default async (owner, name) => {
7
8
  if(!owner || !name){
8
9
  return [{ code: 400, msg: 'Both owner and name are required' }, null];
9
10
  }
10
-
11
+
11
12
  try {
12
13
  const fullName = `${owner}:${name}`;
13
14
  const [result] = await db
@@ -15,12 +16,18 @@ export default async (owner, name) => {
15
16
  .from(setting)
16
17
  .where(eq(setting.name, fullName))
17
18
  .limit(1);
18
-
19
+
19
20
  if(!result) return [{ code: 404, msg: 'Setting not found' }, null];
20
-
21
+
22
+ /*
23
+ This feeds the HTTP API (GET /kempo/api/settings/[owner]/[name]) — the decrypted value must
24
+ never travel over the wire, so a secret is masked here rather than passed through convertValue.
25
+ */
21
26
  return [null, {
22
27
  name: result.name,
23
- value: convertValue(result.value, result.type),
28
+ value: result.type === 'secret'
29
+ ? (result.value === null ? null : SECRET_MASK)
30
+ : convertValue(result.value, result.type),
24
31
  type: result.type,
25
32
  isPublic: result.isPublic,
26
33
  description: result.description,
@@ -1,6 +1,8 @@
1
+ import { encryptSecret, decryptSecret } from './secretCrypto.js';
2
+
1
3
  export const convertValue = (value, type) => {
2
4
  if(value === null) return null;
3
-
5
+
4
6
  switch(type){
5
7
  case 'number':
6
8
  return Number(value);
@@ -8,6 +10,8 @@ export const convertValue = (value, type) => {
8
10
  return value === 'true';
9
11
  case 'json':
10
12
  return JSON.parse(value);
13
+ case 'secret':
14
+ return decryptSecret(value);
11
15
  case 'string':
12
16
  default:
13
17
  return value;
@@ -16,10 +20,12 @@ export const convertValue = (value, type) => {
16
20
 
17
21
  export const serializeValue = (value, type) => {
18
22
  if(value === null) return null;
19
-
23
+
20
24
  switch(type){
21
25
  case 'json':
22
26
  return JSON.stringify(value);
27
+ case 'secret':
28
+ return encryptSecret(value);
23
29
  case 'number':
24
30
  case 'boolean':
25
31
  case 'string':
@@ -1,6 +1,7 @@
1
1
  import db from '../../db/index.js';
2
2
  import { setting } from '../../db/schema.js';
3
3
  import { like, not, and, sql } from 'drizzle-orm';
4
+ import { SECRET_MASK } from './secretCrypto.js';
4
5
 
5
6
  export default async ({ owner, limit = 50, offset = 0 } = {}) => {
6
7
  try {
@@ -16,8 +17,15 @@ export default async ({ owner, limit = 50, offset = 0 } = {}) => {
16
17
  const q = db.select().from(setting);
17
18
  const cq = db.select({ count: sql`count(*)` }).from(setting);
18
19
  if(where){ q.where(where); cq.where(where); }
19
- const settings = await q.limit(limit).offset(offset);
20
+ const results = await q.limit(limit).offset(offset);
20
21
  const [{ count }] = await cq;
22
+
23
+ // Never send secret ciphertext to the admin UI, not even encrypted.
24
+ const settings = results.map(s => s.type === 'secret'
25
+ ? { ...s, value: s.value === null ? null : SECRET_MASK }
26
+ : s
27
+ );
28
+
21
29
  return [null, { settings, total: Number(count), limit, offset }];
22
30
  } catch(error){
23
31
  return [{ code: 500, msg: 'Failed to retrieve settings' }, null];
@@ -0,0 +1,41 @@
1
+ import crypto from 'crypto';
2
+
3
+ /*
4
+ 'secret' settings (API keys, webhook signing secrets, etc.) are encrypted at rest with
5
+ AES-256-GCM. SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes) — generate
6
+ one with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
7
+
8
+ Losing or rotating this key makes every stored secret unrecoverable, so it belongs in the same
9
+ place as DATABASE_URL: set once per environment, backed up, never committed.
10
+ */
11
+
12
+ export const SECRET_MASK = '••••••••';
13
+
14
+ const getKey = () => {
15
+ const raw = process.env.SETTINGS_ENCRYPTION_KEY;
16
+ if(!raw){
17
+ throw new Error('SETTINGS_ENCRYPTION_KEY environment variable is required to store secret settings');
18
+ }
19
+ const key = Buffer.from(raw, 'hex');
20
+ if(key.length !== 32){
21
+ throw new Error('SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
22
+ }
23
+ return key;
24
+ };
25
+
26
+ export const encryptSecret = value => {
27
+ const iv = crypto.randomBytes(12);
28
+ const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
29
+ const ciphertext = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
30
+ return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64');
31
+ };
32
+
33
+ export const decryptSecret = encoded => {
34
+ const data = Buffer.from(encoded, 'base64');
35
+ const iv = data.subarray(0, 12);
36
+ const authTag = data.subarray(12, 28);
37
+ const ciphertext = data.subarray(28);
38
+ const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
39
+ decipher.setAuthTag(authTag);
40
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
41
+ };