slotlock 0.6.2 → 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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  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 +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
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
@@ -4,11 +4,10 @@ export type SlotlockAssistantProvider = 'anthropic' | 'openai';
4
4
  /** What the assistant does with each agent operation: run it, propose it, or never offer it. */
5
5
  export type SlotlockAssistantToolUse = 'read' | 'propose' | 'excluded';
6
6
  /**
7
- * The tools the model is offered, by agent operation. A record over every operation, so one a later
8
- * release adds does not compile until it is placed here. Reads run when the model calls them;
9
- * every write is only proposed, and runs when the member approves it. API keys, webhooks, booking
10
- * rules, pools, erasure and coverage are application operations, never agent ones, so no entry can
11
- * offer them.
7
+ * The tools the model is offered, by agent operation. A record over every operation, so a new one
8
+ * does not compile until placed here. Reads run when called; writes are only proposed and run on
9
+ * approval. API keys, webhooks, booking rules, pools, erasure and coverage are application
10
+ * operations, never agent ones, so none can be offered.
12
11
  */
13
12
  export declare const SLOTLOCK_ASSISTANT_TOOL_USE: Readonly<Record<SlotlockAgentOperation, SlotlockAssistantToolUse>>;
14
13
  /** Model calls one message may make before the assistant stops and says so. */
@@ -16,20 +15,17 @@ export declare const SLOTLOCK_ASSISTANT_MAX_STEPS = 8;
16
15
  /** Characters of one message from a member. */
17
16
  export declare const SLOTLOCK_ASSISTANT_MAX_MESSAGE = 2000;
18
17
  /**
19
- * UTF-8 bytes of the stored conversation as JSON, past which the oldest exchanges go first.
20
- * PostgreSQL's jsonb text adds at most one space per `:` or `,`, so at most half again: under the
21
- * table's 262,144-byte CHECK on `messages::text` whatever the text.
18
+ * UTF-8 bytes of the stored conversation as JSON. jsonb text adds at most one space per `:` or `,`,
19
+ * so this stays under the table's 262,144-byte CHECK on `messages::text`.
22
20
  */
23
21
  export declare const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160000;
24
22
  /** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
25
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;
26
26
  /** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
27
27
  export declare const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
28
- /**
29
- * The Messages API version this sends: the one @anthropic-ai/sdk 1.3.0 (2026-10-23) sends, read
30
- * from its source on 2026-10-28. Top-level `system`, `tools[].input_schema`, `tool_use` and
31
- * `tool_result` blocks are as before in it.
32
- */
28
+ /** The Messages API version @anthropic-ai/sdk 1.3.0 sends (read from its source 2026-10-28). */
33
29
  export declare const SLOTLOCK_ANTHROPIC_VERSION = "2026-10-21";
34
30
  /** A model as the assistant calls it, with the key opened. */
35
31
  export interface SlotlockAssistantModel {
@@ -93,6 +89,12 @@ export interface SlotlockAssistantStore {
93
89
  saved: boolean;
94
90
  }>;
95
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>;
96
98
  getConversation(tenantRef: string, memberRef: string): Promise<SlotlockAssistantConversationRecord | null>;
97
99
  /**
98
100
  * Replace a member's messages; keep the stored actions `keep` names, as they stand, and add the
@@ -112,14 +114,22 @@ export declare function createSlotlockAssistantStore(store: SlotlockStore): Slot
112
114
  export declare function createSlotlockMemoryAssistantStore(now?: () => number): SlotlockAssistantStore;
113
115
  /** The key sealed for one tenant: `v1.<iv>.<ciphertext and tag>`, base64url. */
114
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>;
115
126
  /** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
116
127
  export declare function openSlotlockAssistantKey(secret: string, tenantRef: string, sealed: string): Promise<string | null>;
117
128
  /**
118
- * A provider base URL as stored: https, or http on localhost or 127.0.0.1 (a model on the server's
119
- * own machine); no credentials, query or fragment; no trailing slash. Unless `privateUrls`, a URL
120
- * whose host is `localhost` or a loopback, private, link-local, shared (CGNAT), unique-local or
121
- * metadata address (the webhooks' isPrivateAddress) is refused as `private`; a name that resolves
122
- * to one is refused when it is resolved (checkSlotlockAssistantHost, and at each connection).
129
+ * A provider base URL as stored: https, or http on localhost or 127.0.0.1; no credentials, query,
130
+ * fragment or trailing slash. Unless `privateUrls`, a host that is `localhost` or a private-range
131
+ * IP literal is refused as `private`; a name resolving to one is refused when resolved
132
+ * (checkSlotlockAssistantHost, and at each connection).
123
133
  */
124
134
  export declare function readSlotlockAssistantBaseUrl(value: string, options?: {
125
135
  privateUrls?: boolean;
@@ -131,10 +141,9 @@ export declare function readSlotlockAssistantBaseUrl(value: string, options?: {
131
141
  reason: 'invalid' | 'private';
132
142
  };
133
143
  /**
134
- * Whether a base URL's host resolves only to public addresses: `resolve` lists them (dns.lookup's,
135
- * on Node.js). True when `privateUrls`, or when the host is an IP literal readSlotlockAssistantBaseUrl
136
- * already checked; false when any address is private. A name that does not resolve is not refused
137
- * here: the connection fails later, plainly.
144
+ * Whether a base URL's host resolves only to public addresses (`resolve`: dns.lookup on Node.js).
145
+ * True with `privateUrls` or an IP literal already checked; a name that does not resolve is not
146
+ * refused here: the connection fails later.
138
147
  */
139
148
  export declare function checkSlotlockAssistantHost(baseUrl: string, options: {
140
149
  privateUrls?: boolean;
@@ -185,6 +194,29 @@ interface ModelRequest {
185
194
  }
186
195
  /** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
187
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[]>;
188
220
  /**
189
221
  * The connection test: one request offering one read tool the model must call. True when it
190
222
  * answers with exactly one call of that tool with object arguments.
@@ -239,14 +271,13 @@ export interface SlotlockAssistantTurnResult {
239
271
  */
240
272
  export declare function runSlotlockAssistantTurn(options: SlotlockAssistantTurnOptions): Promise<SlotlockAssistantTurnResult>;
241
273
  /**
242
- * The conversation within its bounds: at most MAX_ENTRIES entries and MAX_STORED_CHARACTERS, the
243
- * oldest exchanges dropped whole, so it always starts at a member's message (a tool result is never
244
- * kept without the call it answers).
274
+ * The conversation within MAX_ENTRIES and the stored-bytes bound, oldest exchanges dropped whole,
275
+ * so it always starts at a member's message (a tool result is never kept without its call).
245
276
  */
246
277
  export declare function boundSlotlockAssistantEntries(entries: readonly SlotlockAssistantEntry[]): SlotlockAssistantEntry[];
247
278
  /**
248
- * The actions a conversation keeps: those its kept entries show, the oldest settled ones dropped
249
- * first past MAX_ACTIONS_BYTES (a waiting one is kept: SLOTLOCK_ASSISTANT_MAX_PENDING bounds them).
279
+ * The actions a conversation keeps: those its entries show, oldest settled ones dropped first past
280
+ * MAX_ACTIONS_BYTES (waiting ones are bounded by SLOTLOCK_ASSISTANT_MAX_PENDING).
250
281
  */
251
282
  export declare function pruneSlotlockAssistantActions(entries: readonly SlotlockAssistantEntry[], actions: Readonly<Record<string, Record<string, unknown>>>): Record<string, Record<string, unknown>>;
252
283
  /** How many of a conversation's actions wait for the member. */