slotlock 0.2.0 → 0.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.
- package/CHANGELOG.md +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +213 -9
package/config.js
CHANGED
|
@@ -136,3 +136,195 @@ export function readDashboardConfig(env) {
|
|
|
136
136
|
allowedUsers: users === '*' ? '*' : [...new Set(users.split(','))],
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
+
/** The token buckets `slotlock serve` keeps (SLOTLOCK_LIMITS), each counted per its own key. */
|
|
140
|
+
export const SLOTLOCK_RATE_NAMES = Object.freeze([
|
|
141
|
+
'address',
|
|
142
|
+
'key_read',
|
|
143
|
+
'key_write',
|
|
144
|
+
'tenant_read',
|
|
145
|
+
'tenant_write',
|
|
146
|
+
'global_read',
|
|
147
|
+
'global_write',
|
|
148
|
+
'app_read',
|
|
149
|
+
'app_write',
|
|
150
|
+
'sign_in',
|
|
151
|
+
]);
|
|
152
|
+
/** The counts `slotlock serve` caps (SLOTLOCK_LIMITS), with the range each may be set to. */
|
|
153
|
+
const CAP_RANGES = Object.freeze({
|
|
154
|
+
tenant_series_writes: [1, 100],
|
|
155
|
+
series_writes: [1, 100],
|
|
156
|
+
maintenance_runs: [1, 100],
|
|
157
|
+
subscriptions: [1, 10_000],
|
|
158
|
+
tenant_subscriptions: [1, 10_000],
|
|
159
|
+
key_subscriptions: [1, 64],
|
|
160
|
+
key_lookups: [1, 8],
|
|
161
|
+
key_lookup_queue: [0, 10_000],
|
|
162
|
+
// serve's pool has 10 connections: more than that bounds nothing.
|
|
163
|
+
tenant_transactions: [1, 10],
|
|
164
|
+
tenant_holds: [1, 10_000_000],
|
|
165
|
+
tenant_reservations: [1, 10_000_000],
|
|
166
|
+
tenant_events: [1, 10_000_000],
|
|
167
|
+
tenant_coverage: [1, 10_000_000],
|
|
168
|
+
});
|
|
169
|
+
export const SLOTLOCK_CAP_NAMES = Object.freeze(Object.keys(CAP_RANGES));
|
|
170
|
+
/**
|
|
171
|
+
* What `slotlock serve` allows unless SLOTLOCK_LIMITS says otherwise, sized for one public server on
|
|
172
|
+
* one CPU (the 2026-10-24 readiness audit measured the database at about 900 writes a second):
|
|
173
|
+
* - `address`: every request from one client address, before any credential is looked up.
|
|
174
|
+
* - `key_*`, `tenant_*`, `global_*`: an authenticated request takes a read token from its key, its
|
|
175
|
+
* tenant and the server; a write (a REST method other than GET or HEAD, or a write tool) takes a
|
|
176
|
+
* write token from each as well.
|
|
177
|
+
* - `app_*`: a request to the application API (application-api.ts) from an admin key takes its
|
|
178
|
+
* read and write tokens from its tenant's application buckets instead of the key and tenant ones,
|
|
179
|
+
* so the application and the tenant's agents cannot use up each other's budget; one from any
|
|
180
|
+
* other key takes them from that key's own. `global_*` counts it as well.
|
|
181
|
+
* - `sign_in`: dashboard sign-ins and their callbacks from one client network: an IPv4 address,
|
|
182
|
+
* or an IPv6 client's /48 (slotlockClientNetwork in rate-limit.ts), as the dashboard Worker
|
|
183
|
+
* counts them.
|
|
184
|
+
* - `tenant_series_writes` and `series_writes`: writes that may expand a recurring series (a
|
|
185
|
+
* recurring create, any update) running at once in a tenant and in each server process.
|
|
186
|
+
* - `maintenance_runs`: the application API's maintenance runs at once in each server process, one
|
|
187
|
+
* a tenant, in slots of their own. A freed slot of either kind rests for as long as the event
|
|
188
|
+
* loop was busy while its write held it, up to a second, then goes to the tenant with the fewest
|
|
189
|
+
* writes in flight and, among those, the least use lately. That keeps a tenant sending one write
|
|
190
|
+
* at a time from waiting behind tenants with more, not behind many tenants with one each
|
|
191
|
+
* (rate-limit.ts).
|
|
192
|
+
* - `subscriptions`: MCP live-update streams the server holds open. When it is full, a tenant
|
|
193
|
+
* holding fewer than the tenant holding most, less one, ends that tenant's oldest stream.
|
|
194
|
+
* - `tenant_subscriptions`: of those, how many one tenant may hold.
|
|
195
|
+
* - `key_subscriptions`: of those, how many one key (or the server token) may hold, and always
|
|
196
|
+
* fewer than its tenant's, so one key cannot lock the tenant's other keys out
|
|
197
|
+
* (slotlockKeySubscriptions).
|
|
198
|
+
* - `key_lookups` and `key_lookup_queue`: API key lookups running at once and waiting.
|
|
199
|
+
* - `tenant_transactions`: store transactions one tenant runs at once on the server's pool of 10
|
|
200
|
+
* connections; the rest wait their turn in the process, so a tenant's writes waiting on its own
|
|
201
|
+
* locks leave the other connections to other tenants and key lookups. A server with one tenant
|
|
202
|
+
* can set 10.
|
|
203
|
+
* - `tenant_holds`, `tenant_reservations`, `tenant_events` and `tenant_coverage`: what one tenant
|
|
204
|
+
* may store, counted as the store's tenant limits count it (SlotlockTenantLimits), whichever key,
|
|
205
|
+
* agent or application writes it. Every write surface of the server shares them, so no admin
|
|
206
|
+
* key can grow the shared database without end; a write past one answers 409 with its
|
|
207
|
+
* `*_limit_reached` code.
|
|
208
|
+
*/
|
|
209
|
+
export const SLOTLOCK_DEFAULT_LIMITS = Object.freeze({
|
|
210
|
+
rates: Object.freeze({
|
|
211
|
+
address: { perSecond: 50, burst: 100 },
|
|
212
|
+
key_read: { perSecond: 20, burst: 40 },
|
|
213
|
+
key_write: { perSecond: 5, burst: 20 },
|
|
214
|
+
tenant_read: { perSecond: 20, burst: 40 },
|
|
215
|
+
tenant_write: { perSecond: 5, burst: 20 },
|
|
216
|
+
global_read: { perSecond: 500, burst: 1_000 },
|
|
217
|
+
global_write: { perSecond: 50, burst: 100 },
|
|
218
|
+
app_read: { perSecond: 20, burst: 40 },
|
|
219
|
+
app_write: { perSecond: 5, burst: 20 },
|
|
220
|
+
sign_in: { perSecond: 20 / 60, burst: 20 },
|
|
221
|
+
}),
|
|
222
|
+
caps: Object.freeze({
|
|
223
|
+
tenant_series_writes: 1,
|
|
224
|
+
series_writes: 1,
|
|
225
|
+
maintenance_runs: 1,
|
|
226
|
+
subscriptions: 256,
|
|
227
|
+
tenant_subscriptions: 16,
|
|
228
|
+
key_subscriptions: 4,
|
|
229
|
+
key_lookups: 2,
|
|
230
|
+
key_lookup_queue: 256,
|
|
231
|
+
tenant_transactions: 2,
|
|
232
|
+
// Sized for the server's 10,000 resources a tenant: a live hold for every one, ten bookings and
|
|
233
|
+
// ten events a resource, and coverage from ten calendars a resource.
|
|
234
|
+
tenant_holds: 10_000,
|
|
235
|
+
tenant_reservations: 100_000,
|
|
236
|
+
tenant_events: 100_000,
|
|
237
|
+
tenant_coverage: 100_000,
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
/**
|
|
241
|
+
* The live-update streams one key may hold open: `key_subscriptions`, but at least one fewer than
|
|
242
|
+
* its tenant may (unless the tenant may hold only one), so a key at its own limit leaves its
|
|
243
|
+
* tenant's other keys a stream however the two caps are set.
|
|
244
|
+
*/
|
|
245
|
+
export function slotlockKeySubscriptions(caps) {
|
|
246
|
+
return Math.max(1, Math.min(caps.key_subscriptions, caps.tenant_subscriptions - 1));
|
|
247
|
+
}
|
|
248
|
+
const RATE_PERIOD_SECONDS = { s: 1, m: 60, h: 3_600 };
|
|
249
|
+
const MAX_RATE_COUNT = 1_000_000;
|
|
250
|
+
/** A rate as SLOTLOCK_LIMITS and the `listening` log line write it: `<count>/<s|m|h>:<burst>`. */
|
|
251
|
+
export function formatSlotlockRate(rate) {
|
|
252
|
+
if (rate === null)
|
|
253
|
+
return 'off';
|
|
254
|
+
// The smallest unit in which the count is whole: 50/s rather than 180000/h.
|
|
255
|
+
for (const [unit, seconds] of Object.entries(RATE_PERIOD_SECONDS)) {
|
|
256
|
+
const count = rate.perSecond * seconds;
|
|
257
|
+
if (Number.isInteger(Math.round(count * 1e9) / 1e9) && count >= 1) {
|
|
258
|
+
return `${Math.round(count)}/${unit}:${rate.burst}`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return `${rate.perSecond}/s:${rate.burst}`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* SLOTLOCK_LIMITS: comma-separated `name=value` overrides of SLOTLOCK_DEFAULT_LIMITS. A rate is
|
|
265
|
+
* `<count>/<s|m|h>`, optionally `:<burst>` (default: the count), or `off`; a cap is a whole number.
|
|
266
|
+
* For example `tenant_write=2/s:10,global_write=30/s,address=off`.
|
|
267
|
+
*/
|
|
268
|
+
export function readLimits(env) {
|
|
269
|
+
const rates = { ...SLOTLOCK_DEFAULT_LIMITS.rates };
|
|
270
|
+
const caps = { ...SLOTLOCK_DEFAULT_LIMITS.caps };
|
|
271
|
+
const value = optional(env, 'SLOTLOCK_LIMITS');
|
|
272
|
+
if (value === undefined)
|
|
273
|
+
return { rates, caps };
|
|
274
|
+
const invalid = (detail) => new SlotlockConfigError(`SLOTLOCK_LIMITS must be comma-separated name=value settings, rates as <count>/<s|m|h>[:<burst>] or off (${SLOTLOCK_RATE_NAMES.join(', ')}) and caps as whole numbers (${SLOTLOCK_CAP_NAMES.join(', ')}): ${detail}`);
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
for (const entry of value.split(',').map((part) => part.trim())) {
|
|
277
|
+
const match = /^([a-z_]+)=(\S+)$/.exec(entry);
|
|
278
|
+
if (!match)
|
|
279
|
+
throw invalid(`"${entry.slice(0, 40)}" is not name=value`);
|
|
280
|
+
const [, name, setting] = match;
|
|
281
|
+
if (seen.has(name))
|
|
282
|
+
throw invalid(`${name} is set twice`);
|
|
283
|
+
seen.add(name);
|
|
284
|
+
if (SLOTLOCK_RATE_NAMES.includes(name)) {
|
|
285
|
+
if (setting === 'off') {
|
|
286
|
+
rates[name] = null;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const rate = /^(\d{1,7})\/([smh])(?::(\d{1,7}))?$/.exec(setting);
|
|
290
|
+
const count = Number(rate?.[1]);
|
|
291
|
+
const burst = rate?.[3] === undefined ? count : Number(rate[3]);
|
|
292
|
+
if (!rate || count < 1 || count > MAX_RATE_COUNT || burst < 1 || burst > MAX_RATE_COUNT) {
|
|
293
|
+
throw invalid(`${name} must be <count>/<s|m|h>[:<burst>] with each 1-${MAX_RATE_COUNT}, or off`);
|
|
294
|
+
}
|
|
295
|
+
rates[name] = {
|
|
296
|
+
perSecond: count / RATE_PERIOD_SECONDS[rate[2]],
|
|
297
|
+
burst,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
else if (Object.hasOwn(CAP_RANGES, name)) {
|
|
301
|
+
const [minimum, maximum] = CAP_RANGES[name];
|
|
302
|
+
const cap = /^\d{1,8}$/.test(setting) ? Number(setting) : Number.NaN;
|
|
303
|
+
if (!(cap >= minimum && cap <= maximum)) {
|
|
304
|
+
throw invalid(`${name} must be a whole number from ${minimum} to ${maximum}`);
|
|
305
|
+
}
|
|
306
|
+
caps[name] = cap;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
throw invalid(`${name.slice(0, 40)} is not a limit`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { rates, caps };
|
|
313
|
+
}
|
|
314
|
+
/** Fields a proxy cannot be trusted to set, because the client sends them to every server. */
|
|
315
|
+
const UNTRUSTABLE_FIELDS = new Set(['authorization', 'cookie', 'host', 'origin', 'content-type']);
|
|
316
|
+
/**
|
|
317
|
+
* SLOTLOCK_TRUSTED_PROXY_HEADER: the request field holding the client's address, set (and
|
|
318
|
+
* overwritten) by a proxy every request passes through, such as `CF-Connecting-IP` behind
|
|
319
|
+
* Cloudflare. Unset, the client address is the connection's. A client can send any field, so set it
|
|
320
|
+
* only when nothing reaches the server except through that proxy.
|
|
321
|
+
*/
|
|
322
|
+
export function readTrustedProxyHeader(env) {
|
|
323
|
+
const value = optional(env, 'SLOTLOCK_TRUSTED_PROXY_HEADER');
|
|
324
|
+
if (value === undefined)
|
|
325
|
+
return undefined;
|
|
326
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(value) || UNTRUSTABLE_FIELDS.has(value.toLowerCase())) {
|
|
327
|
+
throw new SlotlockConfigError('SLOTLOCK_TRUSTED_PROXY_HEADER must be the name of a request field your proxy sets to the client address, such as CF-Connecting-IP');
|
|
328
|
+
}
|
|
329
|
+
return value.toLowerCase();
|
|
330
|
+
}
|
package/dashboard.d.ts
CHANGED
|
@@ -76,6 +76,12 @@ export interface SlotlockDashboardOptions {
|
|
|
76
76
|
availability?: readonly WeeklyAvailabilityRule[];
|
|
77
77
|
fetch?: typeof fetch;
|
|
78
78
|
now?: () => number;
|
|
79
|
+
/**
|
|
80
|
+
* The address a request came from, as the server counts its clients (the socket's, or a trusted
|
|
81
|
+
* proxy's field such as CF-Connecting-IP), so one client's sign-ins in flight are capped below
|
|
82
|
+
* every client's. Without it, or when it returns undefined, only the cap on every client applies.
|
|
83
|
+
*/
|
|
84
|
+
clientAddress?: (request: Request) => string | undefined;
|
|
79
85
|
/**
|
|
80
86
|
* Called with whatever a request handler throws; the person sees a generic error page. Not
|
|
81
87
|
* awaited; what it throws, or its promise rejects with, is ignored.
|
package/dashboard.js
CHANGED
|
@@ -26,6 +26,8 @@ const MAX_FORM_BYTES = 8_192;
|
|
|
26
26
|
* This also bounds how fast unauthenticated callbacks can write sign-in records.
|
|
27
27
|
*/
|
|
28
28
|
const MAX_SIGN_INS_IN_FLIGHT = 8;
|
|
29
|
+
/** Of those, how many one client address may run at once, so one client cannot take every slot. */
|
|
30
|
+
const MAX_SIGN_INS_IN_FLIGHT_PER_CLIENT = 2;
|
|
29
31
|
/** Entries each in-memory record keeps (createSlotlockMemoryDashboardState); past this the oldest go first. */
|
|
30
32
|
const MAX_REMEMBERED = 10_000;
|
|
31
33
|
/** A form's single-use value: 18 random bytes, then a MAC binding them to the session. */
|
|
@@ -432,6 +434,8 @@ export function createSlotlockDashboard(options) {
|
|
|
432
434
|
const allowed = (uid) => options.allowedUsers === '*' ||
|
|
433
435
|
(Array.isArray(options.allowedUsers) && options.allowedUsers.includes(uid));
|
|
434
436
|
let signInsInFlight = 0;
|
|
437
|
+
/** Sign-ins in flight by client address. */
|
|
438
|
+
const signInsByClient = new Map();
|
|
435
439
|
function report(message) {
|
|
436
440
|
try {
|
|
437
441
|
ignoreRejection(options.onError?.(new Error(message)));
|
|
@@ -861,11 +865,16 @@ ${state.unshown
|
|
|
861
865
|
!safeEqual(state, pending.state)) {
|
|
862
866
|
return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
|
|
863
867
|
}
|
|
864
|
-
|
|
868
|
+
const client = options.clientAddress?.(request);
|
|
869
|
+
const fromClient = client === undefined ? 0 : (signInsByClient.get(client) ?? 0);
|
|
870
|
+
if (signInsInFlight >= MAX_SIGN_INS_IN_FLIGHT ||
|
|
871
|
+
fromClient >= MAX_SIGN_INS_IN_FLIGHT_PER_CLIENT) {
|
|
865
872
|
return signedOutPage(503, 'Too many people are signing in at once. Sign in again in a moment.', [clearSignIn]);
|
|
866
873
|
}
|
|
867
874
|
const verifier = pending.verifier;
|
|
868
875
|
signInsInFlight += 1;
|
|
876
|
+
if (client !== undefined)
|
|
877
|
+
signInsByClient.set(client, fromClient + 1);
|
|
869
878
|
let user;
|
|
870
879
|
try {
|
|
871
880
|
// Recorded before GitHub is asked, so a replayed callback is refused on every server unasked.
|
|
@@ -876,6 +885,13 @@ ${state.unshown
|
|
|
876
885
|
}
|
|
877
886
|
finally {
|
|
878
887
|
signInsInFlight -= 1;
|
|
888
|
+
if (client !== undefined) {
|
|
889
|
+
const left = (signInsByClient.get(client) ?? 1) - 1;
|
|
890
|
+
if (left === 0)
|
|
891
|
+
signInsByClient.delete(client);
|
|
892
|
+
else
|
|
893
|
+
signInsByClient.set(client, left);
|
|
894
|
+
}
|
|
879
895
|
}
|
|
880
896
|
if (!user) {
|
|
881
897
|
return signedOutPage(502, 'GitHub did not complete the sign-in. Try again.', [clearSignIn]);
|
package/ddl.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
/** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
|
|
2
2
|
export declare const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = "Slotlock will not deploy into schema slotlock";
|
|
3
|
+
/**
|
|
4
|
+
* The version of the schema this release creates, recorded in `slotlock.schema_version` by every
|
|
5
|
+
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SLOTLOCK_SCHEMA_VERSION = 3;
|
|
8
|
+
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
9
|
+
export declare const SLOTLOCK_SCHEMA_NEWER_REFUSAL = "Slotlock will not migrate a schema a newer release migrated";
|
|
10
|
+
/**
|
|
11
|
+
* What every migration checks before it changes anything, applySchema and applyTenantRls included
|
|
12
|
+
* when they find nothing to change: that no other role controls the schema, then the server version
|
|
13
|
+
* and the schema version. Run with search_path pinned, as the DDL pins it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SLOTLOCK_DEPLOYMENT_CHECKS = "DO $slotlock_schema_control$\nDECLARE\n refusals text[];\nBEGIN\n SELECT pg_catalog.array_agg(refusal.reason ORDER BY refusal.reason)\n INTO refusals\n FROM (\n SELECT 'schema slotlock is owned by ' || owned_by.rolname AS reason\n FROM pg_catalog.pg_namespace ns\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ns.nspowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create objects in schema slotlock'\n FROM pg_catalog.pg_namespace ns\n CROSS JOIN LATERAL pg_catalog.aclexplode(ns.nspacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'CREATE'\n AND acl.grantee <> ns.nspowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT ident.type || ' ' || ident.identity || ' is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_shdepend owned\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = owned.refobjid\n CROSS JOIN LATERAL pg_catalog.pg_identify_object(owned.classid, owned.objid, owned.objsubid) ident\n WHERE owned.dbid = (SELECT db.oid FROM pg_catalog.pg_database db\n WHERE db.datname = pg_catalog.current_database())\n AND owned.deptype = 'o'\n AND owned.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n AND ident.schema = 'slotlock'\n UNION\n SELECT 'trigger ' || trg.tgname || ' on slotlock.' || rel.relname || ' runs '\n || trg.tgfoid::pg_catalog.regprocedure::pg_catalog.text\n || ', owned by ' || owned_by.rolname\n FROM pg_catalog.pg_trigger trg\n JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n JOIN pg_catalog.pg_proc fn ON fn.oid = trg.tgfoid\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = fn.proowner\n WHERE ns.nspname = 'slotlock'\n AND NOT trg.tgisinternal\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create triggers on slotlock.'\n || rel.relname\n FROM pg_catalog.pg_class rel\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n CROSS JOIN LATERAL pg_catalog.aclexplode(rel.relacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'TRIGGER'\n AND acl.grantee <> rel.relowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT 'extension ' || ext.extname || ' in schema slotlock is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_extension ext\n JOIN pg_catalog.pg_namespace ns ON ns.oid = ext.extnamespace\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ext.extowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n ) refusal;\n IF refusals IS NOT NULL THEN\n RAISE EXCEPTION 'Slotlock will not deploy into schema slotlock: another role controls it'\n USING ERRCODE = 'insufficient_privilege',\n DETAIL = pg_catalog.to_jsonb(refusals)::pg_catalog.text,\n HINT = 'The deployment role must own schema slotlock and every object in it, and no other role may create objects in it.';\n END IF;\nEND\n$slotlock_schema_control$;\nDO $slotlock_schema_preconditions$\nDECLARE\n installed integer;\nBEGIN\n IF pg_catalog.current_setting('server_version_num')::integer < 160000 THEN\n RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',\n pg_catalog.current_setting('server_version')\n USING ERRCODE = 'feature_not_supported';\n END IF;\n IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN\n EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;\n IF installed > 3 THEN\n RAISE EXCEPTION 'Slotlock will not migrate a schema a newer release migrated (version %; this release is version 3)', installed\n USING ERRCODE = 'object_not_in_prerequisite_state',\n HINT = 'Migrate and serve it with the release that migrated it, or a later one.';\n END IF;\n END IF;\nEND\n$slotlock_schema_preconditions$;\n";
|
|
3
16
|
/** Active (unrevoked, unexpired) API keys one tenant may hold. */
|
|
4
17
|
export declare const SLOTLOCK_API_KEY_ACTIVE_LIMIT = 100;
|
|
5
18
|
/** API keys one tenant may hold in all, revoked and expired ones included, so lists stay bounded. */
|
|
@@ -23,18 +36,47 @@ export declare const SLOTLOCK_DASHBOARD_TABLES: readonly ["dashboard_tokens"];
|
|
|
23
36
|
export declare const SLOTLOCK_DASHBOARD_FUNCTIONS: readonly ["slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)", "slotlock.dashboard_session_ended(bytea, timestamptz)"];
|
|
24
37
|
/** Live form records one person may leave in the dashboard's records (12 hours of forms). */
|
|
25
38
|
export declare const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1000;
|
|
39
|
+
/** The table that records which release migrated the schema (SLOTLOCK_SCHEMA_VERSION). */
|
|
40
|
+
export declare const SLOTLOCK_SCHEMA_TABLES: readonly ["schema_version"];
|
|
41
|
+
/** The SECURITY DEFINER function through which a server reads the version it is serving. */
|
|
42
|
+
export declare const SLOTLOCK_SCHEMA_FUNCTIONS: readonly ["slotlock.installed_schema_version()"];
|
|
43
|
+
/** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
|
|
44
|
+
export declare const SLOTLOCK_TENANT_ADMIN_TABLES: readonly ["tenant_suspensions"];
|
|
45
|
+
/**
|
|
46
|
+
* The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
|
|
47
|
+
* tenant, by signature.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SLOTLOCK_TENANT_ADMIN_FUNCTIONS: readonly ["slotlock.tenant_suspended(text)", "slotlock.erase_tenant(text)"];
|
|
50
|
+
/**
|
|
51
|
+
* Functions only the deployment role runs: suspending and lifting a suspension. No other role is
|
|
52
|
+
* granted them, so a serving role cannot lift a suspension.
|
|
53
|
+
*/
|
|
54
|
+
export declare const SLOTLOCK_OPERATOR_FUNCTIONS: readonly ["slotlock.suspend_tenant(text, text)", "slotlock.unsuspend_tenant(text)"];
|
|
26
55
|
/** Every SECURITY DEFINER function the serving role may execute, by signature. */
|
|
27
|
-
export declare const SLOTLOCK_DEFINER_FUNCTIONS: readonly ("slotlock.authenticate_api_key(bytea)" | "slotlock.create_api_key(text, text, text, bytea, text[], timestamptz, text)" | "slotlock.dashboard_session_ended(bytea, timestamptz)" | "slotlock.erase_api_keys(text)" | "slotlock.list_api_keys(text)" | "slotlock.revoke_api_key(text, uuid)" | "slotlock.rotate_api_key(text, uuid, text, bytea)" | "slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)")[];
|
|
56
|
+
export declare const SLOTLOCK_DEFINER_FUNCTIONS: readonly ("slotlock.authenticate_api_key(bytea)" | "slotlock.create_api_key(text, text, text, bytea, text[], timestamptz, text)" | "slotlock.dashboard_session_ended(bytea, timestamptz)" | "slotlock.erase_api_keys(text)" | "slotlock.erase_tenant(text)" | "slotlock.installed_schema_version()" | "slotlock.list_api_keys(text)" | "slotlock.revoke_api_key(text, uuid)" | "slotlock.rotate_api_key(text, uuid, text, bytea)" | "slotlock.tenant_suspended(text)" | "slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)")[];
|
|
28
57
|
/** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
|
|
29
|
-
export declare const SLOTLOCK_DEFINER_TABLES: readonly ("api_key_retired_digests" | "api_keys" | "dashboard_tokens")[];
|
|
58
|
+
export declare const SLOTLOCK_DEFINER_TABLES: readonly ("api_key_retired_digests" | "api_keys" | "dashboard_tokens" | "schema_version" | "tenant_suspensions")[];
|
|
59
|
+
/**
|
|
60
|
+
* The advisory lock every server transaction for a tenant holds shared (store.ts withTenant), and
|
|
61
|
+
* suspension and erasure take exclusively, keyed by this text's hashtextextended(…, 0).
|
|
62
|
+
*/
|
|
63
|
+
export declare function slotlockTenantLockKey(tenantRef: string): string;
|
|
64
|
+
/** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
|
|
65
|
+
export declare const SLOTLOCK_VALIDATION_DDL: string;
|
|
66
|
+
/** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
|
|
30
67
|
export declare const SLOTLOCK_CORE_DDL: string;
|
|
68
|
+
/**
|
|
69
|
+
* SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
|
|
70
|
+
* commits (SLOTLOCK_VALIDATION_DDL).
|
|
71
|
+
*/
|
|
72
|
+
export declare const SLOTLOCK_STRUCTURE_DDL: string;
|
|
31
73
|
export declare const SLOTLOCK_TENANT_CONTEXT_SETTING = "slotlock.tenant_ref";
|
|
32
74
|
/**
|
|
33
75
|
* Every table the store reads or writes at runtime, each under forced tenant RLS.
|
|
34
76
|
* `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
|
|
35
77
|
* deployment-only and deliberately absent.
|
|
36
78
|
*/
|
|
37
|
-
export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage"];
|
|
79
|
+
export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands"];
|
|
38
80
|
/**
|
|
39
81
|
* Build the grants for the application role: USAGE on the `slotlock` schema, DML on the store's
|
|
40
82
|
* tables and EXECUTE on the API key and dashboard functions. Nothing else: no ownership, DDL,
|