slotlock 0.9.0 → 0.10.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 (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. package/workspaces.js +433 -0
package/types.d.ts CHANGED
@@ -431,6 +431,11 @@ export type AcquireHoldResult =
431
431
  | {
432
432
  ok: false;
433
433
  code: 'invalid_payment';
434
+ }
435
+ /** An agent's `payment` that is not the resource's hold price, or where it sets none. */
436
+ | {
437
+ ok: false;
438
+ code: 'payment_price_mismatch';
434
439
  } | {
435
440
  ok: false;
436
441
  code: 'invalid_buffer';
@@ -503,6 +508,11 @@ export type ConfirmHoldResult = {
503
508
  ok: false;
504
509
  code: 'payment_required';
505
510
  }
511
+ /** A calendar reported its time busy (RFC 0004 C1) after the hold was made. */
512
+ | {
513
+ ok: false;
514
+ code: 'overlap';
515
+ }
506
516
  /** A cancellation of the hold's reference (its id) arrived first and retired it. */
507
517
  | {
508
518
  ok: false;
@@ -583,15 +593,21 @@ export type MarkHoldPaidResult = {
583
593
  ok: false;
584
594
  code: 'hold_not_found' | 'hold_expired';
585
595
  }
596
+ /** Paid, but the hold ended without a booking: the payment was never applied. */
597
+ | {
598
+ ok: false;
599
+ code: 'hold_ended';
600
+ }
586
601
  /** The hold does not wait for payment. */
587
602
  | {
588
603
  ok: false;
589
604
  code: 'payment_not_required';
590
605
  }
591
- /** It was already paid by another payment. */
606
+ /** It was already paid by another payment, `paymentRef`. */
592
607
  | {
593
608
  ok: false;
594
609
  code: 'payment_conflict';
610
+ paymentRef: string;
595
611
  };
596
612
  /** RFC 5545 TRANSP projection. Transparent events remain visible but never occupy a resource. */
597
613
  export type CalendarTransparency = 'opaque' | 'transparent';
@@ -970,3 +986,19 @@ export interface CalendarFreeBusy {
970
986
  busy: Interval[];
971
987
  coverage: CalendarCoverageAssessment;
972
988
  }
989
+ /**
990
+ * The tables a subject export lists and an erasure counts (RFC 0004 D3), in that order: what can
991
+ * name one person.
992
+ */
993
+ export declare const SLOTLOCK_SUBJECT_TABLES: readonly ["contacts", "calendar_event_attendees", "calendar_events", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_event_tombstones", "calendar_event_commands", "waitlist_entries", "approvals", "outbox"];
994
+ export type SlotlockSubjectTable = (typeof SLOTLOCK_SUBJECT_TABLES)[number];
995
+ /**
996
+ * What a tenant export holds (RFC 0004 D3), in this order: each table's rows as kept, without the
997
+ * tenant and without secrets (a webhook's signing secret, the assistant's sealed provider key, API
998
+ * key digests: `api_keys` is each key's name, prefix, scopes and dates). Every other tenant table
999
+ * is in SLOTLOCK_TENANT_EXPORT_LEFT_OUT, with why.
1000
+ */
1001
+ export declare const SLOTLOCK_TENANT_EXPORT_TABLES: readonly ["resources", "locations", "resource_groups", "resource_group_members", "pool_settings", "booking_rule_defaults", "availability_defaults", "availability_exceptions", "holiday_calendars", "reservations", "reservation_tombstones", "reservation_conflict_archive", "hold_payments", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_tombstones", "calendar_coverage", "source_busy", "waitlist_entries", "approval_policies", "approvals", "contacts", "webhook_endpoints", "assistant_settings", "api_keys", "outbox", "audit_events"];
1002
+ export type SlotlockTenantExportTable = (typeof SLOTLOCK_TENANT_EXPORT_TABLES)[number];
1003
+ /** The tenant tables an export leaves out, and why: each is derived, internal or one member's own. */
1004
+ export declare const SLOTLOCK_TENANT_EXPORT_LEFT_OUT: Readonly<Record<string, string>>;
package/types.js CHANGED
@@ -1 +1,68 @@
1
- export {};
1
+ /**
2
+ * The tables a subject export lists and an erasure counts (RFC 0004 D3), in that order: what can
3
+ * name one person.
4
+ */
5
+ export const SLOTLOCK_SUBJECT_TABLES = Object.freeze([
6
+ 'contacts',
7
+ 'calendar_event_attendees',
8
+ 'calendar_events',
9
+ 'reservations',
10
+ 'reservation_tombstones',
11
+ 'reservation_conflict_archive',
12
+ 'calendar_event_tombstones',
13
+ 'calendar_event_commands',
14
+ 'waitlist_entries',
15
+ 'approvals',
16
+ 'outbox',
17
+ ]);
18
+ /**
19
+ * What a tenant export holds (RFC 0004 D3), in this order: each table's rows as kept, without the
20
+ * tenant and without secrets (a webhook's signing secret, the assistant's sealed provider key, API
21
+ * key digests: `api_keys` is each key's name, prefix, scopes and dates). Every other tenant table
22
+ * is in SLOTLOCK_TENANT_EXPORT_LEFT_OUT, with why.
23
+ */
24
+ export const SLOTLOCK_TENANT_EXPORT_TABLES = Object.freeze([
25
+ 'resources',
26
+ 'locations',
27
+ 'resource_groups',
28
+ 'resource_group_members',
29
+ 'pool_settings',
30
+ 'booking_rule_defaults',
31
+ 'availability_defaults',
32
+ 'availability_exceptions',
33
+ 'holiday_calendars',
34
+ 'reservations',
35
+ 'reservation_tombstones',
36
+ 'reservation_conflict_archive',
37
+ 'hold_payments',
38
+ 'calendar_events',
39
+ 'calendar_event_attendees',
40
+ 'calendar_event_reminders',
41
+ 'calendar_event_exceptions',
42
+ 'calendar_event_tombstones',
43
+ 'calendar_coverage',
44
+ 'source_busy',
45
+ 'waitlist_entries',
46
+ 'approval_policies',
47
+ 'approvals',
48
+ 'contacts',
49
+ 'webhook_endpoints',
50
+ 'assistant_settings',
51
+ 'api_keys',
52
+ 'outbox',
53
+ 'audit_events',
54
+ ]);
55
+ /** The tenant tables an export leaves out, and why: each is derived, internal or one member's own. */
56
+ export const SLOTLOCK_TENANT_EXPORT_LEFT_OUT = Object.freeze({
57
+ calendar_event_occurrences: "derived from each event's time and recurrence",
58
+ calendar_event_commands: 'records of retried writes',
59
+ hold_commands: 'records of retried writes',
60
+ outbox_pruned: 'where the history was last pruned to',
61
+ composite_claims: 'derived from bookings',
62
+ reminder_dispatches: 'which reminders were put on the feed',
63
+ reservation_reminder_dispatches: 'which reminders were put on the feed',
64
+ assistant_conversations: "each member's own conversations with the assistant",
65
+ assistant_chats: "each member's own conversations with the assistant",
66
+ tenant_exports: 'the exports themselves',
67
+ tenant_export_parts: 'the exports themselves',
68
+ });
@@ -0,0 +1,146 @@
1
+ import { type SlotlockDnsResolver, type SlotlockDomainCheck } from './domains.js';
2
+ import type { SlotlockAuditEventInput, SlotlockSql } from './store.js';
3
+ import { type SlotlockWorkspaceRole } from './workspace-roles.js';
4
+ /** Why a change to a team's domains, or a request to join, was refused. */
5
+ export type SlotlockDomainChangeRefusal = 'invalid_domain' | 'public_suffix' | 'consumer_domain' | 'not_found' | 'not_allowed' | 'already_claimed' | 'domain_taken' | 'domain_limit' | 'too_soon' | 'expired' | 'restricted' | 'no_verified_domain' | 'already_member' | 'pending' | 'request_limit' | 'member_cap' | 'role_not_allowed' | 'domain_not_verified';
6
+ /**
7
+ * How the addresses a person brings were verified: by a Google Workspace account of their domain
8
+ * (Google's `hd` claim; the company controls it), or by a Google or GitHub account that verified the
9
+ * address once and never checks again. Only the first may join at once.
10
+ */
11
+ export type SlotlockJoinEvidence = 'google_workspace' | 'google' | 'github';
12
+ /** What a domain lets the people whose verified address is at it do. */
13
+ export type SlotlockDomainJoinPolicy = 'off' | 'request' | 'auto';
14
+ /** A team's domain as its Identity page shows it. */
15
+ export interface SlotlockWorkspaceDomain {
16
+ id: string;
17
+ ascii: string;
18
+ unicode: string;
19
+ /** `pending` (with `expired` past its seven days), `verified`, or `lapsing` (its record has gone). */
20
+ status: 'pending' | 'verified' | 'lapsing';
21
+ expired: boolean;
22
+ /** The token the record must hold: public, since it is published. */
23
+ token: string;
24
+ joinPolicy: SlotlockDomainJoinPolicy;
25
+ createdAt: Date;
26
+ verifiedAt: Date | null;
27
+ lastCheckedAt: Date | null;
28
+ missingSince: Date | null;
29
+ errorSince: Date | null;
30
+ lapseAfter: Date | null;
31
+ }
32
+ /** A team a person may ask to join, or join, through a verified address. */
33
+ export interface SlotlockDiscoverableWorkspace {
34
+ tenantRef: string;
35
+ handle: string;
36
+ name: string;
37
+ /** As people read it. */
38
+ domain: string;
39
+ /** As addresses are matched on it (ASCII). */
40
+ domainKey: string;
41
+ joinPolicy: 'request' | 'auto';
42
+ requested: boolean;
43
+ }
44
+ /** A request waiting on a team. */
45
+ export interface SlotlockJoinRequest {
46
+ id: string;
47
+ person: string;
48
+ display: string | null;
49
+ email: string;
50
+ domain: string;
51
+ createdAt: Date;
52
+ expiresAt: Date;
53
+ evidence: SlotlockJoinEvidence;
54
+ }
55
+ /** What one claim's check did, for the audit log and the people told. */
56
+ export interface SlotlockDomainCheckOutcome {
57
+ domainId: string;
58
+ tenantRef: string | null;
59
+ domain: string | null;
60
+ /**
61
+ * `verified`, `restored` (a lapsing domain's new record found), `seen` (still there), `missing`
62
+ * (gone once), `lapsing` (gone a day: a new token issued), `lapsed`, `absent` (a pending claim's
63
+ * record not there yet), `unchanged`, `error`; or a refusal: `domain_taken`, `too_soon`,
64
+ * `expired`, `not_found`, `not_allowed`.
65
+ */
66
+ outcome: string;
67
+ check: SlotlockDomainCheck | null;
68
+ }
69
+ export interface SlotlockWorkspaceDomains {
70
+ /** Claim a domain for a team (its Owner), with a new token. The domain as typed: normalised here. */
71
+ claim(tenantRef: string, actor: string, typed: string): Promise<{
72
+ id: string;
73
+ } | {
74
+ refusal: SlotlockDomainChangeRefusal;
75
+ }>;
76
+ list(tenantRef: string): Promise<SlotlockWorkspaceDomain[]>;
77
+ /** An Owner's "Verify now": the record looked up and the outcome applied (at most once a minute). */
78
+ verifyNow(tenantRef: string, actor: string, id: string, resolvers: readonly SlotlockDnsResolver[]): Promise<SlotlockDomainCheckOutcome>;
79
+ /**
80
+ * The daily check: up to `limit` claims due, each looked up and applied. `seen` is told each claim's
81
+ * missing_since before its check (null when its record was there at the last one).
82
+ */
83
+ checkDue(resolvers: readonly SlotlockDnsResolver[], limit?: number, seen?: (id: string, missingSince: Date | null) => void): Promise<SlotlockDomainCheckOutcome[]>;
84
+ release(tenantRef: string, actor: string, id: string): Promise<SlotlockDomainChangeRefusal | null>;
85
+ restrictInvites(tenantRef: string, actor: string, on: boolean): Promise<SlotlockDomainChangeRefusal | null>;
86
+ /** Whether a team's invitations are held to its verified domains. */
87
+ invitesRestricted(tenantRef: string): Promise<boolean>;
88
+ setJoinPolicy(tenantRef: string, actor: string, id: string, policy: SlotlockDomainJoinPolicy): Promise<SlotlockDomainChangeRefusal | null>;
89
+ /** The teams a person's verified addresses let them ask to join. */
90
+ discoverable(person: string, emails: readonly string[]): Promise<SlotlockDiscoverableWorkspace[]>;
91
+ requestToJoin(tenantRef: string, person: string, emails: readonly string[], evidence: SlotlockJoinEvidence, memberCap: number | null): Promise<{
92
+ outcome: 'joined' | 'requested';
93
+ } | {
94
+ refusal: SlotlockDomainChangeRefusal;
95
+ }>;
96
+ requests(tenantRef: string): Promise<SlotlockJoinRequest[]>;
97
+ decide(params: {
98
+ tenantRef: string;
99
+ actor: string;
100
+ id: string;
101
+ decision: 'approve' | 'decline';
102
+ role: SlotlockWorkspaceRole | null;
103
+ memberCap: number | null;
104
+ }): Promise<{
105
+ person: string | null;
106
+ refusal: SlotlockDomainChangeRefusal | null;
107
+ }>;
108
+ /** Members a domain let in: with the domain, and whether the team still holds it. */
109
+ domainMembers(tenantRef: string): Promise<Map<string, {
110
+ domain: string;
111
+ status: string;
112
+ }>>;
113
+ prune(): Promise<number>;
114
+ }
115
+ /**
116
+ * The addresses as matched: lowercase, the domain in its ASCII form, the domain claimable at all
117
+ * (a consumer address matches no team). At most ten.
118
+ */
119
+ export declare function slotlockMatchableEmails(emails: readonly string[]): string[];
120
+ /** Through the functions of ddl-identity.ts. */
121
+ export declare function createSlotlockWorkspaceDomains(sql: SlotlockSql): SlotlockWorkspaceDomains;
122
+ /** The role a request may be approved as: Viewer, Member or Developer. */
123
+ export declare function slotlockJoinRole(value: string | null): SlotlockWorkspaceRole | null;
124
+ /** The audit log's actions for a team's domains (its Identity group). */
125
+ export declare const SLOTLOCK_DOMAIN_AUDIT_ACTIONS: readonly ["domain.claimed", "domain.verified", "domain.record_missing", "domain.lapsing", "domain.restored", "domain.lapsed", "domain.released", "domain.join_policy_changed", "workspace.invites_restricted", "workspace.invites_unrestricted"];
126
+ /**
127
+ * The audit event a check that changed something records: which domain, and what each resolver said
128
+ * (public DNS data). A domain gone for the first time is recorded once (`missing` while it had been
129
+ * seen); repeats and checks that changed nothing are not.
130
+ */
131
+ export declare function slotlockDomainCheckEvent(found: SlotlockDomainCheckOutcome, firstMissing: boolean): {
132
+ action: string;
133
+ targetType: 'domain';
134
+ targetRef: string;
135
+ metadata: Record<string, unknown>;
136
+ } | null;
137
+ /**
138
+ * The daily check, from the server's maintenance: the claims due looked up and applied, each change
139
+ * recorded in its team's audit log as Slotlock's. How many were checked.
140
+ */
141
+ export declare function runSlotlockDomainChecks(params: {
142
+ domains: SlotlockWorkspaceDomains;
143
+ resolvers: readonly SlotlockDnsResolver[];
144
+ record?: (tenantRef: string, event: SlotlockAuditEventInput) => Promise<void>;
145
+ limit?: number;
146
+ }): Promise<number>;
@@ -0,0 +1,249 @@
1
+ // A team's verified domains and the requests to join that come through them (teams phase 5), as
2
+ // the dashboard and the daily check reach them: through the SECURITY DEFINER functions of
3
+ // ddl-identity.ts, which check the actor's role and lock the team themselves. The DNS side, and
4
+ // the normaliser every domain and address goes through, is domains.ts.
5
+ import { checkSlotlockDomain, normalizeSlotlockDomain, slotlockDomainToken, slotlockEmailDomain, } from './domains.js';
6
+ import { isSlotlockWorkspaceRole } from './workspace-roles.js';
7
+ const PERSON = /^(github:[1-9]\d{0,19}|google:[A-Za-z0-9._-]{1,255})$/;
8
+ const TEAM = /^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ const refusalOf = (value) => value === null || value === undefined ? null : value;
11
+ /**
12
+ * The addresses as matched: lowercase, the domain in its ASCII form, the domain claimable at all
13
+ * (a consumer address matches no team). At most ten.
14
+ */
15
+ export function slotlockMatchableEmails(emails) {
16
+ const matchable = [];
17
+ for (const email of emails.slice(0, 10)) {
18
+ const at = email.lastIndexOf('@');
19
+ const domain = slotlockEmailDomain(email);
20
+ if (at < 1 || domain === null)
21
+ continue;
22
+ matchable.push(`${email.slice(0, at).toLowerCase()}@${domain}`);
23
+ }
24
+ return matchable;
25
+ }
26
+ /** Through the functions of ddl-identity.ts. */
27
+ export function createSlotlockWorkspaceDomains(sql) {
28
+ async function list(tenantRef) {
29
+ if (!TEAM.test(tenantRef))
30
+ return [];
31
+ const rows = await sql `SELECT * FROM slotlock.workspace_domains_list(${tenantRef}::text)`;
32
+ return rows.map((row) => ({
33
+ id: row.domain_id,
34
+ ascii: row.domain_ascii,
35
+ unicode: row.domain_unicode,
36
+ status: row.status,
37
+ expired: row.expired,
38
+ token: row.token,
39
+ joinPolicy: row.join_policy,
40
+ createdAt: row.created_at,
41
+ verifiedAt: row.verified_at,
42
+ lastCheckedAt: row.last_checked_at,
43
+ missingSince: row.missing_since,
44
+ errorSince: row.error_since,
45
+ lapseAfter: row.lapse_after,
46
+ }));
47
+ }
48
+ async function apply(id, actor, token, check) {
49
+ const [row] = await sql `
50
+ SELECT outcome, team_ref, domain_name FROM slotlock.record_domain_check(
51
+ ${id}::uuid, ${actor}::text, ${token}::text, ${check.primary}::text, ${check.secondary}::text,
52
+ ${slotlockDomainToken()}::text)`;
53
+ return { domainId: id, tenantRef: row?.team_ref ?? null, domain: row?.domain_name ?? null, outcome: row?.outcome ?? 'not_found', check };
54
+ }
55
+ return {
56
+ async claim(tenantRef, actor, typed) {
57
+ if (!TEAM.test(tenantRef) || !PERSON.test(actor))
58
+ return { refusal: 'not_found' };
59
+ const domain = normalizeSlotlockDomain(typed);
60
+ if ('refusal' in domain)
61
+ return { refusal: domain.refusal };
62
+ const [row] = await sql `
63
+ SELECT claimed_id, refusal FROM slotlock.claim_domain(
64
+ ${tenantRef}::text, ${actor}::text, ${domain.ascii}::text, ${domain.unicode}::text, ${slotlockDomainToken()}::text)`;
65
+ if (row?.refusal)
66
+ return { refusal: refusalOf(row.refusal) ?? 'not_allowed' };
67
+ return row?.claimed_id ? { id: row.claimed_id } : { refusal: 'not_allowed' };
68
+ },
69
+ list,
70
+ async verifyNow(tenantRef, actor, id, resolvers) {
71
+ const none = { domainId: id, tenantRef: null, domain: null, check: null };
72
+ if (!TEAM.test(tenantRef) || !PERSON.test(actor) || !UUID.test(id))
73
+ return { ...none, outcome: 'not_found' };
74
+ const claim = (await list(tenantRef)).find((domain) => domain.id === id);
75
+ if (!claim)
76
+ return { ...none, outcome: 'not_found' };
77
+ if (claim.expired)
78
+ return { ...none, outcome: 'expired' };
79
+ if (claim.lastCheckedAt !== null && Date.now() - claim.lastCheckedAt.getTime() < 60_000)
80
+ return { ...none, outcome: 'too_soon' };
81
+ return apply(id, actor, claim.token, await checkSlotlockDomain(resolvers, claim.ascii, claim.token));
82
+ },
83
+ async checkDue(resolvers, limit = 50, seen) {
84
+ const due = await sql `
85
+ SELECT domain_id, domain_ascii, token, missing_since FROM slotlock.domains_due(${limit}::integer)`;
86
+ const outcomes = [];
87
+ for (const claim of due) {
88
+ seen?.(claim.domain_id, claim.missing_since);
89
+ outcomes.push(await apply(claim.domain_id, null, claim.token, await checkSlotlockDomain(resolvers, claim.domain_ascii, claim.token)));
90
+ }
91
+ return outcomes;
92
+ },
93
+ async release(tenantRef, actor, id) {
94
+ if (!UUID.test(id))
95
+ return 'not_found';
96
+ const [row] = await sql `
97
+ SELECT slotlock.release_domain(${tenantRef}::text, ${actor}::text, ${id}::uuid) AS refusal`;
98
+ return refusalOf(row?.refusal);
99
+ },
100
+ async restrictInvites(tenantRef, actor, on) {
101
+ const [row] = await sql `
102
+ SELECT slotlock.set_invite_domain_restriction(${tenantRef}::text, ${actor}::text, ${on}::boolean) AS refusal`;
103
+ return refusalOf(row?.refusal);
104
+ },
105
+ async invitesRestricted(tenantRef) {
106
+ if (!TEAM.test(tenantRef))
107
+ return false;
108
+ const [state] = await sql `
109
+ SELECT slotlock.workspace_invites_restricted(${tenantRef}::text) AS restricted`;
110
+ return state?.restricted === true;
111
+ },
112
+ async setJoinPolicy(tenantRef, actor, id, policy) {
113
+ if (!UUID.test(id))
114
+ return 'not_found';
115
+ const [row] = await sql `
116
+ SELECT slotlock.set_domain_join_policy(${tenantRef}::text, ${actor}::text, ${id}::uuid, ${policy}::text) AS refusal`;
117
+ return refusalOf(row?.refusal);
118
+ },
119
+ async discoverable(person, emails) {
120
+ const matchable = slotlockMatchableEmails(emails);
121
+ if (!PERSON.test(person) || matchable.length === 0)
122
+ return [];
123
+ const rows = await sql `SELECT team_ref, handle, name, domain, domain_key, join_policy, requested
124
+ FROM slotlock.discoverable_workspaces(${person}::text, ${matchable}::text[])`;
125
+ return rows.map((row) => ({
126
+ tenantRef: row.team_ref,
127
+ handle: row.handle,
128
+ name: row.name,
129
+ domain: row.domain,
130
+ domainKey: row.domain_key,
131
+ joinPolicy: row.join_policy,
132
+ requested: row.requested,
133
+ }));
134
+ },
135
+ async requestToJoin(tenantRef, person, emails, evidence, memberCap) {
136
+ const matchable = slotlockMatchableEmails(emails);
137
+ if (!TEAM.test(tenantRef) || !PERSON.test(person) || matchable.length === 0)
138
+ return { refusal: 'not_found' };
139
+ const [row] = await sql `
140
+ SELECT outcome, refusal FROM slotlock.request_to_join(
141
+ ${tenantRef}::text, ${person}::text, ${matchable}::text[], ${evidence}::text, ${memberCap}::integer)`;
142
+ return row?.outcome ? { outcome: row.outcome } : { refusal: refusalOf(row?.refusal) ?? 'not_found' };
143
+ },
144
+ async requests(tenantRef) {
145
+ if (!TEAM.test(tenantRef))
146
+ return [];
147
+ const rows = await sql `SELECT * FROM slotlock.workspace_join_requests_list(${tenantRef}::text)`;
148
+ return rows.map((row) => ({
149
+ id: row.request_id,
150
+ person: row.person_ref,
151
+ display: row.display,
152
+ email: row.email,
153
+ domain: row.domain,
154
+ createdAt: row.created_at,
155
+ expiresAt: row.expires_at,
156
+ evidence: row.evidence,
157
+ }));
158
+ },
159
+ async decide({ tenantRef, actor, id, decision, role, memberCap }) {
160
+ if (!UUID.test(id))
161
+ return { person: null, refusal: 'not_found' };
162
+ const [row] = await sql `
163
+ SELECT refusal, joined_person FROM slotlock.decide_join_request(
164
+ ${tenantRef}::text, ${actor}::text, ${id}::uuid, ${decision}::text, ${role}::text, ${memberCap}::integer)`;
165
+ return { person: row?.joined_person ?? null, refusal: refusalOf(row?.refusal) };
166
+ },
167
+ async domainMembers(tenantRef) {
168
+ const found = new Map();
169
+ if (!TEAM.test(tenantRef))
170
+ return found;
171
+ const rows = await sql `
172
+ SELECT person_ref, domain, domain_status FROM slotlock.workspace_domain_members(${tenantRef}::text)`;
173
+ for (const row of rows)
174
+ found.set(row.person_ref, { domain: row.domain, status: row.domain_status });
175
+ return found;
176
+ },
177
+ async prune() {
178
+ const [row] = await sql `SELECT slotlock.prune_workspace_domains() AS pruned`;
179
+ return Number(row?.pruned ?? 0);
180
+ },
181
+ };
182
+ }
183
+ /** The role a request may be approved as: Viewer, Member or Developer. */
184
+ export function slotlockJoinRole(value) {
185
+ return isSlotlockWorkspaceRole(value) && (value === 'viewer' || value === 'member' || value === 'developer') ? value : null;
186
+ }
187
+ /** The audit log's actions for a team's domains (its Identity group). */
188
+ export const SLOTLOCK_DOMAIN_AUDIT_ACTIONS = Object.freeze([
189
+ 'domain.claimed',
190
+ 'domain.verified',
191
+ 'domain.record_missing',
192
+ 'domain.lapsing',
193
+ 'domain.restored',
194
+ 'domain.lapsed',
195
+ 'domain.released',
196
+ 'domain.join_policy_changed',
197
+ 'workspace.invites_restricted',
198
+ 'workspace.invites_unrestricted',
199
+ ]);
200
+ /** The action a check's outcome records, or null for one that changed nothing. */
201
+ const CHECK_ACTIONS = Object.freeze({
202
+ verified: 'domain.verified',
203
+ restored: 'domain.restored',
204
+ lapsing: 'domain.lapsing',
205
+ lapsed: 'domain.lapsed',
206
+ });
207
+ /**
208
+ * The audit event a check that changed something records: which domain, and what each resolver said
209
+ * (public DNS data). A domain gone for the first time is recorded once (`missing` while it had been
210
+ * seen); repeats and checks that changed nothing are not.
211
+ */
212
+ export function slotlockDomainCheckEvent(found, firstMissing) {
213
+ const action = CHECK_ACTIONS[found.outcome] ?? (found.outcome === 'missing' && firstMissing ? 'domain.record_missing' : null);
214
+ if (action === null || found.domain === null)
215
+ return null;
216
+ return {
217
+ action,
218
+ targetType: 'domain',
219
+ targetRef: found.domainId,
220
+ metadata: {
221
+ domain: found.domain,
222
+ resolvers: (found.check?.detail ?? []).map((asked) => ({
223
+ resolver: asked.resolver,
224
+ verdict: asked.verdict,
225
+ ad: asked.ad,
226
+ ttl: asked.ttl,
227
+ values: asked.values,
228
+ ...(asked.reason !== null ? { reason: asked.reason } : {}),
229
+ })),
230
+ },
231
+ };
232
+ }
233
+ /**
234
+ * The daily check, from the server's maintenance: the claims due looked up and applied, each change
235
+ * recorded in its team's audit log as Slotlock's. How many were checked.
236
+ */
237
+ export async function runSlotlockDomainChecks(params) {
238
+ const before = new Map();
239
+ const outcomes = await params.domains.checkDue(params.resolvers, params.limit ?? 50, (id, missingSince) => {
240
+ before.set(id, missingSince);
241
+ });
242
+ for (const found of outcomes) {
243
+ const event = slotlockDomainCheckEvent(found, before.get(found.domainId) === null);
244
+ if (event === null || found.tenantRef === null || !params.record)
245
+ continue;
246
+ await params.record(found.tenantRef, { ...event, actorType: 'system', actorRef: 'slotlock' }).catch(() => undefined);
247
+ }
248
+ return outcomes.length;
249
+ }
@@ -0,0 +1,47 @@
1
+ /** The roles, most powerful first. */
2
+ export declare const SLOTLOCK_WORKSPACE_ROLES: readonly ["owner", "admin", "developer", "member", "viewer", "billing"];
3
+ export type SlotlockWorkspaceRole = (typeof SLOTLOCK_WORKSPACE_ROLES)[number];
4
+ /** Each role as the dashboard names it. */
5
+ export declare const SLOTLOCK_WORKSPACE_ROLE_NAMES: Readonly<Record<SlotlockWorkspaceRole, string>>;
6
+ export declare const SLOTLOCK_WORKSPACE_CAPABILITIES: readonly ["account.use", "workspace.read", "usage.read", "assistant.chat", "operations.run", "resources.manage", "sandbox.manage", "keys.manage_own", "keys.full", "keys.manage_any", "webhooks.manage", "contacts.forget", "assistant.settings", "members.manage", "audit.read", "privacy.manage", "billing.manage", "workspace.own", "identity.manage"];
7
+ export type SlotlockWorkspaceCapability = (typeof SLOTLOCK_WORKSPACE_CAPABILITIES)[number];
8
+ /** Capabilities that also need a sign-in in the last 15 minutes (as GitHub's sudo mode). */
9
+ export declare const SLOTLOCK_FRESH_CAPABILITIES: ReadonlySet<SlotlockWorkspaceCapability>;
10
+ /** What each role may do. */
11
+ export declare const SLOTLOCK_ROLE_CAPABILITIES: Readonly<Record<SlotlockWorkspaceRole, ReadonlySet<SlotlockWorkspaceCapability>>>;
12
+ export declare function isSlotlockWorkspaceRole(value: unknown): value is SlotlockWorkspaceRole;
13
+ /** Whether a role may use a capability. */
14
+ export declare function slotlockRoleCan(role: SlotlockWorkspaceRole | null | undefined, capability: SlotlockWorkspaceCapability): boolean;
15
+ /**
16
+ * Whether `granter` may give `role` to someone: never a role with a capability the granter lacks,
17
+ * so an Admin invites up to Admin, never Owner or Billing, and only those who manage members grant
18
+ * at all.
19
+ */
20
+ export declare function slotlockRoleMayGrant(granter: SlotlockWorkspaceRole | null | undefined, role: SlotlockWorkspaceRole): boolean;
21
+ /** The API key scopes a role may give a key it creates: admin only with `keys.full`. */
22
+ export declare function slotlockRoleKeyScopes(role: SlotlockWorkspaceRole | null | undefined): readonly ('read' | 'write' | 'admin')[];
23
+ /**
24
+ * Each dashboard POST route, by the capability it needs before its handler runs. A handler may ask
25
+ * for more where the form decides (a Full-access key asks `keys.full`; another person's key asks
26
+ * `keys.manage_any`). A route not listed is refused.
27
+ */
28
+ export declare const SLOTLOCK_DASHBOARD_POST_CAPABILITIES: Readonly<Record<string, SlotlockWorkspaceCapability>>;
29
+ /** Each dashboard page (GET), by the capability it needs. A page not listed is refused. */
30
+ export declare const SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES: Readonly<Record<string, SlotlockWorkspaceCapability>>;
31
+ /**
32
+ * A member limited to some locations and groups (RFC 0004 D1) keeps at most these of their role's
33
+ * capabilities: they read and run the desk for those resources, and chat with an assistant that
34
+ * reaches only them. Nothing that reaches the whole workspace (keys, webhooks, people, money, its
35
+ * hours and closures, its data whole or a person's) is theirs.
36
+ */
37
+ export declare const SLOTLOCK_SCOPED_CAPABILITIES: ReadonlySet<SlotlockWorkspaceCapability>;
38
+ /**
39
+ * The pages a limited member reaches: what the store reads for them is held to their resources by
40
+ * the database (resource_scope_v1), and pages whose content is the whole workspace's (keys,
41
+ * webhooks, contacts, hours, closures, locations, usage, the audit log) are not among them.
42
+ */
43
+ export declare const SLOTLOCK_SCOPED_DASHBOARD_PAGES: ReadonlySet<string>;
44
+ /** The forms a limited member may send: their own account's, and the desk's for their resources. */
45
+ export declare const SLOTLOCK_SCOPED_DASHBOARD_POSTS: ReadonlySet<string>;
46
+ /** Whether a role, limited (`scoped`) or not, may use a capability. */
47
+ export declare function slotlockMemberCan(role: SlotlockWorkspaceRole | null | undefined, scoped: boolean, capability: SlotlockWorkspaceCapability): boolean;