slotlock 0.6.3 → 0.8.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 (76) hide show
  1. package/CHANGELOG.md +511 -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 +70 -1
  17. package/assistant.js +478 -50
  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-assistant-ui.d.ts +21 -0
  23. package/dashboard-assistant-ui.js +438 -0
  24. package/dashboard-icon.d.ts +1 -0
  25. package/dashboard-icon.js +5 -0
  26. package/dashboard-markdown.d.ts +2 -0
  27. package/dashboard-markdown.js +157 -0
  28. package/dashboard-settings-ui.d.ts +1 -0
  29. package/dashboard-settings-ui.js +78 -0
  30. package/dashboard.d.ts +227 -7
  31. package/dashboard.js +2313 -247
  32. package/ddl.d.ts +51 -12
  33. package/ddl.js +2566 -54
  34. package/event-feed.d.ts +30 -4
  35. package/event-feed.js +171 -19
  36. package/index.d.ts +15 -4
  37. package/index.js +8 -2
  38. package/maintenance.d.ts +10 -0
  39. package/maintenance.js +16 -2
  40. package/metrics.d.ts +41 -0
  41. package/metrics.js +22 -0
  42. package/openapi.json +14675 -5957
  43. package/otel.d.ts +86 -0
  44. package/otel.js +273 -0
  45. package/overage.d.ts +33 -0
  46. package/overage.js +115 -0
  47. package/package.json +1 -1
  48. package/partner-billing.d.ts +81 -0
  49. package/partner-billing.js +333 -0
  50. package/partner-names.d.ts +8 -0
  51. package/partner-names.js +10 -0
  52. package/partners.d.ts +147 -0
  53. package/partners.js +375 -0
  54. package/plans.d.ts +92 -0
  55. package/plans.js +115 -0
  56. package/quotas.d.ts +108 -0
  57. package/quotas.js +258 -0
  58. package/rate-limit.d.ts +21 -1
  59. package/rate-limit.js +82 -15
  60. package/resource-profile.d.ts +135 -0
  61. package/resource-profile.js +263 -0
  62. package/rest-api.d.ts +2 -0
  63. package/rest-api.js +68 -3
  64. package/sandbox.d.ts +70 -0
  65. package/sandbox.js +295 -0
  66. package/self-host.d.ts +33 -5
  67. package/self-host.js +950 -22
  68. package/slotlock.manifest.json +1 -1
  69. package/store.d.ts +373 -4
  70. package/store.js +1212 -118
  71. package/teams.d.ts +25 -0
  72. package/teams.js +63 -0
  73. package/timezone.js +13 -1
  74. package/types.d.ts +95 -1
  75. package/usage.d.ts +106 -0
  76. 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
  /**
@@ -174,9 +191,37 @@ interface ModelRequest {
174
191
  force?: string;
175
192
  fetch: typeof fetch;
176
193
  signal: AbortSignal;
194
+ /**
195
+ * Stream the answer: called with each piece of its text as it arrives, in order; the pieces join
196
+ * to the answer's text. Without it the call asks for one JSON answer.
197
+ */
198
+ onText?: (delta: string) => void;
177
199
  }
178
200
  /** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
179
201
  export declare function callSlotlockAssistantModel(request: ModelRequest): Promise<SlotlockAssistantModelTurn>;
202
+ /**
203
+ * Providers the Settings form fills in (RFC 0003 A4): each one's API and the base URL it documents
204
+ * for it. Custom fills nothing, for any other OpenAI-compatible API. Choosing one saves nothing.
205
+ */
206
+ export declare const SLOTLOCK_ASSISTANT_PRESETS: readonly {
207
+ id: string;
208
+ name: string;
209
+ provider: SlotlockAssistantProvider;
210
+ baseUrl: string;
211
+ }[];
212
+ /**
213
+ * The models a provider offers (`GET /v1/models` on the Anthropic API, `GET /models` on an
214
+ * OpenAI-compatible one, listed under `data` or as a bare array), by id, sorted, for the Settings
215
+ * form's Model field. Throws
216
+ * SlotlockAssistantProviderError as a model call does, never with the key in its message.
217
+ */
218
+ export declare function listSlotlockAssistantModels(args: {
219
+ provider: SlotlockAssistantProvider;
220
+ baseUrl: string;
221
+ apiKey: string | null;
222
+ fetch: typeof fetch;
223
+ timeoutMs: number;
224
+ }): Promise<string[]>;
180
225
  /**
181
226
  * The connection test: one request offering one read tool the model must call. True when it
182
227
  * answers with exactly one call of that tool with object arguments.
@@ -217,7 +262,30 @@ export interface SlotlockAssistantTurnOptions {
217
262
  ok: false;
218
263
  code: string;
219
264
  }>;
265
+ /** Stream the turn: each model call's text as it arrives, and each tool call before and after it runs. */
266
+ onEvent?: (event: SlotlockAssistantTurnEvent) => void;
267
+ /** The member's Stop: the turn ends at once, keeping what it has, and says it stopped. */
268
+ signal?: AbortSignal;
220
269
  }
270
+ /**
271
+ * What a streamed turn tells as it goes, in order: text as the model writes it; then each tool call
272
+ * (`read` runs, `write` is proposed, `unknown` is refused) before it runs, and its result after.
273
+ */
274
+ export type SlotlockAssistantTurnEvent = {
275
+ type: 'text';
276
+ delta: string;
277
+ } | {
278
+ type: 'call';
279
+ id: string;
280
+ name: string;
281
+ use: 'read' | 'write' | 'unknown';
282
+ } | {
283
+ type: 'result';
284
+ id: string;
285
+ name: string;
286
+ ok: boolean;
287
+ actionId?: string;
288
+ };
221
289
  export interface SlotlockAssistantTurnResult {
222
290
  /** The entries this turn added. */
223
291
  added: SlotlockAssistantEntry[];
@@ -227,7 +295,8 @@ export interface SlotlockAssistantTurnResult {
227
295
  /**
228
296
  * One member message: call the model, run what it reads, keep what it would write, and call it
229
297
  * again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
230
- * names; everything else is answered `tool_not_available`.
298
+ * names; everything else is answered `tool_not_available`. With `onEvent` each model call streams;
299
+ * once `signal` aborts, no model call or tool goes on, and every call kept has its result.
231
300
  */
232
301
  export declare function runSlotlockAssistantTurn(options: SlotlockAssistantTurnOptions): Promise<SlotlockAssistantTurnResult>;
233
302
  /**