slotlock 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +511 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +70 -1
  17. package/assistant.js +478 -50
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-assistant-ui.d.ts +21 -0
  23. package/dashboard-assistant-ui.js +438 -0
  24. package/dashboard-icon.d.ts +1 -0
  25. package/dashboard-icon.js +5 -0
  26. package/dashboard-markdown.d.ts +2 -0
  27. package/dashboard-markdown.js +157 -0
  28. package/dashboard-settings-ui.d.ts +1 -0
  29. package/dashboard-settings-ui.js +78 -0
  30. package/dashboard.d.ts +227 -7
  31. package/dashboard.js +2313 -247
  32. package/ddl.d.ts +51 -12
  33. package/ddl.js +2566 -54
  34. package/event-feed.d.ts +30 -4
  35. package/event-feed.js +171 -19
  36. package/index.d.ts +15 -4
  37. package/index.js +8 -2
  38. package/maintenance.d.ts +10 -0
  39. package/maintenance.js +16 -2
  40. package/metrics.d.ts +41 -0
  41. package/metrics.js +22 -0
  42. package/openapi.json +14675 -5957
  43. package/otel.d.ts +86 -0
  44. package/otel.js +273 -0
  45. package/overage.d.ts +33 -0
  46. package/overage.js +115 -0
  47. package/package.json +1 -1
  48. package/partner-billing.d.ts +81 -0
  49. package/partner-billing.js +333 -0
  50. package/partner-names.d.ts +8 -0
  51. package/partner-names.js +10 -0
  52. package/partners.d.ts +147 -0
  53. package/partners.js +375 -0
  54. package/plans.d.ts +92 -0
  55. package/plans.js +115 -0
  56. package/quotas.d.ts +108 -0
  57. package/quotas.js +258 -0
  58. package/rate-limit.d.ts +21 -1
  59. package/rate-limit.js +82 -15
  60. package/resource-profile.d.ts +135 -0
  61. package/resource-profile.js +263 -0
  62. package/rest-api.d.ts +2 -0
  63. package/rest-api.js +68 -3
  64. package/sandbox.d.ts +70 -0
  65. package/sandbox.js +295 -0
  66. package/self-host.d.ts +33 -5
  67. package/self-host.js +950 -22
  68. package/slotlock.manifest.json +1 -1
  69. package/store.d.ts +373 -4
  70. package/store.js +1212 -118
  71. package/teams.d.ts +25 -0
  72. package/teams.js +63 -0
  73. package/timezone.js +13 -1
  74. package/types.d.ts +95 -1
  75. package/usage.d.ts +106 -0
  76. package/usage.js +238 -0
package/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
- /** A new key with the prefix lists show and the digest the database keeps. */
115
- export declare function generateSlotlockApiKey(): {
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
- /** What lists show: the prefix and 8 random characters, enough to recognise a key, not to use it. */
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 = SLOTLOCK_API_KEY_PREFIX.length;
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 the digest the database keeps. */
112
- export function generateSlotlockApiKey() {
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 = `${SLOTLOCK_API_KEY_PREFIX}${random}${checksum(random)}`;
115
- return { key, prefix: key.slice(0, DISPLAY_CHARACTERS), digest: slotlockApiKeyDigest(key) };
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) => tx `
210
- SELECT * FROM slotlock.create_api_key(
211
- ${input.tenantRef}::text,
212
- ${name}::text,
213
- ${prefix}::text,
214
- ${digest}::bytea,
215
- pg_catalog.string_to_array(${scopes.join(',')}::text, ','),
216
- ${expiresAt ?? null}::timestamptz,
217
- ${createdBy ?? null}::text
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
- return { key, apiKey: apiKeyFrom(row) };
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 sql `
227
- SELECT * FROM slotlock.list_api_keys(${tenantRef}::text)`;
228
- return rows.map(apiKeyFrom);
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 [row] = await sql `
253
- SELECT * FROM slotlock.authenticate_api_key(${slotlockApiKeyDigest(key)}::bytea)`;
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
- return { id: row.key_id, tenantRef: row.key_tenant_ref, scopes: knownScopes(row.key_scopes) };
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 {
@@ -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, whichever operation it is. */
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
  }