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/api-keys.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SlotlockAgentPrincipal } from './agent-server.js';
|
|
2
2
|
import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
|
|
3
3
|
import { type SlotlockChangeOrigin } from './event-feed.js';
|
|
4
|
+
import { type SlotlockResourceScope } from './resource-profile.js';
|
|
4
5
|
import type { SlotlockSql } from './store.js';
|
|
5
6
|
export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
|
|
6
7
|
/**
|
|
@@ -22,7 +23,7 @@ export interface SlotlockApiKey {
|
|
|
22
23
|
id: string;
|
|
23
24
|
tenantRef: string;
|
|
24
25
|
name: string;
|
|
25
|
-
/** `slk_` and the next 8 characters of the key. */
|
|
26
|
+
/** `slk_` (`slk_test_` in a test environment) and the next 8 characters of the key. */
|
|
26
27
|
prefix: string;
|
|
27
28
|
scopes: SlotlockApiKeyScope[];
|
|
28
29
|
/** Who created it, as the caller recorded it (`cli`, `github:<id>`), or null. */
|
|
@@ -32,12 +33,36 @@ export interface SlotlockApiKey {
|
|
|
32
33
|
/** When the key last authenticated, to the minute. */
|
|
33
34
|
lastUsedAt: Date | null;
|
|
34
35
|
revokedAt: Date | null;
|
|
36
|
+
/** For a key limited to part of its tenant (RFC 0003 G6), the locations and groups it reaches. */
|
|
37
|
+
resourceScope?: SlotlockResourceScope;
|
|
38
|
+
/** What the key may do below the server's limits (RFC 0004 A5), when anything is set. */
|
|
39
|
+
limits?: SlotlockApiKeyLimits;
|
|
35
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* One agent's key held below the server's limits (RFC 0004 A5). Each only lowers: the server's own
|
|
43
|
+
* limit applies where it is lower, and on a hosted plan the plan's. Unset fields are the server's.
|
|
44
|
+
*/
|
|
45
|
+
export interface SlotlockApiKeyLimits {
|
|
46
|
+
/** Live holds at once (the server's `holder_holds`, 20 by default), 1-10,000. */
|
|
47
|
+
maxHolds?: number;
|
|
48
|
+
/** Hours its live holds may hold together (`holder_held_hours`, 2,160), 1-876,000. */
|
|
49
|
+
maxHeldHours?: number;
|
|
50
|
+
/** Requests a minute, reads and writes alike, on top of the server's per-key rates; 1-100,000. */
|
|
51
|
+
requestsPerMinute?: number;
|
|
52
|
+
/** Every booking it makes waits for a person (RFC 0004 A4), as an approval policy would hold it. */
|
|
53
|
+
requiresApproval?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Whether `value` is a valid SlotlockApiKeyLimits. */
|
|
56
|
+
export declare function isSlotlockApiKeyLimits(value: unknown): value is SlotlockApiKeyLimits;
|
|
36
57
|
/** What an active key authenticates as. */
|
|
37
58
|
export interface SlotlockApiKeyIdentity {
|
|
38
59
|
id: string;
|
|
39
60
|
tenantRef: string;
|
|
40
61
|
scopes: SlotlockApiKeyScope[];
|
|
62
|
+
/** Set for a key limited to part of its tenant: withTenant holds it to these resources. */
|
|
63
|
+
resourceScope?: SlotlockResourceScope;
|
|
64
|
+
/** Set for a key with limits of its own (RFC 0004 A5). */
|
|
65
|
+
limits?: SlotlockApiKeyLimits;
|
|
41
66
|
}
|
|
42
67
|
export interface CreateSlotlockApiKeyInput {
|
|
43
68
|
tenantRef: string;
|
|
@@ -51,6 +76,14 @@ export interface CreateSlotlockApiKeyInput {
|
|
|
51
76
|
actor?: string;
|
|
52
77
|
/** Where from, for the feed (`dashboard`, say); else the enclosing withTenant's, or `library`. */
|
|
53
78
|
origin?: SlotlockChangeOrigin;
|
|
79
|
+
/**
|
|
80
|
+
* Limit the key to the resources at these locations (each with every location in it) or in these
|
|
81
|
+
* groups (RFC 0003 G6), for good: up to 20 of each. Read and write keys only: a limited key with
|
|
82
|
+
* `admin` is refused (`invalid_api_key_scopes`), since admin operations are the whole tenant's.
|
|
83
|
+
*/
|
|
84
|
+
resourceScope?: SlotlockResourceScope;
|
|
85
|
+
/** Hold the key below the server's limits (RFC 0004 A5); `setLimits` changes them later. */
|
|
86
|
+
limits?: SlotlockApiKeyLimits;
|
|
54
87
|
}
|
|
55
88
|
/** Who rotates or revokes a key and from where, recorded on the feed as `create`'s are. */
|
|
56
89
|
export interface SlotlockApiKeyChange {
|
|
@@ -93,6 +126,15 @@ export interface SlotlockApiKeyStore {
|
|
|
93
126
|
tenantRef: string;
|
|
94
127
|
id: string;
|
|
95
128
|
} & SlotlockApiKeyChange): Promise<SlotlockApiKey | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Replace an active key's limits (RFC 0004 A5); `{}` removes them. False when the tenant has no
|
|
131
|
+
* active key with that id. Throws `invalid_api_key_limits` for limits out of range.
|
|
132
|
+
*/
|
|
133
|
+
setLimits(input: {
|
|
134
|
+
tenantRef: string;
|
|
135
|
+
id: string;
|
|
136
|
+
limits: SlotlockApiKeyLimits;
|
|
137
|
+
}): Promise<boolean>;
|
|
96
138
|
/** Resolve a presented key to its tenant and scopes; null unless it is active. */
|
|
97
139
|
authenticate(key: string): Promise<SlotlockApiKeyIdentity | null>;
|
|
98
140
|
/**
|
|
@@ -111,8 +153,32 @@ export declare function slotlockApiKeyDigest(key: string): Buffer;
|
|
|
111
153
|
* found key offline the same way; the server refuses a mistyped one without a lookup.
|
|
112
154
|
*/
|
|
113
155
|
export declare function isSlotlockApiKey(value: string): boolean;
|
|
114
|
-
/**
|
|
115
|
-
|
|
156
|
+
/**
|
|
157
|
+
* A new key with the prefix lists show and the digest the database keeps; a test environment's
|
|
158
|
+
* (`test`) starts `slk_test_`.
|
|
159
|
+
*/
|
|
160
|
+
export declare function generateSlotlockApiKey(test?: boolean): {
|
|
161
|
+
key: string;
|
|
162
|
+
prefix: string;
|
|
163
|
+
digest: Buffer;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* What a test environment's key starts with (RFC 0003 G9): it acts in `<tenant>~test`, a tenant of
|
|
167
|
+
* its own on its live tenant's plan, never billed.
|
|
168
|
+
*/
|
|
169
|
+
export declare const SLOTLOCK_TEST_API_KEY_PREFIX = "slk_test_";
|
|
170
|
+
/** What ends a test environment's tenant reference: `<tenant>~test`. */
|
|
171
|
+
export declare const SLOTLOCK_TEST_TENANT_SUFFIX = "~test";
|
|
172
|
+
/** Whether the tenant is a test environment (`<tenant>~test`). */
|
|
173
|
+
export declare function isSlotlockTestTenant(tenantRef: string): boolean;
|
|
174
|
+
/** The live tenant of a test environment, or the tenant itself. */
|
|
175
|
+
export declare function slotlockLiveTenant(tenantRef: string): string;
|
|
176
|
+
/** What a partner key (RFC 0003 P1) starts with: never a tenant's key, which `slk_` and base62 follow. */
|
|
177
|
+
export declare const SLOTLOCK_PARTNER_KEY_PREFIX = "slk_partner_";
|
|
178
|
+
/** Whether `value` is shaped as a partner key with a matching checksum, as isSlotlockApiKey. */
|
|
179
|
+
export declare function isSlotlockPartnerKey(value: string): boolean;
|
|
180
|
+
/** A new partner key, with the prefix lists show (`slk_partner_` and 6 more) and its digest. */
|
|
181
|
+
export declare function generateSlotlockPartnerKey(): {
|
|
116
182
|
key: string;
|
|
117
183
|
prefix: string;
|
|
118
184
|
digest: Buffer;
|
package/api-keys.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
11
11
|
import { SlotlockAgentOperationError } from './agent-server.js';
|
|
12
12
|
import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
|
|
13
13
|
import { isSlotlockChangeOrigin } from './event-feed.js';
|
|
14
|
+
import { isSlotlockResourceScope } from './resource-profile.js';
|
|
14
15
|
export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
|
|
15
16
|
/**
|
|
16
17
|
* `read` covers the agent operations that only look; `write` the ones that change a calendar; `admin`
|
|
@@ -30,9 +31,7 @@ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
|
30
31
|
const RANDOM_CHARACTERS = 40;
|
|
31
32
|
/** A 32-bit checksum fits in 6 base62 digits (62^6 > 2^32). */
|
|
32
33
|
const CHECKSUM_CHARACTERS = 6;
|
|
33
|
-
|
|
34
|
-
const DISPLAY_CHARACTERS = SLOTLOCK_API_KEY_PREFIX.length + 8;
|
|
35
|
-
const KEY_PATTERN = /^slk_[0-9A-Za-z]{46}$/;
|
|
34
|
+
const KEY_PATTERN = /^slk_(test_)?[0-9A-Za-z]{46}$/;
|
|
36
35
|
/** The largest byte below 256 that is a multiple of 62, so `byte % 62` is unbiased below it. */
|
|
37
36
|
const UNBIASED_BYTE_LIMIT = 248;
|
|
38
37
|
const MAX_NAME_CHARACTERS = 100;
|
|
@@ -47,6 +46,52 @@ const DEFAULT_MAX_WAITING_LOOKUPS = 256;
|
|
|
47
46
|
const RECENTLY_VALID_MS = 15 * 60_000;
|
|
48
47
|
/** Keys the authenticator remembers as recently valid, by digest; past this the oldest go first. */
|
|
49
48
|
const MAX_RECENTLY_VALID = 10_000;
|
|
49
|
+
/** Whether `value` is a valid SlotlockApiKeyLimits. */
|
|
50
|
+
export function isSlotlockApiKeyLimits(value) {
|
|
51
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
52
|
+
return false;
|
|
53
|
+
const limits = value;
|
|
54
|
+
const known = new Set(['maxHolds', 'maxHeldHours', 'requestsPerMinute', 'requiresApproval']);
|
|
55
|
+
if (Object.keys(limits).some((key) => !known.has(key)))
|
|
56
|
+
return false;
|
|
57
|
+
const whole = (field, most) => field === undefined || (Number.isSafeInteger(field) && field >= 1 && field <= most);
|
|
58
|
+
return (whole(limits.maxHolds, 10_000) &&
|
|
59
|
+
whole(limits.maxHeldHours, 876_000) &&
|
|
60
|
+
whole(limits.requestsPerMinute, 100_000) &&
|
|
61
|
+
(limits.requiresApproval === undefined || typeof limits.requiresApproval === 'boolean'));
|
|
62
|
+
}
|
|
63
|
+
/** Limits as the database row has them, or undefined when the key has none. */
|
|
64
|
+
function limitsFrom(row) {
|
|
65
|
+
const limits = {
|
|
66
|
+
...(row.max_holds !== null ? { maxHolds: row.max_holds } : {}),
|
|
67
|
+
...(row.max_held_hours !== null ? { maxHeldHours: row.max_held_hours } : {}),
|
|
68
|
+
...(row.requests_per_minute !== null ? { requestsPerMinute: row.requests_per_minute } : {}),
|
|
69
|
+
...(row.requires_approval === true ? { requiresApproval: true } : {}),
|
|
70
|
+
};
|
|
71
|
+
return Object.keys(limits).length > 0 ? limits : undefined;
|
|
72
|
+
}
|
|
73
|
+
const LIMITS_MESSAGE = 'Slotlock key limits are maxHolds (1-10,000), maxHeldHours (1-876,000), requestsPerMinute (1-100,000) and requiresApproval, each optional';
|
|
74
|
+
function rowOfLimits(limits) {
|
|
75
|
+
return {
|
|
76
|
+
max_holds: limits.maxHolds ?? null,
|
|
77
|
+
max_held_hours: limits.maxHeldHours ?? null,
|
|
78
|
+
requests_per_minute: limits.requestsPerMinute ?? null,
|
|
79
|
+
requires_approval: limits.requiresApproval ?? null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function setKeyLimits(sql, tenantRef, id, limits) {
|
|
83
|
+
const row = rowOfLimits(limits);
|
|
84
|
+
const [set] = await sql `
|
|
85
|
+
SELECT slotlock.set_api_key_limits(
|
|
86
|
+
${tenantRef}::text, ${id}::uuid, ${row.max_holds}::integer, ${row.max_held_hours}::integer,
|
|
87
|
+
${row.requests_per_minute}::integer, ${row.requires_approval === true}::boolean) AS set`;
|
|
88
|
+
return set?.set === true;
|
|
89
|
+
}
|
|
90
|
+
/** A function the role was not granted, or that this database does not have yet. */
|
|
91
|
+
function isNotGranted(error) {
|
|
92
|
+
const code = error?.code;
|
|
93
|
+
return code === '42501' || code === '42883';
|
|
94
|
+
}
|
|
50
95
|
function invalid(code, message) {
|
|
51
96
|
return Object.assign(new Error(message), { code });
|
|
52
97
|
}
|
|
@@ -104,20 +149,66 @@ export function slotlockApiKeyDigest(key) {
|
|
|
104
149
|
export function isSlotlockApiKey(value) {
|
|
105
150
|
if (typeof value !== 'string' || !KEY_PATTERN.test(value))
|
|
106
151
|
return false;
|
|
107
|
-
const start =
|
|
152
|
+
const start = value.startsWith(SLOTLOCK_TEST_API_KEY_PREFIX)
|
|
153
|
+
? SLOTLOCK_TEST_API_KEY_PREFIX.length
|
|
154
|
+
: SLOTLOCK_API_KEY_PREFIX.length;
|
|
155
|
+
const random = value.slice(start, start + RANDOM_CHARACTERS);
|
|
156
|
+
return value.slice(start + RANDOM_CHARACTERS) === checksum(random);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A new key with the prefix lists show and the digest the database keeps; a test environment's
|
|
160
|
+
* (`test`) starts `slk_test_`.
|
|
161
|
+
*/
|
|
162
|
+
export function generateSlotlockApiKey(test = false) {
|
|
163
|
+
const random = randomBase62(RANDOM_CHARACTERS);
|
|
164
|
+
const start = test ? SLOTLOCK_TEST_API_KEY_PREFIX : SLOTLOCK_API_KEY_PREFIX;
|
|
165
|
+
const key = `${start}${random}${checksum(random)}`;
|
|
166
|
+
return { key, prefix: key.slice(0, start.length + 8), digest: slotlockApiKeyDigest(key) };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* What a test environment's key starts with (RFC 0003 G9): it acts in `<tenant>~test`, a tenant of
|
|
170
|
+
* its own on its live tenant's plan, never billed.
|
|
171
|
+
*/
|
|
172
|
+
export const SLOTLOCK_TEST_API_KEY_PREFIX = 'slk_test_';
|
|
173
|
+
/** What ends a test environment's tenant reference: `<tenant>~test`. */
|
|
174
|
+
export const SLOTLOCK_TEST_TENANT_SUFFIX = '~test';
|
|
175
|
+
/** Whether the tenant is a test environment (`<tenant>~test`). */
|
|
176
|
+
export function isSlotlockTestTenant(tenantRef) {
|
|
177
|
+
return tenantRef.endsWith(SLOTLOCK_TEST_TENANT_SUFFIX);
|
|
178
|
+
}
|
|
179
|
+
/** The live tenant of a test environment, or the tenant itself. */
|
|
180
|
+
export function slotlockLiveTenant(tenantRef) {
|
|
181
|
+
return isSlotlockTestTenant(tenantRef) ? tenantRef.slice(0, -SLOTLOCK_TEST_TENANT_SUFFIX.length) : tenantRef;
|
|
182
|
+
}
|
|
183
|
+
/** What a partner key (RFC 0003 P1) starts with: never a tenant's key, which `slk_` and base62 follow. */
|
|
184
|
+
export const SLOTLOCK_PARTNER_KEY_PREFIX = 'slk_partner_';
|
|
185
|
+
const PARTNER_KEY_PATTERN = /^slk_partner_[0-9A-Za-z]{46}$/;
|
|
186
|
+
/** Whether `value` is shaped as a partner key with a matching checksum, as isSlotlockApiKey. */
|
|
187
|
+
export function isSlotlockPartnerKey(value) {
|
|
188
|
+
if (typeof value !== 'string' || !PARTNER_KEY_PATTERN.test(value))
|
|
189
|
+
return false;
|
|
190
|
+
const start = SLOTLOCK_PARTNER_KEY_PREFIX.length;
|
|
108
191
|
const random = value.slice(start, start + RANDOM_CHARACTERS);
|
|
109
192
|
return value.slice(start + RANDOM_CHARACTERS) === checksum(random);
|
|
110
193
|
}
|
|
111
|
-
/** A new key with the prefix lists show and
|
|
112
|
-
export function
|
|
194
|
+
/** A new partner key, with the prefix lists show (`slk_partner_` and 6 more) and its digest. */
|
|
195
|
+
export function generateSlotlockPartnerKey() {
|
|
113
196
|
const random = randomBase62(RANDOM_CHARACTERS);
|
|
114
|
-
const key = `${
|
|
115
|
-
return {
|
|
197
|
+
const key = `${SLOTLOCK_PARTNER_KEY_PREFIX}${random}${checksum(random)}`;
|
|
198
|
+
return {
|
|
199
|
+
key,
|
|
200
|
+
prefix: key.slice(0, SLOTLOCK_PARTNER_KEY_PREFIX.length + 6),
|
|
201
|
+
digest: slotlockApiKeyDigest(key),
|
|
202
|
+
};
|
|
116
203
|
}
|
|
117
204
|
/** The credential of an `Authorization: Bearer <credential>` header, or undefined. */
|
|
118
205
|
export function slotlockBearerCredential(request) {
|
|
119
206
|
return /^Bearer +(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
|
|
120
207
|
}
|
|
208
|
+
const canonicalResourceScope = (locations, groups) => ({
|
|
209
|
+
locations: [...new Set(locations)].sort(),
|
|
210
|
+
groups: [...new Set(groups)].sort(),
|
|
211
|
+
});
|
|
121
212
|
const knownScopes = (scopes) => SLOTLOCK_API_KEY_SCOPES.filter((scope) => scopes.includes(scope));
|
|
122
213
|
function apiKeyFrom(row) {
|
|
123
214
|
return {
|
|
@@ -177,6 +268,8 @@ async function asChange(sql, change, query) {
|
|
|
177
268
|
* before a query runs; the functions and the table's constraints check the same rules again.
|
|
178
269
|
*/
|
|
179
270
|
export function createSlotlockApiKeyStore(sql) {
|
|
271
|
+
// Whether the role may read key limits; a role granted before them may not, until granted again.
|
|
272
|
+
let limitsGranted = true;
|
|
180
273
|
return {
|
|
181
274
|
async create(input) {
|
|
182
275
|
assertTenantRef(input.tenantRef);
|
|
@@ -187,6 +280,19 @@ export function createSlotlockApiKeyStore(sql) {
|
|
|
187
280
|
throw invalid('invalid_api_key_name', `Slotlock API key name must be 1-${MAX_NAME_CHARACTERS} characters without control characters`);
|
|
188
281
|
}
|
|
189
282
|
const scopes = canonicalScopes(input.scopes);
|
|
283
|
+
const resourceScope = input.resourceScope;
|
|
284
|
+
if (resourceScope !== undefined) {
|
|
285
|
+
if (!isSlotlockResourceScope(resourceScope)) {
|
|
286
|
+
throw invalid('invalid_api_key_resource_scope', 'Slotlock key scope must name up to 20 locations and 20 groups by reference, at least one');
|
|
287
|
+
}
|
|
288
|
+
if (scopes.includes('admin')) {
|
|
289
|
+
throw invalid('invalid_api_key_scopes', 'Slotlock keys limited to locations or groups read and write only: admin is the whole tenant');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const limits = input.limits;
|
|
293
|
+
if (limits !== undefined && !isSlotlockApiKeyLimits(limits)) {
|
|
294
|
+
throw invalid('invalid_api_key_limits', LIMITS_MESSAGE);
|
|
295
|
+
}
|
|
190
296
|
const expiresAt = input.expiresAt;
|
|
191
297
|
if (expiresAt !== undefined &&
|
|
192
298
|
(!(expiresAt instanceof Date) ||
|
|
@@ -203,35 +309,85 @@ export function createSlotlockApiKeyStore(sql) {
|
|
|
203
309
|
hasControlCharacter(createdBy))) {
|
|
204
310
|
throw invalid('invalid_api_key_creator', `Slotlock API key creator must be 1-${MAX_CREATOR_BYTES} bytes without control characters`);
|
|
205
311
|
}
|
|
206
|
-
const { key, prefix, digest } = generateSlotlockApiKey();
|
|
312
|
+
const { key, prefix, digest } = generateSlotlockApiKey(isSlotlockTestTenant(input.tenantRef));
|
|
207
313
|
// Scopes travel as one string: postgres.js binds `sql.array` as text[] only once the pool
|
|
208
314
|
// has fetched the server's array types, which the first query on a new pool precedes.
|
|
209
|
-
const [row] = await asChange(sql, { ...(input.actor !== undefined ? { actor: input.actor } : {}), ...(input.origin ? { origin: input.origin } : {}) }, (tx) =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
315
|
+
const [row] = await asChange(sql, { ...(input.actor !== undefined ? { actor: input.actor } : {}), ...(input.origin ? { origin: input.origin } : {}) }, async (tx) => {
|
|
316
|
+
const created = await tx `
|
|
317
|
+
SELECT * FROM slotlock.create_api_key(
|
|
318
|
+
${input.tenantRef}::text,
|
|
319
|
+
${name}::text,
|
|
320
|
+
${prefix}::text,
|
|
321
|
+
${digest}::bytea,
|
|
322
|
+
pg_catalog.string_to_array(${scopes.join(',')}::text, ','),
|
|
323
|
+
${expiresAt ?? null}::timestamptz,
|
|
324
|
+
${createdBy ?? null}::text
|
|
325
|
+
)`;
|
|
326
|
+
// In the same transaction: a limited key never exists without its limit.
|
|
327
|
+
if (created[0] && resourceScope) {
|
|
328
|
+
await tx `
|
|
329
|
+
SELECT slotlock.set_api_key_resource_scope(
|
|
330
|
+
${input.tenantRef}::text, ${created[0].key_id}::uuid,
|
|
331
|
+
pg_catalog.string_to_array(${resourceScope.locations.join(',')}::text, ','),
|
|
332
|
+
pg_catalog.string_to_array(${resourceScope.groups.join(',')}::text, ','))`;
|
|
333
|
+
}
|
|
334
|
+
if (created[0] && limits && limitsFrom(rowOfLimits(limits))) {
|
|
335
|
+
await setKeyLimits(tx, input.tenantRef, created[0].key_id, limits);
|
|
336
|
+
}
|
|
337
|
+
return created;
|
|
338
|
+
});
|
|
219
339
|
if (!row) {
|
|
220
|
-
throw invalid('api_key_limit_reached', `Slotlock allows ${SLOTLOCK_API_KEY_ACTIVE_LIMIT} active and ${SLOTLOCK_API_KEY_RETAINED_LIMIT} kept API keys per tenant; revoke one first`);
|
|
340
|
+
throw invalid('api_key_limit_reached', `Slotlock allows ${SLOTLOCK_API_KEY_ACTIVE_LIMIT} active and ${SLOTLOCK_API_KEY_RETAINED_LIMIT} kept API keys per tenant, or fewer active ones on a hosted plan; revoke one first`);
|
|
221
341
|
}
|
|
222
|
-
|
|
342
|
+
const apiKey = apiKeyFrom(row);
|
|
343
|
+
if (resourceScope)
|
|
344
|
+
apiKey.resourceScope = canonicalResourceScope(resourceScope.locations, resourceScope.groups);
|
|
345
|
+
const kept = limits ? limitsFrom(rowOfLimits(limits)) : undefined;
|
|
346
|
+
if (kept)
|
|
347
|
+
apiKey.limits = kept;
|
|
348
|
+
return { key, apiKey };
|
|
349
|
+
},
|
|
350
|
+
async setLimits({ tenantRef, id, limits }) {
|
|
351
|
+
assertTenantRef(tenantRef);
|
|
352
|
+
if (typeof id !== 'string' || !UUID_PATTERN.test(id))
|
|
353
|
+
return false;
|
|
354
|
+
if (!isSlotlockApiKeyLimits(limits))
|
|
355
|
+
throw invalid('invalid_api_key_limits', LIMITS_MESSAGE);
|
|
356
|
+
return setKeyLimits(sql, tenantRef, id, limits);
|
|
223
357
|
},
|
|
224
358
|
async list({ tenantRef }) {
|
|
225
359
|
assertTenantRef(tenantRef);
|
|
226
|
-
const rows = await
|
|
227
|
-
|
|
228
|
-
|
|
360
|
+
const [rows, limited, bounded] = await Promise.all([
|
|
361
|
+
sql `SELECT * FROM slotlock.list_api_keys(${tenantRef}::text)`,
|
|
362
|
+
sql `
|
|
363
|
+
SELECT key_id::text, locations, groups FROM slotlock.list_api_key_resource_scopes(${tenantRef}::text)`,
|
|
364
|
+
// A role granted before key limits cannot read them yet: its keys show none.
|
|
365
|
+
sql `
|
|
366
|
+
SELECT key_id::text, max_holds, max_held_hours, requests_per_minute, requires_approval
|
|
367
|
+
FROM slotlock.list_api_key_limits(${tenantRef}::text)`.catch((error) => {
|
|
368
|
+
if (isNotGranted(error))
|
|
369
|
+
return [];
|
|
370
|
+
throw error;
|
|
371
|
+
}),
|
|
372
|
+
]);
|
|
373
|
+
const scopes = new Map(limited.map((row) => [row.key_id, canonicalResourceScope(row.locations, row.groups)]));
|
|
374
|
+
const limitsByKey = new Map(bounded.map((row) => [row.key_id, limitsFrom(row)]));
|
|
375
|
+
return rows.map((row) => {
|
|
376
|
+
const apiKey = apiKeyFrom(row);
|
|
377
|
+
const scope = scopes.get(row.key_id);
|
|
378
|
+
if (scope)
|
|
379
|
+
apiKey.resourceScope = scope;
|
|
380
|
+
const keyLimits = limitsByKey.get(row.key_id);
|
|
381
|
+
if (keyLimits)
|
|
382
|
+
apiKey.limits = keyLimits;
|
|
383
|
+
return apiKey;
|
|
384
|
+
});
|
|
229
385
|
},
|
|
230
386
|
async rotate({ tenantRef, id, ...change }) {
|
|
231
387
|
assertTenantRef(tenantRef);
|
|
232
388
|
if (typeof id !== 'string' || !UUID_PATTERN.test(id))
|
|
233
389
|
return null;
|
|
234
|
-
const { key, prefix, digest } = generateSlotlockApiKey();
|
|
390
|
+
const { key, prefix, digest } = generateSlotlockApiKey(isSlotlockTestTenant(tenantRef));
|
|
235
391
|
const [row] = await asChange(sql, change, (tx) => tx `
|
|
236
392
|
SELECT * FROM slotlock.rotate_api_key(
|
|
237
393
|
${tenantRef}::text, ${id}::uuid, ${prefix}::text, ${digest}::bytea
|
|
@@ -249,11 +405,42 @@ export function createSlotlockApiKeyStore(sql) {
|
|
|
249
405
|
async authenticate(key) {
|
|
250
406
|
if (!isSlotlockApiKey(key))
|
|
251
407
|
return null;
|
|
252
|
-
const
|
|
253
|
-
|
|
408
|
+
const digest = slotlockApiKeyDigest(key);
|
|
409
|
+
let row;
|
|
410
|
+
if (limitsGranted) {
|
|
411
|
+
try {
|
|
412
|
+
[row] = await sql `SELECT * FROM slotlock.authenticate_limited_api_key(${digest}::bytea)`;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
// A role granted before key limits (grantApplicationRole not run again since): keys
|
|
416
|
+
// still authenticate, without limits, and this process stops asking.
|
|
417
|
+
if (!isNotGranted(error))
|
|
418
|
+
throw error;
|
|
419
|
+
limitsGranted = false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!limitsGranted) {
|
|
423
|
+
[row] = await sql `SELECT * FROM slotlock.authenticate_scoped_api_key(${digest}::bytea)`;
|
|
424
|
+
}
|
|
254
425
|
if (!row)
|
|
255
426
|
return null;
|
|
256
|
-
|
|
427
|
+
const identity = {
|
|
428
|
+
id: row.key_id,
|
|
429
|
+
tenantRef: row.key_tenant_ref,
|
|
430
|
+
scopes: knownScopes(row.key_scopes),
|
|
431
|
+
};
|
|
432
|
+
if (row.scope_locations !== null || row.scope_groups !== null) {
|
|
433
|
+
identity.resourceScope = canonicalResourceScope(row.scope_locations ?? [], row.scope_groups ?? []);
|
|
434
|
+
}
|
|
435
|
+
const keyLimits = limitsFrom({
|
|
436
|
+
max_holds: row.max_holds ?? null,
|
|
437
|
+
max_held_hours: row.max_held_hours ?? null,
|
|
438
|
+
requests_per_minute: row.requests_per_minute ?? null,
|
|
439
|
+
requires_approval: row.requires_approval ?? null,
|
|
440
|
+
});
|
|
441
|
+
if (keyLimits)
|
|
442
|
+
identity.limits = keyLimits;
|
|
443
|
+
return identity;
|
|
257
444
|
},
|
|
258
445
|
async erase({ tenantRef }) {
|
|
259
446
|
assertTenantRef(tenantRef);
|
|
@@ -326,6 +513,8 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
|
|
|
326
513
|
tenantRef: identity.tenantRef,
|
|
327
514
|
scopes: identity.scopes,
|
|
328
515
|
keyId: identity.id,
|
|
516
|
+
...(identity.resourceScope ? { resourceScope: identity.resourceScope } : {}),
|
|
517
|
+
...(identity.limits ? { limits: identity.limits } : {}),
|
|
329
518
|
};
|
|
330
519
|
}
|
|
331
520
|
finally {
|
package/application-api.d.ts
CHANGED
|
@@ -2,9 +2,13 @@ import { type SlotlockChangeOrigin } from './event-feed.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { SlotlockAgentPrincipal } from './agent-server.js';
|
|
4
4
|
import type { SlotlockTraceContext } from './mcp-modern.js';
|
|
5
|
-
export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_list_pools' | 'slotlock_get_booking_rule_defaults' | 'slotlock_set_tenant_booking_rules' | 'slotlock_set_pool_booking_rules' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook';
|
|
6
|
-
/** The scope an application operation needs,
|
|
5
|
+
export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_update_resource' | 'slotlock_list_locations' | 'slotlock_create_location' | 'slotlock_update_location' | 'slotlock_delete_location' | 'slotlock_list_groups' | 'slotlock_create_group' | 'slotlock_delete_group' | 'slotlock_add_group_member' | 'slotlock_remove_group_member' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_set_resource_parent' | 'slotlock_list_pools' | 'slotlock_set_pool_order' | 'slotlock_get_booking_rule_defaults' | 'slotlock_set_tenant_booking_rules' | 'slotlock_set_pool_booking_rules' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook' | 'slotlock_get_usage' | 'slotlock_list_approval_policies' | 'slotlock_put_approval_policy' | 'slotlock_delete_approval_policy' | 'slotlock_list_approvals' | 'slotlock_get_approval' | 'slotlock_decide_approval' | 'slotlock_sandbox_seed' | 'slotlock_sandbox_reset' | 'slotlock_sandbox_fast_forward' | 'slotlock_sandbox_race' | 'slotlock_partner_put_account' | 'slotlock_partner_list_accounts' | 'slotlock_partner_get_account' | 'slotlock_partner_list_keys' | 'slotlock_partner_create_key' | 'slotlock_partner_rotate_key' | 'slotlock_partner_revoke_key' | 'slotlock_partner_suspend_account' | 'slotlock_partner_unsuspend_account' | 'slotlock_partner_erase_account' | 'slotlock_partner_get_usage' | 'slotlock_partner_reconcile_usage';
|
|
6
|
+
/** The scope an application operation needs, but the partner API's (`slotlockApplicationScope`). */
|
|
7
7
|
export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
|
|
8
|
+
/** The scope one application operation needs: `partner` for the partner API's, else `admin`. */
|
|
9
|
+
export declare function slotlockApplicationScope(definition: {
|
|
10
|
+
scope?: 'partner';
|
|
11
|
+
}): string;
|
|
8
12
|
export interface SlotlockApplicationInvocationContext {
|
|
9
13
|
principal: SlotlockAgentPrincipal;
|
|
10
14
|
operation: SlotlockApplicationOperation;
|
|
@@ -13,7 +17,16 @@ export interface SlotlockApplicationInvocationContext {
|
|
|
13
17
|
trace?: SlotlockTraceContext;
|
|
14
18
|
/** The surface the call came through: `application` when the agent server dispatches it. */
|
|
15
19
|
origin?: SlotlockChangeOrigin;
|
|
20
|
+
/** A dry run (RFC 0004 A3): every check, then rolled back (SlotlockAgentInvocationContext). */
|
|
21
|
+
dryRun?: boolean;
|
|
16
22
|
}
|
|
23
|
+
/** Whether an application operation takes `dry_run` (RFC 0004 A3): a write that can be undone. */
|
|
24
|
+
export declare function slotlockApplicationDryRunnable(definition: {
|
|
25
|
+
name: string;
|
|
26
|
+
risk: {
|
|
27
|
+
readOnly: boolean;
|
|
28
|
+
};
|
|
29
|
+
}): boolean;
|
|
17
30
|
type Handler = (context: SlotlockApplicationInvocationContext, input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
18
31
|
/**
|
|
19
32
|
* What serves the application operations: each method gets schema-valid input and returns what its
|
|
@@ -22,6 +35,16 @@ type Handler = (context: SlotlockApplicationInvocationContext, input: Record<str
|
|
|
22
35
|
*/
|
|
23
36
|
export interface SlotlockApplicationBackend {
|
|
24
37
|
createResource: Handler;
|
|
38
|
+
updateResource: Handler;
|
|
39
|
+
listLocations: Handler;
|
|
40
|
+
createLocation: Handler;
|
|
41
|
+
updateLocation: Handler;
|
|
42
|
+
deleteLocation: Handler;
|
|
43
|
+
listGroups: Handler;
|
|
44
|
+
createGroup: Handler;
|
|
45
|
+
deleteGroup: Handler;
|
|
46
|
+
addGroupMember: Handler;
|
|
47
|
+
removeGroupMember: Handler;
|
|
25
48
|
getResource: Handler;
|
|
26
49
|
archiveResource: Handler;
|
|
27
50
|
restoreResource: Handler;
|
|
@@ -33,7 +56,9 @@ export interface SlotlockApplicationBackend {
|
|
|
33
56
|
importResourceAvailability: Handler;
|
|
34
57
|
setResourceBookingRules: Handler;
|
|
35
58
|
setResourcePool: Handler;
|
|
59
|
+
setResourceParent: Handler;
|
|
36
60
|
listPools: Handler;
|
|
61
|
+
setPoolOrder: Handler;
|
|
37
62
|
getBookingRuleDefaults: Handler;
|
|
38
63
|
setTenantBookingRules: Handler;
|
|
39
64
|
setPoolBookingRules: Handler;
|
|
@@ -69,12 +94,35 @@ export interface SlotlockApplicationBackend {
|
|
|
69
94
|
listWebhooks: Handler;
|
|
70
95
|
deleteWebhook: Handler;
|
|
71
96
|
enableWebhook: Handler;
|
|
97
|
+
getUsage: Handler;
|
|
98
|
+
listApprovalPolicies: Handler;
|
|
99
|
+
putApprovalPolicy: Handler;
|
|
100
|
+
deleteApprovalPolicy: Handler;
|
|
101
|
+
listApprovals: Handler;
|
|
102
|
+
getApproval: Handler;
|
|
103
|
+
decideApproval: Handler;
|
|
104
|
+
sandboxSeed: Handler;
|
|
105
|
+
sandboxReset: Handler;
|
|
106
|
+
sandboxFastForward: Handler;
|
|
107
|
+
sandboxRace: Handler;
|
|
108
|
+
partnerPutAccount: Handler;
|
|
109
|
+
partnerListAccounts: Handler;
|
|
110
|
+
partnerGetAccount: Handler;
|
|
111
|
+
partnerListKeys: Handler;
|
|
112
|
+
partnerCreateKey: Handler;
|
|
113
|
+
partnerRotateKey: Handler;
|
|
114
|
+
partnerRevokeKey: Handler;
|
|
115
|
+
partnerSuspendAccount: Handler;
|
|
116
|
+
partnerUnsuspendAccount: Handler;
|
|
117
|
+
partnerEraseAccount: Handler;
|
|
118
|
+
partnerGetUsage: Handler;
|
|
119
|
+
partnerReconcileUsage: Handler;
|
|
72
120
|
}
|
|
73
121
|
export interface SlotlockApplicationOperationDefinition {
|
|
74
122
|
name: SlotlockApplicationOperation;
|
|
75
123
|
title: string;
|
|
76
124
|
description: string;
|
|
77
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
125
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
78
126
|
/** Relative to the server's base URL; `{name}` is one path segment, an input of that name. */
|
|
79
127
|
path: string;
|
|
80
128
|
input: z.ZodType<Record<string, unknown>>;
|
|
@@ -85,6 +133,11 @@ export interface SlotlockApplicationOperationDefinition {
|
|
|
85
133
|
idempotent: boolean;
|
|
86
134
|
};
|
|
87
135
|
invoke: keyof SlotlockApplicationBackend;
|
|
136
|
+
/**
|
|
137
|
+
* The scope the operation needs: `admin` (SLOTLOCK_APPLICATION_SCOPE) unless it is the partner
|
|
138
|
+
* API's, which needs a partner key's `partner` (RFC 0003 P3) and nothing else reaches.
|
|
139
|
+
*/
|
|
140
|
+
scope?: 'partner';
|
|
88
141
|
/** Valid input, path parameters included (a test runs each against the schema). */
|
|
89
142
|
example: Record<string, unknown>;
|
|
90
143
|
}
|