slotlock 0.5.1 → 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 +24 -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 +199 -2
- 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/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
|