slotlock 0.5.0 → 0.5.2
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 +37 -0
- package/README.md +55 -1
- package/agent-server.d.ts +14 -0
- package/agent-server.js +15 -0
- package/assistant.d.ts +263 -0
- package/assistant.js +771 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +61 -0
- package/dashboard.js +589 -11
- package/ddl.d.ts +5 -5
- package/ddl.js +202 -4
- package/event-feed.js +20 -8
- package/index.d.ts +4 -2
- package/index.js +2 -1
- package/maintenance.d.ts +2 -1
- package/maintenance.js +22 -0
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +34 -3
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +101 -1
- package/store.js +158 -0
- package/webhooks.d.ts +24 -0
- package/webhooks.js +96 -19
package/self-host.js
CHANGED
|
@@ -43,6 +43,8 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
43
43
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
44
44
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
45
45
|
'SLOTLOCK_SESSION_SECRET',
|
|
46
|
+
'SLOTLOCK_ASSISTANT_SECRET',
|
|
47
|
+
'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
|
|
46
48
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
47
49
|
'SLOTLOCK_DASHBOARD_ORGS',
|
|
48
50
|
'SLOTLOCK_DASHBOARD_NAME',
|
|
@@ -245,6 +247,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
245
247
|
confirmationSecret,
|
|
246
248
|
env.SLOTLOCK_SESSION_SECRET?.trim(),
|
|
247
249
|
env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
|
|
250
|
+
env.SLOTLOCK_ASSISTANT_SECRET?.trim(),
|
|
248
251
|
];
|
|
249
252
|
if (others.includes(metricsToken)) {
|
|
250
253
|
throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
|
|
@@ -308,6 +311,7 @@ function secretsIn(env) {
|
|
|
308
311
|
'SLOTLOCK_CONFIRMATION_SECRET',
|
|
309
312
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
310
313
|
'SLOTLOCK_SESSION_SECRET',
|
|
314
|
+
'SLOTLOCK_ASSISTANT_SECRET',
|
|
311
315
|
'SLOTLOCK_METRICS_TOKEN',
|
|
312
316
|
]) {
|
|
313
317
|
const value = env[name]?.trim();
|
|
@@ -634,13 +638,14 @@ export async function startSlotlockServer(config, log) {
|
|
|
634
638
|
onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
|
|
635
639
|
});
|
|
636
640
|
let health;
|
|
641
|
+
const agentBackend = createSlotlockStoreAgentBackend(store, {
|
|
642
|
+
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
643
|
+
});
|
|
637
644
|
const agentServer = createSlotlockAgentServer({
|
|
638
645
|
publicBaseUrl: config.publicUrl,
|
|
639
646
|
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
640
647
|
allowInsecureLocalhost: publicUrl.protocol === 'http:',
|
|
641
|
-
backend:
|
|
642
|
-
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
643
|
-
}),
|
|
648
|
+
backend: agentBackend,
|
|
644
649
|
authenticate: limits.authenticate,
|
|
645
650
|
consumeRateLimit: limits.consumeRateLimit,
|
|
646
651
|
// A write that may expand a recurring series also waits for one of a few slots, so one
|
|
@@ -718,6 +723,22 @@ export async function startSlotlockServer(config, log) {
|
|
|
718
723
|
resources: createSlotlockDashboardResources(store),
|
|
719
724
|
state: createSlotlockDashboardState(sql),
|
|
720
725
|
availability: config.availability,
|
|
726
|
+
...(config.dashboard.assistantSecret !== undefined
|
|
727
|
+
? {
|
|
728
|
+
// The assistant's reads and approved writes are agent calls: the same backend,
|
|
729
|
+
// operations, rate limits and series-write slots as an API key's.
|
|
730
|
+
assistant: {
|
|
731
|
+
secret: config.dashboard.assistantSecret,
|
|
732
|
+
...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
|
|
733
|
+
backend: agentBackend,
|
|
734
|
+
consumeRateLimit: limits.consumeRateLimit,
|
|
735
|
+
// Checks only: a proposal holds nothing. The series-write slot and the write
|
|
736
|
+
// token are taken when an approved change runs (admit, consumeRateLimit).
|
|
737
|
+
authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
|
|
738
|
+
admit: (args) => limits.admit(args),
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
: {}),
|
|
721
742
|
clientAddress: (request) => limits.clientAddress(request),
|
|
722
743
|
onError: (error) => log.error('dashboard_error', describeError(error)),
|
|
723
744
|
});
|
|
@@ -1099,6 +1120,9 @@ async function maintainTenant(config, caps) {
|
|
|
1099
1120
|
if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
|
|
1100
1121
|
break;
|
|
1101
1122
|
}
|
|
1123
|
+
// Every tenant's expired assistant conversations, a tenant only the dashboard serves
|
|
1124
|
+
// included. Counted nowhere: the counts above are this tenant's.
|
|
1125
|
+
await store.sweepAssistantConversations();
|
|
1102
1126
|
return sum;
|
|
1103
1127
|
});
|
|
1104
1128
|
return total ?? null;
|
|
@@ -1249,6 +1273,13 @@ Environment:
|
|
|
1249
1273
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
1250
1274
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
1251
1275
|
SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
|
|
1276
|
+
SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
|
|
1277
|
+
model API key with it, 32+ random characters (dashboard; off
|
|
1278
|
+
without it)
|
|
1279
|
+
SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
|
|
1280
|
+
resolves to, a loopback, private, link-local, CGNAT, unique-local
|
|
1281
|
+
or metadata address; on: allow them, for a self-hosted model
|
|
1282
|
+
(Ollama, vLLM) on this machine or network
|
|
1252
1283
|
SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
|
|
1253
1284
|
SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
|
|
1254
1285
|
SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
|
package/slotlock.manifest.json
CHANGED
package/store.d.ts
CHANGED
|
@@ -635,6 +635,83 @@ export interface SlotlockStore extends SlotlockEventFeed {
|
|
|
635
635
|
id: string;
|
|
636
636
|
holderRef?: string;
|
|
637
637
|
}): Promise<boolean>;
|
|
638
|
+
/** The tenant's dashboard assistant settings, or null when it has none. */
|
|
639
|
+
getAssistantSettings(params: {
|
|
640
|
+
tenantRef: string;
|
|
641
|
+
}): Promise<SlotlockAssistantSettingsRecord | null>;
|
|
642
|
+
/**
|
|
643
|
+
* Set the tenant's assistant settings. `key` is the sealed key and its hint, null for none, or
|
|
644
|
+
* `'keep'` to keep the one stored (none, for a tenant without settings). A stored key is kept only
|
|
645
|
+
* for the provider and base URL it was entered for: `'keep'` with another provider or URL changes
|
|
646
|
+
* nothing and answers `{ saved: false }`, decided in the one statement that writes, so racing
|
|
647
|
+
* saves cannot carry the key elsewhere.
|
|
648
|
+
*/
|
|
649
|
+
putAssistantSettings(params: {
|
|
650
|
+
tenantRef: string;
|
|
651
|
+
provider: 'anthropic' | 'openai';
|
|
652
|
+
baseUrl: string;
|
|
653
|
+
model: string;
|
|
654
|
+
key: {
|
|
655
|
+
sealed: string;
|
|
656
|
+
hint: string;
|
|
657
|
+
} | null | 'keep';
|
|
658
|
+
updatedBy?: string;
|
|
659
|
+
}): Promise<{
|
|
660
|
+
saved: boolean;
|
|
661
|
+
}>;
|
|
662
|
+
/** Remove the tenant's assistant settings, its key with them. False when it had none. */
|
|
663
|
+
deleteAssistantSettings(params: {
|
|
664
|
+
tenantRef: string;
|
|
665
|
+
}): Promise<boolean>;
|
|
666
|
+
/** A member's live assistant conversation, or null when none is (or it expired). */
|
|
667
|
+
getAssistantConversation(params: {
|
|
668
|
+
tenantRef: string;
|
|
669
|
+
memberRef: string;
|
|
670
|
+
}): Promise<SlotlockAssistantConversationRecord | null>;
|
|
671
|
+
/**
|
|
672
|
+
* Store a member's conversation for `ttlMs` (1 second to a day) from now, replacing its messages.
|
|
673
|
+
* Its actions become those stored already that `keep` names, in their stored state
|
|
674
|
+
* (settleAssistantAction changes it), and the `added` ones not stored already: an action is never
|
|
675
|
+
* put back into a conversation that no longer has it. The tenant's expired conversations are
|
|
676
|
+
* deleted with it, and the tenant is listed for sweepAssistantConversations.
|
|
677
|
+
*/
|
|
678
|
+
putAssistantConversation(params: {
|
|
679
|
+
tenantRef: string;
|
|
680
|
+
memberRef: string;
|
|
681
|
+
messages: unknown[];
|
|
682
|
+
added: Record<string, Record<string, unknown>>;
|
|
683
|
+
keep: readonly string[];
|
|
684
|
+
ttlMs: number;
|
|
685
|
+
}): Promise<void>;
|
|
686
|
+
/**
|
|
687
|
+
* Change one proposed action, only while its `status` is `from`: its fields are merged with
|
|
688
|
+
* `patch`. The action as changed, or null when it is not in that state (or not there): of two
|
|
689
|
+
* racing approvals, one wins.
|
|
690
|
+
*/
|
|
691
|
+
settleAssistantAction(params: {
|
|
692
|
+
tenantRef: string;
|
|
693
|
+
memberRef: string;
|
|
694
|
+
actionId: string;
|
|
695
|
+
from: string;
|
|
696
|
+
patch: Record<string, unknown>;
|
|
697
|
+
}): Promise<Record<string, unknown> | null>;
|
|
698
|
+
/** Forget a member's conversation. False when there was none. */
|
|
699
|
+
deleteAssistantConversation(params: {
|
|
700
|
+
tenantRef: string;
|
|
701
|
+
memberRef: string;
|
|
702
|
+
}): Promise<boolean>;
|
|
703
|
+
/** Delete up to `limit` (default 1,000) of the tenant's expired assistant conversations: how many. */
|
|
704
|
+
pruneAssistantConversations(params: {
|
|
705
|
+
tenantRef: string;
|
|
706
|
+
limit?: number;
|
|
707
|
+
}): Promise<number>;
|
|
708
|
+
/**
|
|
709
|
+
* Delete up to `limit` (1-100,000, default 10,000) expired assistant conversations of every
|
|
710
|
+
* tenant (slotlock.sweep_assistant_conversations): how many, and nothing else.
|
|
711
|
+
*/
|
|
712
|
+
sweepAssistantConversations(params?: {
|
|
713
|
+
limit?: number;
|
|
714
|
+
}): Promise<number>;
|
|
638
715
|
/**
|
|
639
716
|
* Offer freed time to the waitlist, in the order entries joined: each waiting entry whose window
|
|
640
717
|
* is free (on its resource, or on the first free member of its pool) gets a hold for its holder
|
|
@@ -735,6 +812,29 @@ export interface SlotlockStore extends SlotlockEventFeed {
|
|
|
735
812
|
coverageMaxAgeMs?: number;
|
|
736
813
|
}): Promise<Interval | null>;
|
|
737
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* The model a tenant's dashboard assistant uses (dashboard Settings, Assistant). The API key is held
|
|
817
|
+
* only sealed (assistant.ts sealSlotlockAssistantKey), never in the clear.
|
|
818
|
+
*/
|
|
819
|
+
export interface SlotlockAssistantSettingsRecord {
|
|
820
|
+
provider: 'anthropic' | 'openai';
|
|
821
|
+
baseUrl: string;
|
|
822
|
+
model: string;
|
|
823
|
+
/** The API key, sealed with the deployment's assistant secret; null when it has none. */
|
|
824
|
+
sealedKey: string | null;
|
|
825
|
+
/** The key's last four characters, to show; null when it has none. */
|
|
826
|
+
keyHint: string | null;
|
|
827
|
+
updatedBy: string | null;
|
|
828
|
+
updatedAt: Date;
|
|
829
|
+
}
|
|
830
|
+
/** One member's live conversation with the dashboard assistant, as assistant.ts keeps it. */
|
|
831
|
+
export interface SlotlockAssistantConversationRecord {
|
|
832
|
+
messages: unknown[];
|
|
833
|
+
/** The writes it proposed, by id. */
|
|
834
|
+
actions: Record<string, Record<string, unknown>>;
|
|
835
|
+
updatedAt: Date;
|
|
836
|
+
expiresAt: Date;
|
|
837
|
+
}
|
|
738
838
|
/** One entry of a tenant's waitlist (joinWaitlist). */
|
|
739
839
|
export interface SlotlockWaitlistEntry {
|
|
740
840
|
id: string;
|
|
@@ -874,4 +974,4 @@ export declare function createSlotlockStore(sql: StoreSql, options?: SlotlockSto
|
|
|
874
974
|
* Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
|
|
875
975
|
* Maintenance (rolling series, pruning, purging) and erasure are not among them.
|
|
876
976
|
*/
|
|
877
|
-
export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist"];
|
|
977
|
+
export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist", "putAssistantSettings", "deleteAssistantSettings", "putAssistantConversation", "settleAssistantAction", "deleteAssistantConversation"];
|
package/store.js
CHANGED
|
@@ -1842,6 +1842,11 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
1842
1842
|
'checkInReservation',
|
|
1843
1843
|
'joinWaitlist',
|
|
1844
1844
|
'leaveWaitlist',
|
|
1845
|
+
'putAssistantSettings',
|
|
1846
|
+
'deleteAssistantSettings',
|
|
1847
|
+
'putAssistantConversation',
|
|
1848
|
+
'settleAssistantAction',
|
|
1849
|
+
'deleteAssistantConversation',
|
|
1845
1850
|
]);
|
|
1846
1851
|
function tenantSuspendedError() {
|
|
1847
1852
|
return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
|
|
@@ -4525,6 +4530,159 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4525
4530
|
return true;
|
|
4526
4531
|
});
|
|
4527
4532
|
},
|
|
4533
|
+
async getAssistantSettings(params) {
|
|
4534
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4535
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4536
|
+
const [row] = await sql `
|
|
4537
|
+
SELECT provider, base_url, model, sealed_key, key_hint, updated_by, updated_at
|
|
4538
|
+
FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef}`;
|
|
4539
|
+
if (!row)
|
|
4540
|
+
return null;
|
|
4541
|
+
return {
|
|
4542
|
+
provider: row.provider,
|
|
4543
|
+
baseUrl: row.base_url,
|
|
4544
|
+
model: row.model,
|
|
4545
|
+
sealedKey: row.sealed_key,
|
|
4546
|
+
keyHint: row.key_hint,
|
|
4547
|
+
updatedBy: row.updated_by,
|
|
4548
|
+
updatedAt: row.updated_at,
|
|
4549
|
+
};
|
|
4550
|
+
},
|
|
4551
|
+
async putAssistantSettings(params) {
|
|
4552
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4553
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4554
|
+
const keep = params.key === 'keep';
|
|
4555
|
+
const key = params.key === 'keep' ? null : params.key;
|
|
4556
|
+
const saved = await sql `
|
|
4557
|
+
INSERT INTO slotlock.assistant_settings AS stored
|
|
4558
|
+
(tenant_ref, provider, base_url, model, sealed_key, key_hint, updated_by, updated_at)
|
|
4559
|
+
VALUES (${params.tenantRef}, ${params.provider}, ${params.baseUrl}, ${params.model},
|
|
4560
|
+
${key?.sealed ?? null}, ${key?.hint ?? null}, ${params.updatedBy ?? null},
|
|
4561
|
+
statement_timestamp())
|
|
4562
|
+
ON CONFLICT (tenant_ref) DO UPDATE SET
|
|
4563
|
+
provider = EXCLUDED.provider,
|
|
4564
|
+
base_url = EXCLUDED.base_url,
|
|
4565
|
+
model = EXCLUDED.model,
|
|
4566
|
+
sealed_key = CASE WHEN ${keep}::boolean THEN stored.sealed_key ELSE EXCLUDED.sealed_key END,
|
|
4567
|
+
key_hint = CASE WHEN ${keep}::boolean THEN stored.key_hint ELSE EXCLUDED.key_hint END,
|
|
4568
|
+
updated_by = EXCLUDED.updated_by,
|
|
4569
|
+
updated_at = EXCLUDED.updated_at
|
|
4570
|
+
WHERE NOT ${keep}::boolean
|
|
4571
|
+
OR stored.sealed_key IS NULL
|
|
4572
|
+
OR (stored.provider = EXCLUDED.provider AND stored.base_url = EXCLUDED.base_url)
|
|
4573
|
+
RETURNING 1`;
|
|
4574
|
+
return { saved: saved.length > 0 };
|
|
4575
|
+
},
|
|
4576
|
+
async deleteAssistantSettings(params) {
|
|
4577
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4578
|
+
return false;
|
|
4579
|
+
const rows = await sql `
|
|
4580
|
+
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
|
|
4581
|
+
return rows.length > 0;
|
|
4582
|
+
},
|
|
4583
|
+
async getAssistantConversation(params) {
|
|
4584
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4585
|
+
return null;
|
|
4586
|
+
const [row] = await sql `
|
|
4587
|
+
SELECT messages, actions, updated_at, expires_at FROM slotlock.assistant_conversations
|
|
4588
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
4589
|
+
AND expires_at > statement_timestamp()`;
|
|
4590
|
+
if (!row)
|
|
4591
|
+
return null;
|
|
4592
|
+
return {
|
|
4593
|
+
messages: row.messages,
|
|
4594
|
+
actions: row.actions,
|
|
4595
|
+
updatedAt: row.updated_at,
|
|
4596
|
+
expiresAt: row.expires_at,
|
|
4597
|
+
};
|
|
4598
|
+
},
|
|
4599
|
+
async putAssistantConversation(params) {
|
|
4600
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
|
|
4601
|
+
throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
|
|
4602
|
+
}
|
|
4603
|
+
if (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 86_400_000) {
|
|
4604
|
+
throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to a day');
|
|
4605
|
+
}
|
|
4606
|
+
const messages = sql.json(params.messages);
|
|
4607
|
+
const added = sql.json(params.added);
|
|
4608
|
+
const keep = [...params.keep];
|
|
4609
|
+
await sql `
|
|
4610
|
+
DELETE FROM slotlock.assistant_conversations
|
|
4611
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()`;
|
|
4612
|
+
await sql `
|
|
4613
|
+
INSERT INTO slotlock.assistant_conversations AS stored
|
|
4614
|
+
(tenant_ref, member_ref, messages, actions, updated_at, expires_at)
|
|
4615
|
+
VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
|
|
4616
|
+
statement_timestamp(),
|
|
4617
|
+
statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))
|
|
4618
|
+
ON CONFLICT (tenant_ref, member_ref) DO UPDATE SET
|
|
4619
|
+
messages = EXCLUDED.messages,
|
|
4620
|
+
-- The stored ones kept, as they stand, then the new ones: never a settled one again.
|
|
4621
|
+
actions = (
|
|
4622
|
+
SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
|
|
4623
|
+
FROM jsonb_each(stored.actions) AS existing
|
|
4624
|
+
WHERE existing.key = ANY(${keep}::text[])
|
|
4625
|
+
) || (
|
|
4626
|
+
SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
|
|
4627
|
+
FROM jsonb_each(EXCLUDED.actions) AS fresh
|
|
4628
|
+
WHERE NOT stored.actions ? fresh.key
|
|
4629
|
+
),
|
|
4630
|
+
updated_at = EXCLUDED.updated_at,
|
|
4631
|
+
expires_at = EXCLUDED.expires_at`;
|
|
4632
|
+
await sql `
|
|
4633
|
+
SELECT slotlock.register_assistant_tenant(
|
|
4634
|
+
${params.tenantRef}::text,
|
|
4635
|
+
statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))`;
|
|
4636
|
+
},
|
|
4637
|
+
async settleAssistantAction(params) {
|
|
4638
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4639
|
+
return null;
|
|
4640
|
+
const patch = sql.json(params.patch);
|
|
4641
|
+
const [row] = await sql `
|
|
4642
|
+
UPDATE slotlock.assistant_conversations
|
|
4643
|
+
SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
|
|
4644
|
+
(actions -> ${params.actionId}::text) || ${patch}::jsonb)
|
|
4645
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
4646
|
+
AND expires_at > statement_timestamp()
|
|
4647
|
+
AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
|
|
4648
|
+
RETURNING actions -> ${params.actionId}::text AS action`;
|
|
4649
|
+
return row?.action ?? null;
|
|
4650
|
+
},
|
|
4651
|
+
async pruneAssistantConversations(params) {
|
|
4652
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4653
|
+
return 0;
|
|
4654
|
+
const limit = params.limit ?? 1_000;
|
|
4655
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
|
|
4656
|
+
throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
|
|
4657
|
+
}
|
|
4658
|
+
const rows = await sql `
|
|
4659
|
+
DELETE FROM slotlock.assistant_conversations
|
|
4660
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
4661
|
+
AND member_ref IN (
|
|
4662
|
+
SELECT member_ref FROM slotlock.assistant_conversations
|
|
4663
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
|
|
4664
|
+
LIMIT ${limit})
|
|
4665
|
+
AND expires_at <= statement_timestamp()
|
|
4666
|
+
RETURNING 1`;
|
|
4667
|
+
return rows.length;
|
|
4668
|
+
},
|
|
4669
|
+
async sweepAssistantConversations(params) {
|
|
4670
|
+
const limit = params?.limit ?? 10_000;
|
|
4671
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
|
|
4672
|
+
throw invalidIdentityError('Slotlock assistant sweep limit must be 1-100,000');
|
|
4673
|
+
}
|
|
4674
|
+
const [row] = await sql `
|
|
4675
|
+
SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
|
|
4676
|
+
return Number(row?.deleted ?? 0);
|
|
4677
|
+
},
|
|
4678
|
+
async deleteAssistantConversation(params) {
|
|
4679
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4680
|
+
return false;
|
|
4681
|
+
const rows = await sql `
|
|
4682
|
+
DELETE FROM slotlock.assistant_conversations
|
|
4683
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef} RETURNING 1`;
|
|
4684
|
+
return rows.length > 0;
|
|
4685
|
+
},
|
|
4528
4686
|
async offerWaitlist(params) {
|
|
4529
4687
|
if (!isValidIdentity(params.tenantRef)) {
|
|
4530
4688
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
package/webhooks.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns';
|
|
1
2
|
import type { SlotlockChange, SlotlockEventFeed } from './event-feed.js';
|
|
2
3
|
/** The CloudEvents envelope a change is delivered and listed as. */
|
|
3
4
|
export interface SlotlockCloudEvent {
|
|
@@ -49,6 +50,29 @@ export declare function createSlotlockWebhookSender(options?: {
|
|
|
49
50
|
timeoutMs?: number;
|
|
50
51
|
allowPrivateNetworks?: boolean;
|
|
51
52
|
}): SlotlockWebhookSender;
|
|
53
|
+
/**
|
|
54
|
+
* A `dns.lookup` that answers every address a name resolves to, and refuses (an error with `code`)
|
|
55
|
+
* a name with any private one unless `allowPrivateNetworks`. Given to a socket's connect, it checks
|
|
56
|
+
* the addresses the connection will use, so a name rebound to a private address after an earlier
|
|
57
|
+
* check is still refused. `resolve` stands in for `dns.lookup` (tests).
|
|
58
|
+
*/
|
|
59
|
+
export declare function slotlockPrivateAddressLookup(options: {
|
|
60
|
+
allowPrivateNetworks: boolean;
|
|
61
|
+
code: string;
|
|
62
|
+
what: string;
|
|
63
|
+
resolve?: typeof dnsLookup;
|
|
64
|
+
}): typeof dnsLookup;
|
|
65
|
+
/**
|
|
66
|
+
* A `fetch` for a server reaching a URL a tenant chose (the dashboard assistant's model): Node's
|
|
67
|
+
* http client with the webhooks' address check. A URL naming a private address, or a host that
|
|
68
|
+
* resolves to one when the socket connects, is refused with an error whose `code` is
|
|
69
|
+
* `private_address`, unless `allowPrivateNetworks`. No redirect is followed (the answer is
|
|
70
|
+
* returned as is) and no proxy from the environment is used. Takes a string body only.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createSlotlockGuardedFetch(options?: {
|
|
73
|
+
allowPrivateNetworks?: boolean;
|
|
74
|
+
resolve?: typeof dnsLookup;
|
|
75
|
+
}): typeof fetch;
|
|
52
76
|
export interface SlotlockWebhookPassResult {
|
|
53
77
|
/** Changes an endpoint accepted. */
|
|
54
78
|
delivered: number;
|
package/webhooks.js
CHANGED
|
@@ -8,6 +8,7 @@ import { lookup as dnsLookup } from 'node:dns';
|
|
|
8
8
|
import { Agent as HttpAgent, request as httpRequest } from 'node:http';
|
|
9
9
|
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
|
|
10
10
|
import { BlockList, isIP } from 'node:net';
|
|
11
|
+
import { Readable } from 'node:stream';
|
|
11
12
|
import { ignoreRejection } from './hooks.js';
|
|
12
13
|
/** A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`. */
|
|
13
14
|
export function slotlockCloudEvent(change) {
|
|
@@ -104,28 +105,16 @@ export function isPrivateAddress(address) {
|
|
|
104
105
|
*/
|
|
105
106
|
export function createSlotlockWebhookSender(options = {}) {
|
|
106
107
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
107
|
-
const lookup = (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const list = addresses;
|
|
112
|
-
if (!options.allowPrivateNetworks && list.some(({ address }) => isPrivateAddress(address))) {
|
|
113
|
-
return callback(Object.assign(new Error(`webhook host ${hostname} resolves to a private address`), { code: 'webhook_private_address' }));
|
|
114
|
-
}
|
|
115
|
-
const all = lookupOptions.all;
|
|
116
|
-
if (all)
|
|
117
|
-
return callback(null, list);
|
|
118
|
-
const first = list[0];
|
|
119
|
-
return first ? callback(null, first.address, first.family) : callback(new Error('no address'));
|
|
120
|
-
});
|
|
108
|
+
const lookup = slotlockPrivateAddressLookup({
|
|
109
|
+
allowPrivateNetworks: options.allowPrivateNetworks === true,
|
|
110
|
+
code: 'webhook_private_address',
|
|
111
|
+
what: 'webhook',
|
|
121
112
|
});
|
|
122
113
|
return (request) => new Promise((resolve, reject) => {
|
|
123
114
|
const url = new URL(request.url);
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
115
|
+
if (!options.allowPrivateNetworks && namesPrivateAddress(url)) {
|
|
116
|
+
reject(Object.assign(new Error('webhook URL names a private address'), { code: 'webhook_private_address' }));
|
|
117
|
+
return;
|
|
129
118
|
}
|
|
130
119
|
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
131
120
|
const outgoing = send(url, {
|
|
@@ -164,6 +153,94 @@ export function createSlotlockWebhookSender(options = {}) {
|
|
|
164
153
|
outgoing.end(request.body);
|
|
165
154
|
});
|
|
166
155
|
}
|
|
156
|
+
/** Whether a URL's host is an IP address literal that isPrivateAddress refuses. */
|
|
157
|
+
function namesPrivateAddress(url) {
|
|
158
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
159
|
+
return isIP(host) !== 0 && isPrivateAddress(host);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A `dns.lookup` that answers every address a name resolves to, and refuses (an error with `code`)
|
|
163
|
+
* a name with any private one unless `allowPrivateNetworks`. Given to a socket's connect, it checks
|
|
164
|
+
* the addresses the connection will use, so a name rebound to a private address after an earlier
|
|
165
|
+
* check is still refused. `resolve` stands in for `dns.lookup` (tests).
|
|
166
|
+
*/
|
|
167
|
+
export function slotlockPrivateAddressLookup(options) {
|
|
168
|
+
const resolve = options.resolve ?? dnsLookup;
|
|
169
|
+
return ((hostname, lookupOptions, callback) => {
|
|
170
|
+
resolve(hostname, { ...lookupOptions, all: true }, (error, addresses) => {
|
|
171
|
+
if (error)
|
|
172
|
+
return callback(error);
|
|
173
|
+
const list = addresses;
|
|
174
|
+
if (!options.allowPrivateNetworks && list.some(({ address }) => isPrivateAddress(address))) {
|
|
175
|
+
return callback(Object.assign(new Error(`${options.what} host ${hostname} resolves to a private address`), { code: options.code }));
|
|
176
|
+
}
|
|
177
|
+
const all = lookupOptions.all;
|
|
178
|
+
if (all)
|
|
179
|
+
return callback(null, list);
|
|
180
|
+
const first = list[0];
|
|
181
|
+
return first ? callback(null, first.address, first.family) : callback(new Error('no address'));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A `fetch` for a server reaching a URL a tenant chose (the dashboard assistant's model): Node's
|
|
187
|
+
* http client with the webhooks' address check. A URL naming a private address, or a host that
|
|
188
|
+
* resolves to one when the socket connects, is refused with an error whose `code` is
|
|
189
|
+
* `private_address`, unless `allowPrivateNetworks`. No redirect is followed (the answer is
|
|
190
|
+
* returned as is) and no proxy from the environment is used. Takes a string body only.
|
|
191
|
+
*/
|
|
192
|
+
export function createSlotlockGuardedFetch(options = {}) {
|
|
193
|
+
const allow = options.allowPrivateNetworks === true;
|
|
194
|
+
const lookup = slotlockPrivateAddressLookup({
|
|
195
|
+
allowPrivateNetworks: allow,
|
|
196
|
+
code: 'private_address',
|
|
197
|
+
what: 'requested',
|
|
198
|
+
...(options.resolve ? { resolve: options.resolve } : {}),
|
|
199
|
+
});
|
|
200
|
+
const refused = () => Object.assign(new Error('the URL names a private address'), { code: 'private_address' });
|
|
201
|
+
return (async (input, init = {}) => {
|
|
202
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
203
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
204
|
+
throw new TypeError('unsupported URL');
|
|
205
|
+
if (!allow && namesPrivateAddress(url))
|
|
206
|
+
throw refused();
|
|
207
|
+
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
|
|
208
|
+
const headers = {};
|
|
209
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
210
|
+
headers[name] = value;
|
|
211
|
+
});
|
|
212
|
+
if (body !== undefined)
|
|
213
|
+
headers['content-length'] = String(Buffer.byteLength(body));
|
|
214
|
+
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
const outgoing = send(url, {
|
|
217
|
+
method: init.method ?? 'GET',
|
|
218
|
+
headers,
|
|
219
|
+
// An agent of its own: a proxy from the environment would skip the address check.
|
|
220
|
+
agent: url.protocol === 'https:' ? new HttpsAgent({ lookup }) : new HttpAgent({ lookup }),
|
|
221
|
+
lookup,
|
|
222
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
223
|
+
}, (response) => {
|
|
224
|
+
const status = response.statusCode ?? 0;
|
|
225
|
+
const answer = new Headers();
|
|
226
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
227
|
+
for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
228
|
+
answer.append(name, each);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (status < 200 || status > 599) {
|
|
232
|
+
response.destroy();
|
|
233
|
+
reject(new Error(`unexpected HTTP status ${status}`));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const stream = status === 204 || status === 304 ? null : Readable.toWeb(response);
|
|
237
|
+
resolve(new Response(stream, { status, headers: answer }));
|
|
238
|
+
});
|
|
239
|
+
outgoing.on('error', reject);
|
|
240
|
+
outgoing.end(body);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
167
244
|
/**
|
|
168
245
|
* One delivery pass for a tenant: claim each endpoint with changes due, send them in order until
|
|
169
246
|
* one fails, and record how far each got. A 2xx is delivered; 410 disables the endpoint; anything
|