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.
- package/CHANGELOG.md +511 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- 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
|
/**
|