slotlock 0.6.3 → 0.7.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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
package/approvals.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { Sql, TransactionSql } from 'postgres';
2
+ import type { SlotlockApproval, SlotlockApprovalKind, SlotlockApprovalPolicy } from './types.js';
3
+ type StoreSql = Sql | TransactionSql;
4
+ /** The longest a policy may let a write wait for a person: 30 days. */
5
+ export declare const SLOTLOCK_MAX_APPROVAL_MINUTES: number;
6
+ /** The wait a policy that names none gives a person: a day. */
7
+ export declare const SLOTLOCK_DEFAULT_APPROVAL_MINUTES: number;
8
+ /** The policy reference a key whose bookings all wait for a person is held by (RFC 0004 A5). */
9
+ export declare const SLOTLOCK_KEY_APPROVAL_POLICY = "api-key";
10
+ /** Policies one tenant may have. */
11
+ export declare const SLOTLOCK_MAX_APPROVAL_POLICIES = 50;
12
+ export declare const SLOTLOCK_APPROVAL_KINDS: readonly SlotlockApprovalKind[];
13
+ export declare class SlotlockApprovalPolicyError extends Error {
14
+ readonly code: 'invalid_approval_policy';
15
+ constructor(message: string);
16
+ }
17
+ export interface PutApprovalPolicyParams {
18
+ tenantRef: string;
19
+ ref: string;
20
+ name: string;
21
+ appliesTo?: SlotlockApprovalPolicy['appliesTo'];
22
+ conditions?: SlotlockApprovalPolicy['conditions'];
23
+ outcome: SlotlockApprovalPolicy['outcome'];
24
+ approvalTtlMinutes?: number;
25
+ leadMinutes?: number;
26
+ }
27
+ /** Create or replace a policy by its reference. Throws `invalid_approval_policy`; at most 50. */
28
+ export declare function putApprovalPolicy(sql: StoreSql, params: PutApprovalPolicyParams): Promise<SlotlockApprovalPolicy | null>;
29
+ export declare function listApprovalPolicies(sql: StoreSql, tenantRef: string): Promise<SlotlockApprovalPolicy[]>;
30
+ export declare function deleteApprovalPolicy(sql: StoreSql, tenantRef: string, ref: string): Promise<boolean>;
31
+ /**
32
+ * The tenant's policies that apply to any of these resources: by id, pool, location (a resource in
33
+ * a location inside one named counts) or group, or every resource when a policy names none.
34
+ */
35
+ export declare function approvalPoliciesFor(sql: StoreSql, tenantRef: string, resourceIds: readonly string[]): Promise<SlotlockApprovalPolicy[]>;
36
+ interface ApprovalRow {
37
+ id: string;
38
+ policy_ref: string;
39
+ status: SlotlockApproval['status'];
40
+ operation: SlotlockApproval['operation'];
41
+ request: Record<string, unknown>;
42
+ requester: Record<string, unknown>;
43
+ hold_ids: string[];
44
+ resource_ids: string[];
45
+ starts_at: Date;
46
+ ends_at: Date;
47
+ expires_at: Date;
48
+ decided_by: string | null;
49
+ decided_at: Date | null;
50
+ reason: string | null;
51
+ created_at: Date;
52
+ }
53
+ export declare const APPROVAL_COLUMNS = "id, policy_ref, status, operation, request, requester, hold_ids, resource_ids, starts_at, ends_at, expires_at, decided_by, decided_at, reason, created_at";
54
+ export declare function toApproval(row: ApprovalRow): SlotlockApproval;
55
+ export type ApprovalRowWithRequester = ApprovalRow;
56
+ export declare function listApprovals(sql: StoreSql, params: {
57
+ tenantRef: string;
58
+ status?: SlotlockApproval['status'];
59
+ limit: number;
60
+ }): Promise<SlotlockApproval[]>;
61
+ /** One approval with whom it runs as, for a grant. */
62
+ export declare function getApproval(sql: StoreSql, tenantRef: string, id: string): Promise<(SlotlockApproval & {
63
+ requester: Record<string, unknown>;
64
+ }) | null>;
65
+ /** The approval a requester's retry made, by its request key. */
66
+ export declare function approvalByRequestKey(sql: StoreSql, tenantRef: string, requestKey: string): Promise<SlotlockApproval | null>;
67
+ declare function noteApproval(sql: StoreSql, tenantRef: string, type: string, approval: SlotlockApproval): Promise<void>;
68
+ export { noteApproval as recordApprovalChange };
69
+ type Decided = {
70
+ ok: true;
71
+ approval: SlotlockApproval;
72
+ } | {
73
+ ok: false;
74
+ code: 'approval_not_found' | 'approval_not_pending' | 'approval_expired';
75
+ };
76
+ /** Lock a pending, unexpired approval and let its holds go: the first half of a grant. */
77
+ export declare function beginApprovalGrant(sql: StoreSql, tenantRef: string, id: string): Promise<Decided>;
78
+ /** Mark a granted approval, in the transaction that made its write: the second half. */
79
+ export declare function finishApprovalGrant(sql: StoreSql, tenantRef: string, id: string, decidedBy: string): Promise<SlotlockApproval>;
80
+ /** Decline a pending approval: its holds go, and the feed says so. */
81
+ export declare function declineApproval(sql: StoreSql, params: {
82
+ tenantRef: string;
83
+ id: string;
84
+ decidedBy: string;
85
+ reason?: string;
86
+ }): Promise<Decided>;
87
+ /** Maintenance: pending approvals past their wait expire, their holds go. How many did. */
88
+ export declare function expireApprovals(sql: StoreSql, tenantRef: string, limit: number): Promise<number>;
package/approvals.js ADDED
@@ -0,0 +1,293 @@
1
+ /** The longest a policy may let a write wait for a person: 30 days. */
2
+ export const SLOTLOCK_MAX_APPROVAL_MINUTES = 30 * 24 * 60;
3
+ /** The wait a policy that names none gives a person: a day. */
4
+ export const SLOTLOCK_DEFAULT_APPROVAL_MINUTES = 24 * 60;
5
+ /** The policy reference a key whose bookings all wait for a person is held by (RFC 0004 A5). */
6
+ export const SLOTLOCK_KEY_APPROVAL_POLICY = 'api-key';
7
+ /** Policies one tenant may have. */
8
+ export const SLOTLOCK_MAX_APPROVAL_POLICIES = 50;
9
+ export const SLOTLOCK_APPROVAL_KINDS = Object.freeze(['single', 'pool', 'bundle', 'move']);
10
+ const POLICY_REF = /^[a-z0-9][a-z0-9_-]{0,62}$/;
11
+ const GROUPING_REF = /^[a-z0-9][a-z0-9_.-]{0,99}$/;
12
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ /** References of each kind a policy's applies_to may name. */
14
+ const MAX_APPLIES_TO = 50;
15
+ export class SlotlockApprovalPolicyError extends Error {
16
+ code = 'invalid_approval_policy';
17
+ constructor(message) {
18
+ super(`Slotlock approval policy: ${message}`);
19
+ }
20
+ }
21
+ const POLICY_COLUMNS = 'ref, name, applies_to, conditions, outcome, approval_ttl_minutes, lead_minutes, created_at, updated_at';
22
+ function toPolicy(row) {
23
+ const applies = row.applies_to;
24
+ const conditions = row.conditions;
25
+ return {
26
+ ref: row.ref,
27
+ name: row.name,
28
+ appliesTo: {
29
+ ...(applies.resource_ids ? { resourceIds: applies.resource_ids } : {}),
30
+ ...(applies.pools ? { pools: applies.pools } : {}),
31
+ ...(applies.locations ? { locations: applies.locations } : {}),
32
+ ...(applies.groups ? { groups: applies.groups } : {}),
33
+ },
34
+ conditions: {
35
+ ...(conditions.longer_than_minutes !== undefined ? { longerThanMinutes: conditions.longer_than_minutes } : {}),
36
+ ...(conditions.outside_hours !== undefined ? { outsideHours: conditions.outside_hours } : {}),
37
+ ...(conditions.kinds ? { kinds: conditions.kinds } : {}),
38
+ },
39
+ outcome: row.outcome,
40
+ approvalTtlMinutes: row.approval_ttl_minutes,
41
+ leadMinutes: row.lead_minutes,
42
+ createdAt: row.created_at,
43
+ updatedAt: row.updated_at,
44
+ };
45
+ }
46
+ function refList(values, pattern, what) {
47
+ if (values === undefined)
48
+ return undefined;
49
+ if (!Array.isArray(values) || values.length > MAX_APPLIES_TO || values.some((value) => !pattern.test(value))) {
50
+ throw new SlotlockApprovalPolicyError(`${what} must be at most ${MAX_APPLIES_TO} references`);
51
+ }
52
+ return [...new Set(values)].sort();
53
+ }
54
+ /** A policy's stored shape, checked: applies_to and conditions in snake_case JSON. */
55
+ function storedPolicy(params) {
56
+ if (!POLICY_REF.test(params.ref))
57
+ throw new SlotlockApprovalPolicyError('ref must be 1-63 of a-z, 0-9, _ and -');
58
+ if (params.ref === SLOTLOCK_KEY_APPROVAL_POLICY) {
59
+ throw new SlotlockApprovalPolicyError(`ref ${SLOTLOCK_KEY_APPROVAL_POLICY} is the one a key's own approval uses`);
60
+ }
61
+ const name = params.name.trim();
62
+ if (name.length < 1 || name.length > 200)
63
+ throw new SlotlockApprovalPolicyError('name must be 1-200 characters');
64
+ if (params.outcome !== 'require_approval' && params.outcome !== 'refuse') {
65
+ throw new SlotlockApprovalPolicyError('outcome must be require_approval or refuse');
66
+ }
67
+ const ttl = params.approvalTtlMinutes ?? SLOTLOCK_DEFAULT_APPROVAL_MINUTES;
68
+ const lead = params.leadMinutes ?? 0;
69
+ if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > SLOTLOCK_MAX_APPROVAL_MINUTES) {
70
+ throw new SlotlockApprovalPolicyError(`approval_ttl_minutes must be 1-${SLOTLOCK_MAX_APPROVAL_MINUTES}`);
71
+ }
72
+ if (!Number.isSafeInteger(lead) || lead < 0 || lead > SLOTLOCK_MAX_APPROVAL_MINUTES) {
73
+ throw new SlotlockApprovalPolicyError(`lead_minutes must be 0-${SLOTLOCK_MAX_APPROVAL_MINUTES}`);
74
+ }
75
+ const applies = params.appliesTo ?? {};
76
+ const resourceIds = refList(applies.resourceIds, UUID, 'resource_ids');
77
+ const appliesTo = {
78
+ ...(resourceIds ? { resource_ids: resourceIds.map((id) => id.toLowerCase()) } : {}),
79
+ ...(applies.pools ? { pools: refList(applies.pools, GROUPING_REF, 'pools') } : {}),
80
+ ...(applies.locations ? { locations: refList(applies.locations, GROUPING_REF, 'locations') } : {}),
81
+ ...(applies.groups ? { groups: refList(applies.groups, GROUPING_REF, 'groups') } : {}),
82
+ };
83
+ const given = params.conditions ?? {};
84
+ if (given.longerThanMinutes !== undefined &&
85
+ (!Number.isSafeInteger(given.longerThanMinutes) || given.longerThanMinutes < 0 || given.longerThanMinutes > 5_270_400)) {
86
+ throw new SlotlockApprovalPolicyError('longer_than_minutes must be 0-5,270,400');
87
+ }
88
+ if (given.outsideHours !== undefined && typeof given.outsideHours !== 'boolean') {
89
+ throw new SlotlockApprovalPolicyError('outside_hours must be true or false');
90
+ }
91
+ if (given.kinds !== undefined &&
92
+ (!Array.isArray(given.kinds) ||
93
+ given.kinds.length === 0 ||
94
+ given.kinds.some((kind) => !SLOTLOCK_APPROVAL_KINDS.includes(kind)))) {
95
+ throw new SlotlockApprovalPolicyError(`kinds must name some of ${SLOTLOCK_APPROVAL_KINDS.join(', ')}`);
96
+ }
97
+ const conditions = {
98
+ ...(given.longerThanMinutes !== undefined ? { longer_than_minutes: given.longerThanMinutes } : {}),
99
+ ...(given.outsideHours !== undefined ? { outside_hours: given.outsideHours } : {}),
100
+ ...(given.kinds !== undefined ? { kinds: [...new Set(given.kinds)].sort() } : {}),
101
+ };
102
+ return { name, appliesTo, conditions, ttl, lead };
103
+ }
104
+ /** Create or replace a policy by its reference. Throws `invalid_approval_policy`; at most 50. */
105
+ export async function putApprovalPolicy(sql, params) {
106
+ const stored = storedPolicy(params);
107
+ const [row] = await sql `
108
+ INSERT INTO slotlock.approval_policies (
109
+ tenant_ref, ref, name, applies_to, conditions, outcome, approval_ttl_minutes, lead_minutes
110
+ )
111
+ SELECT ${params.tenantRef}, ${params.ref}, ${stored.name}, ${sql.json(stored.appliesTo)},
112
+ ${sql.json(stored.conditions)}, ${params.outcome}, ${stored.ttl}, ${stored.lead}
113
+ WHERE EXISTS (SELECT 1 FROM slotlock.approval_policies WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref})
114
+ OR (SELECT count(*) FROM slotlock.approval_policies WHERE tenant_ref = ${params.tenantRef})
115
+ < ${SLOTLOCK_MAX_APPROVAL_POLICIES}
116
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
117
+ SET name = EXCLUDED.name, applies_to = EXCLUDED.applies_to, conditions = EXCLUDED.conditions,
118
+ outcome = EXCLUDED.outcome, approval_ttl_minutes = EXCLUDED.approval_ttl_minutes,
119
+ lead_minutes = EXCLUDED.lead_minutes, updated_at = statement_timestamp()
120
+ RETURNING ${sql.unsafe(POLICY_COLUMNS)}`;
121
+ return row ? toPolicy(row) : null;
122
+ }
123
+ export async function listApprovalPolicies(sql, tenantRef) {
124
+ const rows = await sql `
125
+ SELECT ${sql.unsafe(POLICY_COLUMNS)} FROM slotlock.approval_policies
126
+ WHERE tenant_ref = ${tenantRef} ORDER BY ref`;
127
+ return rows.map(toPolicy);
128
+ }
129
+ export async function deleteApprovalPolicy(sql, tenantRef, ref) {
130
+ if (!POLICY_REF.test(ref))
131
+ return false;
132
+ const rows = await sql `DELETE FROM slotlock.approval_policies WHERE tenant_ref = ${tenantRef} AND ref = ${ref} RETURNING ref`;
133
+ return rows.length > 0;
134
+ }
135
+ /**
136
+ * The tenant's policies that apply to any of these resources: by id, pool, location (a resource in
137
+ * a location inside one named counts) or group, or every resource when a policy names none.
138
+ */
139
+ export async function approvalPoliciesFor(sql, tenantRef, resourceIds) {
140
+ const ids = resourceIds.filter((id) => UUID.test(id));
141
+ if (ids.length === 0)
142
+ return [];
143
+ const rows = await sql `
144
+ SELECT ${sql.unsafe(POLICY_COLUMNS.split(', ').map((column) => `policy.${column}`).join(', '))}
145
+ FROM slotlock.approval_policies policy
146
+ WHERE policy.tenant_ref = ${tenantRef}
147
+ AND EXISTS (
148
+ SELECT 1 FROM slotlock.resources resource
149
+ WHERE resource.tenant_ref = ${tenantRef} AND resource.id = ANY (${ids}::uuid[])
150
+ AND (policy.applies_to = '{}'::jsonb
151
+ OR resource.id::text IN (SELECT pg_catalog.jsonb_array_elements_text(policy.applies_to -> 'resource_ids'))
152
+ OR resource.pool_ref IN (SELECT pg_catalog.jsonb_array_elements_text(policy.applies_to -> 'pools'))
153
+ OR (resource.location_ref IS NOT NULL
154
+ AND slotlock.location_path(resource.tenant_ref, resource.location_ref)
155
+ && ARRAY(SELECT pg_catalog.jsonb_array_elements_text(policy.applies_to -> 'locations')))
156
+ OR EXISTS (
157
+ SELECT 1 FROM slotlock.resource_group_members member
158
+ WHERE member.tenant_ref = resource.tenant_ref AND member.resource_id = resource.id
159
+ AND member.group_ref IN (SELECT pg_catalog.jsonb_array_elements_text(policy.applies_to -> 'groups')))))
160
+ ORDER BY policy.ref`;
161
+ return rows.map(toPolicy);
162
+ }
163
+ export const APPROVAL_COLUMNS = 'id, policy_ref, status, operation, request, requester, hold_ids, resource_ids, starts_at, ends_at, expires_at, decided_by, decided_at, reason, created_at';
164
+ export function toApproval(row) {
165
+ return {
166
+ id: row.id,
167
+ policyRef: row.policy_ref,
168
+ status: row.status,
169
+ operation: row.operation,
170
+ request: row.request,
171
+ requestedBy: typeof row.requester.subject === 'string'
172
+ ? row.requester.subject.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 200) || null
173
+ : null,
174
+ holdIds: row.hold_ids,
175
+ resourceIds: row.resource_ids,
176
+ start: row.starts_at,
177
+ end: row.ends_at,
178
+ expiresAt: row.expires_at,
179
+ decidedBy: row.decided_by,
180
+ decidedAt: row.decided_at,
181
+ reason: row.reason,
182
+ createdAt: row.created_at,
183
+ };
184
+ }
185
+ export async function listApprovals(sql, params) {
186
+ const rows = await sql `
187
+ SELECT ${sql.unsafe(APPROVAL_COLUMNS)} FROM slotlock.approvals
188
+ WHERE tenant_ref = ${params.tenantRef}
189
+ AND (${params.status ?? null}::text IS NULL OR status = ${params.status ?? null})
190
+ ORDER BY created_at DESC, id
191
+ LIMIT ${params.limit}`;
192
+ return rows.map(toApproval);
193
+ }
194
+ /** One approval with whom it runs as, for a grant. */
195
+ export async function getApproval(sql, tenantRef, id) {
196
+ if (!UUID.test(id))
197
+ return null;
198
+ const [row] = await sql `
199
+ SELECT ${sql.unsafe(APPROVAL_COLUMNS)} FROM slotlock.approvals WHERE tenant_ref = ${tenantRef} AND id = ${id}`;
200
+ return row ? { ...toApproval(row), requester: row.requester } : null;
201
+ }
202
+ /** The approval a requester's retry made, by its request key. */
203
+ export async function approvalByRequestKey(sql, tenantRef, requestKey) {
204
+ const [row] = await sql `
205
+ SELECT ${sql.unsafe(APPROVAL_COLUMNS)} FROM slotlock.approvals
206
+ WHERE tenant_ref = ${tenantRef} AND request_key = ${requestKey}`;
207
+ return row ? toApproval(row) : null;
208
+ }
209
+ async function noteApproval(sql, tenantRef, type, approval) {
210
+ await sql `
211
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
212
+ VALUES (${tenantRef}, ${approval.resourceIds[0] ?? null}, ${type}, ${approval.id},
213
+ ${sql.json({
214
+ id: approval.id,
215
+ policy: approval.policyRef,
216
+ operation: approval.operation,
217
+ status: approval.status,
218
+ resource_ids: approval.resourceIds,
219
+ start: approval.start.toISOString(),
220
+ end: approval.end.toISOString(),
221
+ expires_at: approval.expiresAt.toISOString(),
222
+ ...(approval.reason ? { reason: approval.reason } : {}),
223
+ })},
224
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
225
+ }
226
+ export { noteApproval as recordApprovalChange };
227
+ /** Lock a pending, unexpired approval and let its holds go: the first half of a grant. */
228
+ export async function beginApprovalGrant(sql, tenantRef, id) {
229
+ if (!UUID.test(id))
230
+ return { ok: false, code: 'approval_not_found' };
231
+ const [row] = await sql `
232
+ SELECT ${sql.unsafe(APPROVAL_COLUMNS)}, expires_at > statement_timestamp() AS live
233
+ FROM slotlock.approvals WHERE tenant_ref = ${tenantRef} AND id = ${id} FOR UPDATE`;
234
+ if (!row)
235
+ return { ok: false, code: 'approval_not_found' };
236
+ if (row.status !== 'pending')
237
+ return { ok: false, code: 'approval_not_pending' };
238
+ if (!row.live)
239
+ return { ok: false, code: 'approval_expired' };
240
+ await sql `DELETE FROM slotlock.reservations WHERE status = 'held' AND holder_ref = ${`approval:${id}`}`;
241
+ return { ok: true, approval: toApproval(row) };
242
+ }
243
+ /** Mark a granted approval, in the transaction that made its write: the second half. */
244
+ export async function finishApprovalGrant(sql, tenantRef, id, decidedBy) {
245
+ const [row] = await sql `
246
+ UPDATE slotlock.approvals
247
+ SET status = 'granted', decided_by = ${decidedBy}, decided_at = statement_timestamp()
248
+ WHERE tenant_ref = ${tenantRef} AND id = ${id} AND status = 'pending'
249
+ RETURNING ${sql.unsafe(APPROVAL_COLUMNS)}`;
250
+ if (!row)
251
+ throw new Error('slotlock: approval grant found no pending approval');
252
+ const approval = toApproval(row);
253
+ await noteApproval(sql, tenantRef, 'approval.granted', approval);
254
+ return approval;
255
+ }
256
+ /** Decline a pending approval: its holds go, and the feed says so. */
257
+ export async function declineApproval(sql, params) {
258
+ if (!UUID.test(params.id))
259
+ return { ok: false, code: 'approval_not_found' };
260
+ const [row] = await sql `
261
+ SELECT ${sql.unsafe(APPROVAL_COLUMNS)} FROM slotlock.approvals
262
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id} FOR UPDATE`;
263
+ if (!row)
264
+ return { ok: false, code: 'approval_not_found' };
265
+ if (row.status !== 'pending')
266
+ return { ok: false, code: 'approval_not_pending' };
267
+ await sql `DELETE FROM slotlock.reservations WHERE status = 'held' AND holder_ref = ${`approval:${params.id}`}`;
268
+ const [declined] = await sql `
269
+ UPDATE slotlock.approvals
270
+ SET status = 'declined', decided_by = ${params.decidedBy}, decided_at = statement_timestamp(),
271
+ reason = ${params.reason ?? null}
272
+ WHERE id = ${params.id}
273
+ RETURNING ${sql.unsafe(APPROVAL_COLUMNS)}`;
274
+ const approval = toApproval(declined);
275
+ await noteApproval(sql, params.tenantRef, 'approval.declined', approval);
276
+ return { ok: true, approval };
277
+ }
278
+ /** Maintenance: pending approvals past their wait expire, their holds go. How many did. */
279
+ export async function expireApprovals(sql, tenantRef, limit) {
280
+ const rows = await sql `
281
+ UPDATE slotlock.approvals
282
+ SET status = 'expired', decided_by = 'slotlock:maintenance', decided_at = statement_timestamp()
283
+ WHERE id IN (
284
+ SELECT id FROM slotlock.approvals
285
+ WHERE tenant_ref = ${tenantRef} AND status = 'pending' AND expires_at <= statement_timestamp()
286
+ ORDER BY expires_at, id LIMIT ${limit} FOR UPDATE SKIP LOCKED)
287
+ RETURNING ${sql.unsafe(APPROVAL_COLUMNS)}`;
288
+ for (const row of rows) {
289
+ await sql `DELETE FROM slotlock.reservations WHERE status = 'held' AND holder_ref = ${`approval:${row.id}`}`;
290
+ await noteApproval(sql, tenantRef, 'approval.expired', toApproval(row));
291
+ }
292
+ return rows.length;
293
+ }
package/assistant.d.ts CHANGED
@@ -21,6 +21,8 @@ export declare const SLOTLOCK_ASSISTANT_MAX_MESSAGE = 2000;
21
21
  export declare const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160000;
22
22
  /** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
23
23
  export declare const SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES = 16000;
24
+ /** Previous secrets a deployment may keep while keys are sealed again under its current one. */
25
+ export declare const SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS = 3;
24
26
  /** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
25
27
  export declare const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
26
28
  /** The Messages API version @anthropic-ai/sdk 1.3.0 sends (read from its source 2026-10-28). */
@@ -87,6 +89,12 @@ export interface SlotlockAssistantStore {
87
89
  saved: boolean;
88
90
  }>;
89
91
  deleteSettings(tenantRef: string, actor?: string): Promise<boolean>;
92
+ /**
93
+ * Replace the sealed key `from` with `to` (the same key under the current secret) only while `from`
94
+ * is the one stored (store.ts resealAssistantKey). Without it, a key sealed under a previous secret
95
+ * still opens, and is opened that way each time.
96
+ */
97
+ resealKey?(tenantRef: string, from: string, to: string): Promise<boolean>;
90
98
  getConversation(tenantRef: string, memberRef: string): Promise<SlotlockAssistantConversationRecord | null>;
91
99
  /**
92
100
  * Replace a member's messages; keep the stored actions `keep` names, as they stand, and add the
@@ -106,6 +114,15 @@ export declare function createSlotlockAssistantStore(store: SlotlockStore): Slot
106
114
  export declare function createSlotlockMemoryAssistantStore(now?: () => number): SlotlockAssistantStore;
107
115
  /** The key sealed for one tenant: `v1.<iv>.<ciphertext and tag>`, base64url. */
108
116
  export declare function sealSlotlockAssistantKey(secret: string, tenantRef: string, apiKey: string): Promise<string>;
117
+ /**
118
+ * The key a seal holds, tried with each secret in turn (the current one first, then
119
+ * SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS), and which one opened it: 0 for the current secret. Null when
120
+ * none of them sealed it for this tenant.
121
+ */
122
+ export declare function openSlotlockAssistantKeyWithAny(secrets: readonly string[], tenantRef: string, sealed: string): Promise<{
123
+ apiKey: string;
124
+ secretIndex: number;
125
+ } | null>;
109
126
  /** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
110
127
  export declare function openSlotlockAssistantKey(secret: string, tenantRef: string, sealed: string): Promise<string | null>;
111
128
  /**
@@ -177,6 +194,29 @@ interface ModelRequest {
177
194
  }
178
195
  /** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
179
196
  export declare function callSlotlockAssistantModel(request: ModelRequest): Promise<SlotlockAssistantModelTurn>;
197
+ /**
198
+ * Providers the Settings form fills in (RFC 0003 A4): each one's API and the base URL it documents
199
+ * for it. Custom fills nothing, for any other OpenAI-compatible API. Choosing one saves nothing.
200
+ */
201
+ export declare const SLOTLOCK_ASSISTANT_PRESETS: readonly {
202
+ id: string;
203
+ name: string;
204
+ provider: SlotlockAssistantProvider;
205
+ baseUrl: string;
206
+ }[];
207
+ /**
208
+ * The models a provider offers (`GET /v1/models` on the Anthropic API, `GET /models` on an
209
+ * OpenAI-compatible one, listed under `data` or as a bare array), by id, sorted, for the Settings
210
+ * form's Model field. Throws
211
+ * SlotlockAssistantProviderError as a model call does, never with the key in its message.
212
+ */
213
+ export declare function listSlotlockAssistantModels(args: {
214
+ provider: SlotlockAssistantProvider;
215
+ baseUrl: string;
216
+ apiKey: string | null;
217
+ fetch: typeof fetch;
218
+ timeoutMs: number;
219
+ }): Promise<string[]>;
180
220
  /**
181
221
  * The connection test: one request offering one read tool the model must call. True when it
182
222
  * answers with exactly one call of that tool with object arguments.
package/assistant.js CHANGED
@@ -34,6 +34,9 @@ export const SLOTLOCK_ASSISTANT_TOOL_USE = Object.freeze({
34
34
  slotlock_create_event: 'propose',
35
35
  slotlock_update_event: 'propose',
36
36
  slotlock_delete_event: 'propose',
37
+ slotlock_confirm_slot: 'propose',
38
+ slotlock_reschedule: 'propose',
39
+ slotlock_cancel: 'propose',
37
40
  });
38
41
  for (const [operation, use] of Object.entries(SLOTLOCK_ASSISTANT_TOOL_USE)) {
39
42
  // A read the assistant runs freely must be one the agent server calls a read.
@@ -56,6 +59,8 @@ export const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160_000;
56
59
  const MAX_ACTIONS_BYTES = 80_000;
57
60
  /** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
58
61
  export const SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES = 16_000;
62
+ /** Previous secrets a deployment may keep while keys are sealed again under its current one. */
63
+ export const SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS = 3;
59
64
  /** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
60
65
  export const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
61
66
  /** Characters of one tool result the model is shown. */
@@ -80,6 +85,7 @@ export function createSlotlockAssistantStore(store) {
80
85
  ...(actor !== undefined ? { updatedBy: actor } : {}),
81
86
  }), by(actor)),
82
87
  deleteSettings: (tenantRef, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAssistantSettings({ tenantRef }), by(actor)),
88
+ resealKey: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.resealAssistantKey({ tenantRef, from, to }), by(undefined)),
83
89
  // Each read deletes the tenant's expired conversations first, for deployments whose maintenance
84
90
  // never meets the tenant (a Worker's).
85
91
  getConversation: (tenantRef, memberRef) => store.withTenant(tenantRef, async (tenant) => {
@@ -133,6 +139,13 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
133
139
  async deleteSettings(tenantRef) {
134
140
  return settings.delete(tenantRef);
135
141
  },
142
+ async resealKey(tenantRef, from, to) {
143
+ const stored = settings.get(tenantRef);
144
+ if (!stored || stored.sealedKey !== from || to.length === 0 || to === from)
145
+ return false;
146
+ settings.set(tenantRef, { ...stored, sealedKey: to });
147
+ return true;
148
+ },
136
149
  async getConversation(tenantRef, memberRef) {
137
150
  const found = live(tenantRef, memberRef);
138
151
  if (!found)
@@ -198,6 +211,19 @@ export async function sealSlotlockAssistantKey(secret, tenantRef, apiKey) {
198
211
  const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: boundTo(tenantRef) }, await sealingKey(secret), encoder.encode(apiKey));
199
212
  return `${SEAL_VERSION}.${Buffer.from(iv).toString('base64url')}.${Buffer.from(sealed).toString('base64url')}`;
200
213
  }
214
+ /**
215
+ * The key a seal holds, tried with each secret in turn (the current one first, then
216
+ * SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS), and which one opened it: 0 for the current secret. Null when
217
+ * none of them sealed it for this tenant.
218
+ */
219
+ export async function openSlotlockAssistantKeyWithAny(secrets, tenantRef, sealed) {
220
+ for (const [secretIndex, secret] of secrets.entries()) {
221
+ const apiKey = await openSlotlockAssistantKey(secret, tenantRef, sealed);
222
+ if (apiKey !== null)
223
+ return { apiKey, secretIndex };
224
+ }
225
+ return null;
226
+ }
201
227
  /** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
202
228
  export async function openSlotlockAssistantKey(secret, tenantRef, sealed) {
203
229
  const [version, iv, body, ...rest] = sealed.split('.');
@@ -547,6 +573,95 @@ export async function callSlotlockAssistantModel(request) {
547
573
  const turn = anthropic ? readAnthropic(body) : readOpenai(body);
548
574
  return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
549
575
  }
576
+ /**
577
+ * Providers the Settings form fills in (RFC 0003 A4): each one's API and the base URL it documents
578
+ * for it. Custom fills nothing, for any other OpenAI-compatible API. Choosing one saves nothing.
579
+ */
580
+ export const SLOTLOCK_ASSISTANT_PRESETS = Object.freeze([
581
+ { id: 'anthropic', name: 'Anthropic', provider: 'anthropic', baseUrl: 'https://api.anthropic.com' },
582
+ { id: 'openai', name: 'OpenAI', provider: 'openai', baseUrl: 'https://api.openai.com/v1' },
583
+ { id: 'openrouter', name: 'OpenRouter', provider: 'openai', baseUrl: 'https://openrouter.ai/api/v1' },
584
+ {
585
+ id: 'google',
586
+ name: 'Google',
587
+ provider: 'openai',
588
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
589
+ },
590
+ { id: 'mistral', name: 'Mistral', provider: 'openai', baseUrl: 'https://api.mistral.ai/v1' },
591
+ { id: 'groq', name: 'Groq', provider: 'openai', baseUrl: 'https://api.groq.com/openai/v1' },
592
+ { id: 'together', name: 'Together', provider: 'openai', baseUrl: 'https://api.together.xyz/v1' },
593
+ { id: 'custom', name: 'Custom', provider: 'openai', baseUrl: '' },
594
+ ]);
595
+ /** Models one list keeps: a provider may offer hundreds. */
596
+ const MAX_LISTED_MODELS = 500;
597
+ /**
598
+ * The models a provider offers (`GET /v1/models` on the Anthropic API, `GET /models` on an
599
+ * OpenAI-compatible one, listed under `data` or as a bare array), by id, sorted, for the Settings
600
+ * form's Model field. Throws
601
+ * SlotlockAssistantProviderError as a model call does, never with the key in its message.
602
+ */
603
+ export async function listSlotlockAssistantModels(args) {
604
+ const anthropic = args.provider === 'anthropic';
605
+ const headers = { accept: 'application/json', 'user-agent': 'slotlock-dashboard-assistant' };
606
+ if (anthropic) {
607
+ headers['anthropic-version'] = SLOTLOCK_ANTHROPIC_VERSION;
608
+ if (args.apiKey)
609
+ headers['x-api-key'] = args.apiKey;
610
+ }
611
+ else if (args.apiKey) {
612
+ headers.authorization = `Bearer ${args.apiKey}`;
613
+ }
614
+ const signal = AbortSignal.timeout(args.timeoutMs);
615
+ let response;
616
+ try {
617
+ response = await args.fetch(anthropic ? `${args.baseUrl}/v1/models?limit=1000` : `${args.baseUrl}/models`, {
618
+ method: 'GET',
619
+ headers,
620
+ // Never follow a redirect: it would carry the key elsewhere.
621
+ redirect: 'manual',
622
+ signal,
623
+ });
624
+ }
625
+ catch (error) {
626
+ const name = error?.name;
627
+ if (error?.code === 'private_address') {
628
+ throw new SlotlockAssistantProviderError('private_address');
629
+ }
630
+ throw new SlotlockAssistantProviderError(name === 'TimeoutError' || name === 'AbortError' || signal.aborted ? 'timeout' : 'unavailable');
631
+ }
632
+ if (!response.ok) {
633
+ await response.body?.cancel().catch(() => undefined);
634
+ const status = response.status;
635
+ throw new SlotlockAssistantProviderError(status === 401 || status === 403
636
+ ? 'refused_key'
637
+ : status === 404
638
+ ? 'not_found'
639
+ : status === 429
640
+ ? 'rate_limited'
641
+ : 'unavailable', status);
642
+ }
643
+ let body;
644
+ try {
645
+ body = JSON.parse(await readBounded(response));
646
+ }
647
+ catch (error) {
648
+ if (error instanceof SlotlockAssistantProviderError)
649
+ throw error;
650
+ throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
651
+ }
652
+ // `{ data: [...] }` as OpenAI and Anthropic answer, or the bare array some OpenAI-compatible
653
+ // providers (Together) answer instead.
654
+ const listed = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.data) ? body.data : null;
655
+ if (!listed)
656
+ throw new SlotlockAssistantProviderError('bad_answer');
657
+ const ids = new Set();
658
+ for (const entry of listed) {
659
+ const id = isRecord(entry) ? entry.id : undefined;
660
+ if (typeof id === 'string' && SLOTLOCK_ASSISTANT_MODEL_NAME.test(id))
661
+ ids.add(id);
662
+ }
663
+ return [...ids].sort().slice(0, MAX_LISTED_MODELS);
664
+ }
550
665
  /**
551
666
  * The connection test: one request offering one read tool the model must call. True when it
552
667
  * answers with exactly one call of that tool with object arguments.