slotlock 0.8.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.
- package/CHANGELOG.md +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
|
@@ -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;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Who may do what in a workspace: six fixed roles, each a set of capabilities, and every dashboard
|
|
2
|
+
// route named by the capability it needs. The dashboard reads a person's role from the database on
|
|
3
|
+
// every request (workspaces.ts) and checks it here before any handler runs, so a role changed or a
|
|
4
|
+
// membership ended applies at the next request, and a route missing from the table is refused.
|
|
5
|
+
//
|
|
6
|
+
// The roles follow what API products settled on (Stripe, Vercel): a Developer holds credentials but
|
|
7
|
+
// not people or money; Billing and Viewer hold the least. Owner and Admin differ by money and by
|
|
8
|
+
// the workspace itself. A capability marked fresh also needs a sign-in in the last 15 minutes.
|
|
9
|
+
/** The roles, most powerful first. */
|
|
10
|
+
export const SLOTLOCK_WORKSPACE_ROLES = Object.freeze([
|
|
11
|
+
'owner',
|
|
12
|
+
'admin',
|
|
13
|
+
'developer',
|
|
14
|
+
'member',
|
|
15
|
+
'viewer',
|
|
16
|
+
'billing',
|
|
17
|
+
]);
|
|
18
|
+
/** Each role as the dashboard names it. */
|
|
19
|
+
export const SLOTLOCK_WORKSPACE_ROLE_NAMES = Object.freeze({
|
|
20
|
+
owner: 'Owner',
|
|
21
|
+
admin: 'Admin',
|
|
22
|
+
developer: 'Developer',
|
|
23
|
+
member: 'Member',
|
|
24
|
+
viewer: 'Viewer',
|
|
25
|
+
billing: 'Billing',
|
|
26
|
+
});
|
|
27
|
+
export const SLOTLOCK_WORKSPACE_CAPABILITIES = Object.freeze([
|
|
28
|
+
/** Switch workspace or environment, sign out, see one's own account. Every role. */
|
|
29
|
+
'account.use',
|
|
30
|
+
/** Read the workspace: overview, resources, bookings, contacts, history, key and webhook lists. */
|
|
31
|
+
'workspace.read',
|
|
32
|
+
/** The plan, its limits and this month's use. */
|
|
33
|
+
'usage.read',
|
|
34
|
+
/** Chat with the assistant; what it proposes still waits for a person to approve. */
|
|
35
|
+
'assistant.chat',
|
|
36
|
+
/** Run the booking desk: approvals, the waitlist, hours, closures and holiday calendars. */
|
|
37
|
+
'operations.run',
|
|
38
|
+
/** Resources, their booking rules, pools, locations and groups. */
|
|
39
|
+
'resources.manage',
|
|
40
|
+
/** The test environment's seed, reset and fast-forward. */
|
|
41
|
+
'sandbox.manage',
|
|
42
|
+
/** Read-only, read-write or resource-limited keys; rotate, revoke and limit one's own. */
|
|
43
|
+
'keys.manage_own',
|
|
44
|
+
/** Full-access keys (the admin scope: contacts, webhooks, history, erasure). Fresh. */
|
|
45
|
+
'keys.full',
|
|
46
|
+
/** Rotate, revoke or limit a key someone else created. */
|
|
47
|
+
'keys.manage_any',
|
|
48
|
+
/** Add, delete and enable webhooks, which stream every change to an outside address. Fresh. */
|
|
49
|
+
'webhooks.manage',
|
|
50
|
+
/** Forget a contact: an erasure request. */
|
|
51
|
+
'contacts.forget',
|
|
52
|
+
/** Connect, test or disconnect the assistant's model and its key. Fresh. */
|
|
53
|
+
'assistant.settings',
|
|
54
|
+
/** Invite, change roles and remove people, up to one's own powers. */
|
|
55
|
+
'members.manage',
|
|
56
|
+
/** Read the audit log. */
|
|
57
|
+
'audit.read',
|
|
58
|
+
/**
|
|
59
|
+
* Export the workspace's data, and find, export or erase what it keeps about one person (RFC 0004
|
|
60
|
+
* D3). Fresh.
|
|
61
|
+
*/
|
|
62
|
+
'privacy.manage',
|
|
63
|
+
/** Change the plan and open billing. Fresh. */
|
|
64
|
+
'billing.manage',
|
|
65
|
+
/** Grant Owner or Billing, transfer, delete the workspace, link a GitHub organization. Fresh. */
|
|
66
|
+
'workspace.own',
|
|
67
|
+
/** The team's identity: its verified domains and who may join through them. Owner only. Fresh. */
|
|
68
|
+
'identity.manage',
|
|
69
|
+
]);
|
|
70
|
+
/** Capabilities that also need a sign-in in the last 15 minutes (as GitHub's sudo mode). */
|
|
71
|
+
export const SLOTLOCK_FRESH_CAPABILITIES = new Set([
|
|
72
|
+
'keys.full',
|
|
73
|
+
'webhooks.manage',
|
|
74
|
+
'assistant.settings',
|
|
75
|
+
'billing.manage',
|
|
76
|
+
'workspace.own',
|
|
77
|
+
'privacy.manage',
|
|
78
|
+
'identity.manage',
|
|
79
|
+
]);
|
|
80
|
+
const everyCapability = SLOTLOCK_WORKSPACE_CAPABILITIES;
|
|
81
|
+
const without = (...left) => everyCapability.filter((capability) => !left.includes(capability));
|
|
82
|
+
/** What each role may do. */
|
|
83
|
+
export const SLOTLOCK_ROLE_CAPABILITIES = Object.freeze({
|
|
84
|
+
owner: new Set(everyCapability),
|
|
85
|
+
admin: new Set(without('billing.manage', 'workspace.own', 'identity.manage')),
|
|
86
|
+
developer: new Set([
|
|
87
|
+
'account.use',
|
|
88
|
+
'workspace.read',
|
|
89
|
+
'usage.read',
|
|
90
|
+
'assistant.chat',
|
|
91
|
+
'operations.run',
|
|
92
|
+
'resources.manage',
|
|
93
|
+
'sandbox.manage',
|
|
94
|
+
'keys.manage_own',
|
|
95
|
+
]),
|
|
96
|
+
member: new Set(['account.use', 'workspace.read', 'usage.read', 'assistant.chat', 'operations.run']),
|
|
97
|
+
viewer: new Set(['account.use', 'workspace.read', 'usage.read', 'assistant.chat']),
|
|
98
|
+
billing: new Set(['account.use', 'usage.read', 'billing.manage']),
|
|
99
|
+
});
|
|
100
|
+
export function isSlotlockWorkspaceRole(value) {
|
|
101
|
+
return typeof value === 'string' && SLOTLOCK_WORKSPACE_ROLES.includes(value);
|
|
102
|
+
}
|
|
103
|
+
/** Whether a role may use a capability. */
|
|
104
|
+
export function slotlockRoleCan(role, capability) {
|
|
105
|
+
return role !== null && role !== undefined && SLOTLOCK_ROLE_CAPABILITIES[role].has(capability);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether `granter` may give `role` to someone: never a role with a capability the granter lacks,
|
|
109
|
+
* so an Admin invites up to Admin, never Owner or Billing, and only those who manage members grant
|
|
110
|
+
* at all.
|
|
111
|
+
*/
|
|
112
|
+
export function slotlockRoleMayGrant(granter, role) {
|
|
113
|
+
if (!slotlockRoleCan(granter, 'members.manage'))
|
|
114
|
+
return false;
|
|
115
|
+
for (const capability of SLOTLOCK_ROLE_CAPABILITIES[role]) {
|
|
116
|
+
if (!slotlockRoleCan(granter, capability))
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/** The API key scopes a role may give a key it creates: admin only with `keys.full`. */
|
|
122
|
+
export function slotlockRoleKeyScopes(role) {
|
|
123
|
+
if (slotlockRoleCan(role, 'keys.full'))
|
|
124
|
+
return ['read', 'write', 'admin'];
|
|
125
|
+
if (slotlockRoleCan(role, 'keys.manage_own'))
|
|
126
|
+
return ['read', 'write'];
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Each dashboard POST route, by the capability it needs before its handler runs. A handler may ask
|
|
131
|
+
* for more where the form decides (a Full-access key asks `keys.full`; another person's key asks
|
|
132
|
+
* `keys.manage_any`). A route not listed is refused.
|
|
133
|
+
*/
|
|
134
|
+
export const SLOTLOCK_DASHBOARD_POST_CAPABILITIES = Object.freeze({
|
|
135
|
+
'/sign-out': 'account.use',
|
|
136
|
+
'/sign-out-everywhere': 'account.use',
|
|
137
|
+
'/sessions/end': 'account.use',
|
|
138
|
+
'/organization': 'account.use',
|
|
139
|
+
'/environment': 'account.use',
|
|
140
|
+
'/teams/enable': 'account.use',
|
|
141
|
+
'/teams/disable': 'account.use',
|
|
142
|
+
// Anyone signed in may create a team workspace, join one they were invited to, or leave one.
|
|
143
|
+
'/teams/create': 'account.use',
|
|
144
|
+
// Deleting a team, or keeping one being deleted: its Owners only.
|
|
145
|
+
'/teams/delete': 'workspace.own',
|
|
146
|
+
'/teams/restore': 'workspace.own',
|
|
147
|
+
'/invite/accept': 'account.use',
|
|
148
|
+
'/members/leave': 'account.use',
|
|
149
|
+
// Inviting or changing someone also needs the power the role given carries (dashboard.ts
|
|
150
|
+
// capabilitiesOf): an Owner `workspace.own`, a Billing manager `billing.manage`.
|
|
151
|
+
'/members/invite': 'members.manage',
|
|
152
|
+
'/members/resend': 'members.manage',
|
|
153
|
+
'/members/revoke': 'members.manage',
|
|
154
|
+
'/members/role': 'members.manage',
|
|
155
|
+
// Limiting a member to locations and groups (RFC 0004 D1), or giving them all of it again.
|
|
156
|
+
'/members/scope': 'members.manage',
|
|
157
|
+
'/members/remove': 'members.manage',
|
|
158
|
+
// Approving or declining a request to join through a verified domain.
|
|
159
|
+
'/members/requests': 'members.manage',
|
|
160
|
+
// Asking to join a team through a verified address: anyone signed in, for a team they are not in.
|
|
161
|
+
'/join/request': 'account.use',
|
|
162
|
+
// A team's domains, and holding invitations to them: its Owners only.
|
|
163
|
+
'/identity/domains': 'identity.manage',
|
|
164
|
+
'/identity/domains/verify': 'identity.manage',
|
|
165
|
+
'/identity/domains/remove': 'identity.manage',
|
|
166
|
+
'/identity/domains/policy': 'identity.manage',
|
|
167
|
+
'/identity/invites': 'identity.manage',
|
|
168
|
+
// A team's single sign-on: its Owners only.
|
|
169
|
+
'/identity/sso': 'identity.manage',
|
|
170
|
+
'/identity/sso/test': 'identity.manage',
|
|
171
|
+
'/identity/sso/status': 'identity.manage',
|
|
172
|
+
'/identity/sso/delete': 'identity.manage',
|
|
173
|
+
'/identity/sso/provisioning': 'identity.manage',
|
|
174
|
+
'/identity/sso/mapping': 'identity.manage',
|
|
175
|
+
'/identity/sso/require': 'identity.manage',
|
|
176
|
+
'/identity/scim/enable': 'identity.manage',
|
|
177
|
+
'/identity/scim/disable': 'identity.manage',
|
|
178
|
+
'/identity/scim/token': 'identity.manage',
|
|
179
|
+
'/identity/scim/token/revoke': 'identity.manage',
|
|
180
|
+
'/identity/scim/group': 'identity.manage',
|
|
181
|
+
// Signing in through one's team's provider (to link it or prove oneself), or an Owner's bypass:
|
|
182
|
+
// from wherever one is, for a team the handler and the database check one belongs to.
|
|
183
|
+
'/sso/proof': 'account.use',
|
|
184
|
+
'/sso/bypass': 'account.use',
|
|
185
|
+
'/keys': 'keys.manage_own',
|
|
186
|
+
'/keys/rotate': 'keys.manage_own',
|
|
187
|
+
'/keys/revoke': 'keys.manage_own',
|
|
188
|
+
'/keys/limits': 'keys.manage_own',
|
|
189
|
+
'/resources': 'resources.manage',
|
|
190
|
+
'/resources/rules': 'resources.manage',
|
|
191
|
+
'/resources/pool': 'resources.manage',
|
|
192
|
+
'/resources/profile': 'resources.manage',
|
|
193
|
+
'/resources/place': 'resources.manage',
|
|
194
|
+
'/resources/parent': 'resources.manage',
|
|
195
|
+
'/locations': 'resources.manage',
|
|
196
|
+
'/locations/delete': 'resources.manage',
|
|
197
|
+
'/groups': 'resources.manage',
|
|
198
|
+
'/groups/delete': 'resources.manage',
|
|
199
|
+
'/webhooks': 'webhooks.manage',
|
|
200
|
+
'/webhooks/delete': 'webhooks.manage',
|
|
201
|
+
'/webhooks/enable': 'webhooks.manage',
|
|
202
|
+
'/waitlist/leave': 'operations.run',
|
|
203
|
+
'/approvals/decide': 'operations.run',
|
|
204
|
+
'/hours': 'operations.run',
|
|
205
|
+
'/closures': 'operations.run',
|
|
206
|
+
'/closures/delete': 'operations.run',
|
|
207
|
+
'/closures/import': 'operations.run',
|
|
208
|
+
'/closures/calendars/delete': 'operations.run',
|
|
209
|
+
'/sandbox/seed': 'sandbox.manage',
|
|
210
|
+
'/sandbox/reset': 'sandbox.manage',
|
|
211
|
+
'/sandbox/fast-forward': 'sandbox.manage',
|
|
212
|
+
'/contacts/forget': 'contacts.forget',
|
|
213
|
+
'/assistant': 'assistant.chat',
|
|
214
|
+
'/assistant/stream': 'assistant.chat',
|
|
215
|
+
'/assistant/reset': 'assistant.chat',
|
|
216
|
+
'/assistant/rename': 'assistant.chat',
|
|
217
|
+
'/assistant/delete': 'assistant.chat',
|
|
218
|
+
// Approving runs the change the assistant proposed: as the desk would.
|
|
219
|
+
'/assistant/approve': 'operations.run',
|
|
220
|
+
'/assistant/dismiss': 'assistant.chat',
|
|
221
|
+
'/settings/assistant': 'assistant.settings',
|
|
222
|
+
'/settings/assistant/test': 'assistant.settings',
|
|
223
|
+
'/settings/assistant/models': 'assistant.settings',
|
|
224
|
+
'/settings/assistant/remove': 'assistant.settings',
|
|
225
|
+
'/plan/upgrade': 'billing.manage',
|
|
226
|
+
'/plan/manage': 'billing.manage',
|
|
227
|
+
'/settings/privacy/export': 'privacy.manage',
|
|
228
|
+
'/settings/privacy/export/delete': 'privacy.manage',
|
|
229
|
+
'/settings/privacy/person': 'privacy.manage',
|
|
230
|
+
'/settings/privacy/person/export': 'privacy.manage',
|
|
231
|
+
'/settings/privacy/person/erase': 'privacy.manage',
|
|
232
|
+
});
|
|
233
|
+
/** Each dashboard page (GET), by the capability it needs. A page not listed is refused. */
|
|
234
|
+
export const SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES = Object.freeze({
|
|
235
|
+
'/': 'workspace.read',
|
|
236
|
+
'/keys': 'workspace.read',
|
|
237
|
+
'/resources': 'workspace.read',
|
|
238
|
+
'/resources/:id': 'workspace.read',
|
|
239
|
+
'/webhooks': 'workspace.read',
|
|
240
|
+
'/waitlist': 'workspace.read',
|
|
241
|
+
'/approvals': 'workspace.read',
|
|
242
|
+
'/hours': 'workspace.read',
|
|
243
|
+
'/closures': 'workspace.read',
|
|
244
|
+
'/contacts': 'workspace.read',
|
|
245
|
+
'/locations': 'workspace.read',
|
|
246
|
+
'/assistant': 'assistant.chat',
|
|
247
|
+
'/assistant/chats': 'assistant.chat',
|
|
248
|
+
'/plan': 'usage.read',
|
|
249
|
+
'/teams': 'account.use',
|
|
250
|
+
'/settings': 'workspace.read',
|
|
251
|
+
'/settings/endpoints': 'workspace.read',
|
|
252
|
+
'/settings/hours': 'workspace.read',
|
|
253
|
+
'/settings/account': 'account.use',
|
|
254
|
+
'/settings/members': 'workspace.read',
|
|
255
|
+
'/settings/audit': 'audit.read',
|
|
256
|
+
'/settings/audit/export': 'audit.read',
|
|
257
|
+
'/settings/team': 'workspace.own',
|
|
258
|
+
'/settings/privacy': 'privacy.manage',
|
|
259
|
+
'/settings/privacy/download': 'privacy.manage',
|
|
260
|
+
'/settings/identity': 'identity.manage',
|
|
261
|
+
});
|
|
262
|
+
/**
|
|
263
|
+
* A member limited to some locations and groups (RFC 0004 D1) keeps at most these of their role's
|
|
264
|
+
* capabilities: they read and run the desk for those resources, and chat with an assistant that
|
|
265
|
+
* reaches only them. Nothing that reaches the whole workspace (keys, webhooks, people, money, its
|
|
266
|
+
* hours and closures, its data whole or a person's) is theirs.
|
|
267
|
+
*/
|
|
268
|
+
export const SLOTLOCK_SCOPED_CAPABILITIES = new Set([
|
|
269
|
+
'account.use',
|
|
270
|
+
'workspace.read',
|
|
271
|
+
'assistant.chat',
|
|
272
|
+
'operations.run',
|
|
273
|
+
]);
|
|
274
|
+
/**
|
|
275
|
+
* The pages a limited member reaches: what the store reads for them is held to their resources by
|
|
276
|
+
* the database (resource_scope_v1), and pages whose content is the whole workspace's (keys,
|
|
277
|
+
* webhooks, contacts, hours, closures, locations, usage, the audit log) are not among them.
|
|
278
|
+
*/
|
|
279
|
+
export const SLOTLOCK_SCOPED_DASHBOARD_PAGES = new Set([
|
|
280
|
+
'/',
|
|
281
|
+
'/resources',
|
|
282
|
+
'/resources/:id',
|
|
283
|
+
'/waitlist',
|
|
284
|
+
'/approvals',
|
|
285
|
+
'/assistant',
|
|
286
|
+
'/assistant/chats',
|
|
287
|
+
'/teams',
|
|
288
|
+
'/settings',
|
|
289
|
+
'/settings/account',
|
|
290
|
+
'/settings/members',
|
|
291
|
+
]);
|
|
292
|
+
/** The forms a limited member may send: their own account's, and the desk's for their resources. */
|
|
293
|
+
export const SLOTLOCK_SCOPED_DASHBOARD_POSTS = new Set([
|
|
294
|
+
...Object.entries(SLOTLOCK_DASHBOARD_POST_CAPABILITIES)
|
|
295
|
+
.filter(([, capability]) => capability === 'account.use' || capability === 'assistant.chat')
|
|
296
|
+
.map(([route]) => route),
|
|
297
|
+
'/waitlist/leave',
|
|
298
|
+
'/approvals/decide',
|
|
299
|
+
'/assistant/approve',
|
|
300
|
+
]);
|
|
301
|
+
/** Whether a role, limited (`scoped`) or not, may use a capability. */
|
|
302
|
+
export function slotlockMemberCan(role, scoped, capability) {
|
|
303
|
+
return slotlockRoleCan(role, capability) && (!scoped || SLOTLOCK_SCOPED_CAPABILITIES.has(capability));
|
|
304
|
+
}
|