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.
- package/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- 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-version.js +2 -4
- 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 +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- 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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
20
|
-
*
|
|
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
243
|
-
*
|
|
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
|
|
249
|
-
*
|
|
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. */
|