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.
- package/CHANGELOG.md +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- 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 +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- 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 +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- 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
package/agent-store-backend.js
CHANGED
|
@@ -12,7 +12,7 @@ import { readSlotlockHolidayCalendar } from './holiday-import.js';
|
|
|
12
12
|
import { SLOTLOCK_OVERRIDE_RESOURCES, } from './availability-store.js';
|
|
13
13
|
import { findEarliestRange } from './ranges.js';
|
|
14
14
|
import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
|
|
15
|
-
import { SLOTLOCK_INTERNAL_EVENT_OWNER_REF, calendarEventRollingHorizon, } from './store.js';
|
|
15
|
+
import { buildSlotlockTenantExport, slotlockAuditEventRecord, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, calendarEventRollingHorizon, } from './store.js';
|
|
16
16
|
import { slotlockResourceSearchWords, slotlockSearchResources } from './resource-search.js';
|
|
17
17
|
import { maintainSlotlockTenant } from './maintenance.js';
|
|
18
18
|
import { fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
@@ -78,6 +78,52 @@ function assertActive(context) {
|
|
|
78
78
|
if (context.signal.aborted)
|
|
79
79
|
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
80
80
|
}
|
|
81
|
+
/** A tenant export as the API gives it. */
|
|
82
|
+
function tenantExportOutput(made) {
|
|
83
|
+
return {
|
|
84
|
+
id: made.id,
|
|
85
|
+
status: made.status,
|
|
86
|
+
requested_by: made.requestedBy,
|
|
87
|
+
created_at: made.createdAt.toISOString(),
|
|
88
|
+
finished_at: made.finishedAt?.toISOString() ?? null,
|
|
89
|
+
expires_at: made.expiresAt.toISOString(),
|
|
90
|
+
parts: made.parts,
|
|
91
|
+
rows: made.rows,
|
|
92
|
+
bytes: made.bytes,
|
|
93
|
+
tables: made.tables,
|
|
94
|
+
error: made.error,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** A key's own caps on what its agent holds (RFC 0004 A5), or undefined when it has none. */
|
|
98
|
+
function keyHoldCaps(context) {
|
|
99
|
+
const limits = context.principal.limits;
|
|
100
|
+
return limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
|
|
101
|
+
? {
|
|
102
|
+
...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
|
|
103
|
+
...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
|
|
104
|
+
}
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|
|
107
|
+
/** The agent an approval holds time for, and its key's caps. */
|
|
108
|
+
function approvalHolder(context) {
|
|
109
|
+
const caps = keyHoldCaps(context);
|
|
110
|
+
return caps ? { ref: agentOwnerRef(context), caps } : { ref: agentOwnerRef(context) };
|
|
111
|
+
}
|
|
112
|
+
/** The person an access or erasure request names (RFC 0004 D3), as the schema checked it. */
|
|
113
|
+
function subjectOf(input) {
|
|
114
|
+
for (const kind of ['email', 'holder', 'contact', 'reference']) {
|
|
115
|
+
const value = input[kind];
|
|
116
|
+
if (typeof value === 'string')
|
|
117
|
+
return { [kind]: value };
|
|
118
|
+
}
|
|
119
|
+
throw new SlotlockAgentOperationError('invalid_subject', 400);
|
|
120
|
+
}
|
|
121
|
+
function subjectRefusal(error) {
|
|
122
|
+
if (error?.code === 'invalid_subject') {
|
|
123
|
+
throw new SlotlockAgentOperationError('invalid_subject', 400);
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
81
127
|
async function withTenant(store, context, callback, options) {
|
|
82
128
|
assertActive(context);
|
|
83
129
|
let value;
|
|
@@ -607,6 +653,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
607
653
|
source: 'agent-server',
|
|
608
654
|
// The key's own caps, below the server's (RFC 0004 A5).
|
|
609
655
|
...(holderCaps ? { holderCaps } : {}),
|
|
656
|
+
// Its price is the tenant's (holdPrice), never the agent's own (RFC 0004 C3).
|
|
657
|
+
priceFromRules: true,
|
|
610
658
|
// Booked only once your system records its payment (RFC 0004 C3).
|
|
611
659
|
...(input.payment_required === true ? { paymentRequired: true } : {}),
|
|
612
660
|
...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
|
|
@@ -665,6 +713,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
665
713
|
ttlMs: input.ttl_seconds * 1_000,
|
|
666
714
|
source: 'slotlock:together',
|
|
667
715
|
...(holderCaps ? { holderCaps } : {}),
|
|
716
|
+
// Its price is the tenant's (holdPrice), never the agent's own (RFC 0004 C3).
|
|
717
|
+
priceFromRules: true,
|
|
668
718
|
});
|
|
669
719
|
// All or nothing: thrown, so the holds already made roll back with it.
|
|
670
720
|
if (!result.ok) {
|
|
@@ -1893,6 +1943,22 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1893
1943
|
const windows = (await hoursWithin(tenantStore, context, [resource], reach, options)).get(resource.id) ?? [];
|
|
1894
1944
|
return !coveredByOpenWindows(windows, { start, end });
|
|
1895
1945
|
}
|
|
1946
|
+
/**
|
|
1947
|
+
* What an update may change without booking any time: everything else (time, resource, zone,
|
|
1948
|
+
* transparency, status, recurrence and its exceptions) is judged by what it would add.
|
|
1949
|
+
*/
|
|
1950
|
+
const EVENT_DETAIL_FIELDS = new Set([
|
|
1951
|
+
'event_id',
|
|
1952
|
+
'expected_revision',
|
|
1953
|
+
'expected_resource_id',
|
|
1954
|
+
'idempotency_key',
|
|
1955
|
+
'title',
|
|
1956
|
+
'description',
|
|
1957
|
+
'location',
|
|
1958
|
+
'organizer',
|
|
1959
|
+
'attendees',
|
|
1960
|
+
'reminders',
|
|
1961
|
+
]);
|
|
1896
1962
|
/** The write's ask and the policies that may cover it, or null when none can. */
|
|
1897
1963
|
async function plan(tenantStore, context, operation, input) {
|
|
1898
1964
|
const tenantRef = context.principal.tenantRef;
|
|
@@ -1950,15 +2016,18 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1950
2016
|
const current = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: input.event_id });
|
|
1951
2017
|
if (!current)
|
|
1952
2018
|
return null;
|
|
2019
|
+
// Details alone book nothing. Any other change may: a move, or time the event did not hold
|
|
2020
|
+
// (made opaque or confirmed, given occurrences or fewer exceptions, another zone), judged
|
|
2021
|
+
// below by what the write would add to what the event holds.
|
|
2022
|
+
if (Object.keys(input).every((field) => EVENT_DETAIL_FIELDS.has(field)))
|
|
2023
|
+
return null;
|
|
1953
2024
|
const resourceId = input.resource_id ?? current.resourceId;
|
|
1954
2025
|
const start = input.starts_at ? new Date(input.starts_at) : current.start;
|
|
1955
2026
|
const end = input.ends_at ? new Date(input.ends_at) : current.end;
|
|
1956
|
-
|
|
1957
|
-
if (resourceId === current.resourceId && start.getTime() === current.start.getTime() && end.getTime() === current.end.getTime()) {
|
|
1958
|
-
return null;
|
|
1959
|
-
}
|
|
2027
|
+
const moved = resourceId !== current.resourceId || start.getTime() !== current.start.getTime() || end.getTime() !== current.end.getTime();
|
|
1960
2028
|
asked = {
|
|
1961
|
-
|
|
2029
|
+
// Time added in place is a booking of its own; a move is a move.
|
|
2030
|
+
kind: moved ? 'move' : 'single',
|
|
1962
2031
|
stored: 'slotlock_update_event',
|
|
1963
2032
|
resourceIds: [resourceId],
|
|
1964
2033
|
externalRefs: [current.externalRef],
|
|
@@ -1995,18 +2064,20 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
1995
2064
|
});
|
|
1996
2065
|
if (asked.prior) {
|
|
1997
2066
|
const { hold_id: _held, ...request } = input;
|
|
1998
|
-
// Granted: the booking was made with this key, so the write answers it as a replay (and
|
|
1999
|
-
// the store refuses another request with it). Without the hold the approval took over,
|
|
2000
|
-
// as the grant made it.
|
|
2001
|
-
if (asked.prior.status === 'granted')
|
|
2002
|
-
return method.call(this, context, request);
|
|
2003
2067
|
const kept = { ...asked.prior.request };
|
|
2004
2068
|
// A pool booking is kept as the member it would take.
|
|
2005
2069
|
if (operation === 'slotlock_book_pool')
|
|
2006
2070
|
delete kept.resource_id;
|
|
2071
|
+
// The key answers only the request a person was asked about, granted or not: a grant
|
|
2072
|
+
// approves that booking, never another made with its key (a bundle's other resources, say,
|
|
2073
|
+
// which the store would take as new).
|
|
2007
2074
|
if (JSON.stringify(canonical(kept)) !== JSON.stringify(canonical(request))) {
|
|
2008
2075
|
throw new SlotlockAgentOperationError('idempotency_conflict', 409);
|
|
2009
2076
|
}
|
|
2077
|
+
// Granted: the booking was made with this key, so the write answers it as a replay.
|
|
2078
|
+
// Without the hold the approval took over, as the grant made it.
|
|
2079
|
+
if (asked.prior.status === 'granted')
|
|
2080
|
+
return method.call(this, context, request);
|
|
2010
2081
|
throw approvalAnswer(asked.prior);
|
|
2011
2082
|
}
|
|
2012
2083
|
const ask = asked.plan;
|
|
@@ -2030,6 +2101,11 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
2030
2101
|
if (probed.replayed === true || occupied.length === 0) {
|
|
2031
2102
|
return context.dryRun ? probed : method.call(this, context, input);
|
|
2032
2103
|
}
|
|
2104
|
+
// Only time the event did not already hold needs a person: an edit or a move within its own
|
|
2105
|
+
// time books nothing new.
|
|
2106
|
+
const holds = uncovered(occupied, ask.before);
|
|
2107
|
+
if (holds.length === 0)
|
|
2108
|
+
return context.dryRun ? probed : method.call(this, context, input);
|
|
2033
2109
|
const written = [...new Set(occupied.map((occupation) => occupation.resourceId))];
|
|
2034
2110
|
const decided = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
|
|
2035
2111
|
// A pool's policies are those of the member the write would take, and its pool.
|
|
@@ -2077,9 +2153,6 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
2077
2153
|
expires_at: expiresAt.toISOString(),
|
|
2078
2154
|
});
|
|
2079
2155
|
}
|
|
2080
|
-
const holds = uncovered(occupied, ask.before);
|
|
2081
|
-
if (holds.length === 0)
|
|
2082
|
-
return method.call(this, context, input);
|
|
2083
2156
|
const chosen = ask.pool ? written[0] : undefined;
|
|
2084
2157
|
const { hold_id: _hold, ...request } = input;
|
|
2085
2158
|
const principal = context.principal;
|
|
@@ -2107,8 +2180,14 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
|
2107
2180
|
end: ask.end,
|
|
2108
2181
|
expiresAt,
|
|
2109
2182
|
...(ask.consumeHold ? { consumeHold: ask.consumeHold } : {}),
|
|
2183
|
+
// An agent's approvals hold time against its own caps (RFC 0004 A5); the tenant's own
|
|
2184
|
+
// systems have none.
|
|
2185
|
+
...(forTenant(context) ? {} : { holder: approvalHolder(context) }),
|
|
2110
2186
|
}), WRITE);
|
|
2111
2187
|
if (!result.ok) {
|
|
2188
|
+
if (result.code === 'hold_limit_reached' || result.code === 'held_time_limit_reached') {
|
|
2189
|
+
throw new SlotlockAgentOperationError(result.code, 409, { limit: String(result.limit) });
|
|
2190
|
+
}
|
|
2112
2191
|
if (result.code === 'approval_limit_reached') {
|
|
2113
2192
|
throw new SlotlockAgentOperationError('approval_limit_reached', 429, { limit: String(result.limit ?? '') });
|
|
2114
2193
|
}
|
|
@@ -2274,6 +2353,9 @@ function bookingRulesOutput(rules) {
|
|
|
2274
2353
|
},
|
|
2275
2354
|
}
|
|
2276
2355
|
: {}),
|
|
2356
|
+
...(rules.holdPrice !== undefined
|
|
2357
|
+
? { hold_price: { amount_minor: rules.holdPrice.amountMinor, currency: rules.holdPrice.currency } }
|
|
2358
|
+
: {}),
|
|
2277
2359
|
};
|
|
2278
2360
|
}
|
|
2279
2361
|
const dayWindowsOutput = (windows) => windows.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes }));
|
|
@@ -2333,6 +2415,10 @@ function bookingRulesInput(value) {
|
|
|
2333
2415
|
...(fee.within_minutes !== undefined ? { withinMinutes: fee.within_minutes } : {}),
|
|
2334
2416
|
};
|
|
2335
2417
|
}
|
|
2418
|
+
if (input.hold_price !== undefined) {
|
|
2419
|
+
const price = input.hold_price;
|
|
2420
|
+
rules.holdPrice = { amountMinor: price.amount_minor, currency: price.currency };
|
|
2421
|
+
}
|
|
2336
2422
|
return rules;
|
|
2337
2423
|
}
|
|
2338
2424
|
function resourceOutput(resource) {
|
|
@@ -3220,8 +3306,12 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3220
3306
|
paymentRef: input.payment_ref,
|
|
3221
3307
|
...(input.received !== undefined ? { received: amountInput(input.received) } : {}),
|
|
3222
3308
|
});
|
|
3223
|
-
if (!result.ok)
|
|
3309
|
+
if (!result.ok) {
|
|
3310
|
+
if (result.code === 'payment_conflict') {
|
|
3311
|
+
throw new SlotlockAgentOperationError('payment_conflict', 409, { payment_ref: result.paymentRef });
|
|
3312
|
+
}
|
|
3224
3313
|
throw applicationRefusal(result.code);
|
|
3314
|
+
}
|
|
3225
3315
|
return { hold: holdOutput(result.hold), replayed: result.idempotent };
|
|
3226
3316
|
}, WRITE);
|
|
3227
3317
|
},
|
|
@@ -3858,6 +3948,110 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
3858
3948
|
surfaces: summary.surfaces,
|
|
3859
3949
|
};
|
|
3860
3950
|
},
|
|
3951
|
+
async listAuditEvents(context, input) {
|
|
3952
|
+
const tenantRef = tenantOf(context);
|
|
3953
|
+
const limit = input.limit ?? 50;
|
|
3954
|
+
return withTenant(store, context, async (tenant) => {
|
|
3955
|
+
// As the dashboard's export: where hosted plans apply, the Team plan's.
|
|
3956
|
+
const plan = await tenant.tenantPlan({ tenantRef });
|
|
3957
|
+
if (plan && (plan.plan !== 'team' || plan.status === 'canceled')) {
|
|
3958
|
+
throw new SlotlockAgentOperationError('team_plan_required', 403);
|
|
3959
|
+
}
|
|
3960
|
+
const events = await tenant.listAuditEvents({
|
|
3961
|
+
tenantRef,
|
|
3962
|
+
limit,
|
|
3963
|
+
...(typeof input.cursor === 'string' ? { beforeId: input.cursor } : {}),
|
|
3964
|
+
...(typeof input.action === 'string' ? { action: input.action } : {}),
|
|
3965
|
+
...(typeof input.actor_ref === 'string' ? { actorRef: input.actor_ref } : {}),
|
|
3966
|
+
});
|
|
3967
|
+
return {
|
|
3968
|
+
events: events.map(slotlockAuditEventRecord),
|
|
3969
|
+
// A full page may have more after it; the next then answers none.
|
|
3970
|
+
next_cursor: events.length === limit ? (events.at(-1)?.id ?? null) : null,
|
|
3971
|
+
};
|
|
3972
|
+
});
|
|
3973
|
+
},
|
|
3974
|
+
async verifyAuditLog(context) {
|
|
3975
|
+
const verified = await withTenant(store, context, (tenant) => tenant.verifyAuditChain({ tenantRef: tenantOf(context) }));
|
|
3976
|
+
return { checked: verified.checked, broken_at: verified.brokenAt };
|
|
3977
|
+
},
|
|
3978
|
+
async createTenantExport(context) {
|
|
3979
|
+
const tenantRef = tenantOf(context);
|
|
3980
|
+
const keyId = context.principal.keyId;
|
|
3981
|
+
const actor = typeof keyId === 'string' && /^[0-9a-f-]{36}$/i.test(keyId)
|
|
3982
|
+
? { actorType: 'api_key', actorRef: `api_key:${keyId}` }
|
|
3983
|
+
: { actorType: 'system', actorRef: 'application' };
|
|
3984
|
+
const created = await withTenant(store, context, async (tenant) => {
|
|
3985
|
+
const made = await tenant.createTenantExport({ tenantRef, requestedBy: actor.actorRef }).catch((error) => {
|
|
3986
|
+
const code = error?.code;
|
|
3987
|
+
if (code === 'export_in_progress' || code === 'export_limit_reached') {
|
|
3988
|
+
throw new SlotlockAgentOperationError(code, 409);
|
|
3989
|
+
}
|
|
3990
|
+
throw error;
|
|
3991
|
+
});
|
|
3992
|
+
await tenant.recordAuditEvent({
|
|
3993
|
+
tenantRef,
|
|
3994
|
+
event: { action: 'export.created', ...actor, targetType: 'export', targetRef: made.id },
|
|
3995
|
+
});
|
|
3996
|
+
return made;
|
|
3997
|
+
}, WRITE);
|
|
3998
|
+
// Built after the answer; maintenance builds one a stopped server left pending.
|
|
3999
|
+
void buildSlotlockTenantExport(store, tenantRef, created.id);
|
|
4000
|
+
return { export: tenantExportOutput(created) };
|
|
4001
|
+
},
|
|
4002
|
+
async listTenantExports(context) {
|
|
4003
|
+
const exports = await withTenant(store, context, (tenant) => tenant.listTenantExports({ tenantRef: tenantOf(context) }));
|
|
4004
|
+
return { exports: exports.slice(0, 100).map(tenantExportOutput) };
|
|
4005
|
+
},
|
|
4006
|
+
async getTenantExport(context, input) {
|
|
4007
|
+
const found = await withTenant(store, context, (tenant) => tenant.getTenantExport({ tenantRef: tenantOf(context), exportId: input.export_id }));
|
|
4008
|
+
if (!found)
|
|
4009
|
+
throw new SlotlockAgentOperationError('export_not_found', 404);
|
|
4010
|
+
return { export: tenantExportOutput(found) };
|
|
4011
|
+
},
|
|
4012
|
+
async getTenantExportPart(context, input) {
|
|
4013
|
+
const part = Number(input.part);
|
|
4014
|
+
const read = await withTenant(store, context, (tenant) => tenant.getTenantExportPart({ tenantRef: tenantOf(context), exportId: input.export_id, part }));
|
|
4015
|
+
if (!read.ok)
|
|
4016
|
+
throw new SlotlockAgentOperationError(read.code, read.code === 'export_not_ready' ? 409 : 404);
|
|
4017
|
+
return {
|
|
4018
|
+
export_id: input.export_id,
|
|
4019
|
+
part,
|
|
4020
|
+
table: read.table,
|
|
4021
|
+
rows: read.lines.map((line) => JSON.parse(line)),
|
|
4022
|
+
};
|
|
4023
|
+
},
|
|
4024
|
+
async deleteTenantExport(context, input) {
|
|
4025
|
+
const deleted = await withTenant(store, context, (tenant) => tenant.deleteTenantExport({ tenantRef: tenantOf(context), exportId: input.export_id }), WRITE);
|
|
4026
|
+
return { deleted };
|
|
4027
|
+
},
|
|
4028
|
+
async exportSubject(context, input) {
|
|
4029
|
+
const subject = subjectOf(input);
|
|
4030
|
+
const exported = await withTenant(store, context, (tenant) => tenant.exportSubject({ tenantRef: tenantOf(context), subject, limit: 1_000 })).catch(subjectRefusal);
|
|
4031
|
+
return { records: exported.records, truncated: exported.truncated };
|
|
4032
|
+
},
|
|
4033
|
+
async eraseSubject(context, input) {
|
|
4034
|
+
const subject = subjectOf(input);
|
|
4035
|
+
const tenantRef = tenantOf(context);
|
|
4036
|
+
const keyId = context.principal.keyId;
|
|
4037
|
+
return withTenant(store, context, async (tenant) => {
|
|
4038
|
+
const { erased } = await tenant.eraseSubject({ tenantRef, subject }).catch(subjectRefusal);
|
|
4039
|
+
// Who erased whom is the workspace's to know, but never the person: how they were named,
|
|
4040
|
+
// and what went.
|
|
4041
|
+
await tenant.recordAuditEvent({
|
|
4042
|
+
tenantRef,
|
|
4043
|
+
event: {
|
|
4044
|
+
action: 'subject.erased',
|
|
4045
|
+
...(typeof keyId === 'string' && /^[0-9a-f-]{36}$/i.test(keyId)
|
|
4046
|
+
? { actorType: 'api_key', actorRef: `api_key:${keyId}` }
|
|
4047
|
+
: { actorType: 'system', actorRef: 'application' }),
|
|
4048
|
+
targetType: 'subject',
|
|
4049
|
+
metadata: { by: Object.keys(subject)[0], ...Object.fromEntries(Object.entries(erased).filter(([, rows]) => rows > 0)) },
|
|
4050
|
+
},
|
|
4051
|
+
});
|
|
4052
|
+
return { erased };
|
|
4053
|
+
}, WRITE);
|
|
4054
|
+
},
|
|
3861
4055
|
async enableWebhook(context, input) {
|
|
3862
4056
|
return withTenant(store, context, async (tenant) => {
|
|
3863
4057
|
const enabled = await tenant.enableWebhookEndpoint({
|
package/application-api.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type SlotlockChangeOrigin } from './event-feed.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { SlotlockAgentPrincipal } from './agent-server.js';
|
|
4
4
|
import type { SlotlockTraceContext } from './mcp-modern.js';
|
|
5
|
-
export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_update_resource' | 'slotlock_list_locations' | 'slotlock_create_location' | 'slotlock_update_location' | 'slotlock_delete_location' | 'slotlock_list_groups' | 'slotlock_create_group' | 'slotlock_delete_group' | 'slotlock_add_group_member' | 'slotlock_remove_group_member' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_get_availability_defaults' | 'slotlock_set_location_availability' | 'slotlock_list_availability_exceptions' | 'slotlock_create_availability_exception' | 'slotlock_delete_availability_exception' | 'slotlock_import_holidays' | 'slotlock_list_holiday_calendars' | 'slotlock_delete_holiday_calendar' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_set_resource_parent' | 'slotlock_list_pools' | 'slotlock_set_pool_order' | 'slotlock_get_booking_rule_defaults' | 'slotlock_set_tenant_booking_rules' | 'slotlock_set_pool_booking_rules' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_put_source_busy' | 'slotlock_report_coverage_conflict' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_mark_hold_paid' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_put_contact' | 'slotlock_get_contact' | 'slotlock_list_contacts' | 'slotlock_delete_contact' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_decline_waitlist_offer' | 'slotlock_tenant_book_together' | 'slotlock_tenant_book_pool' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook' | 'slotlock_get_usage' | 'slotlock_list_approval_policies' | 'slotlock_put_approval_policy' | 'slotlock_delete_approval_policy' | 'slotlock_list_approvals' | 'slotlock_get_approval' | 'slotlock_decide_approval' | 'slotlock_sandbox_seed' | 'slotlock_sandbox_reset' | 'slotlock_sandbox_fast_forward' | 'slotlock_sandbox_race' | 'slotlock_partner_put_account' | 'slotlock_partner_list_accounts' | 'slotlock_partner_get_account' | 'slotlock_partner_list_keys' | 'slotlock_partner_create_key' | 'slotlock_partner_rotate_key' | 'slotlock_partner_revoke_key' | 'slotlock_partner_suspend_account' | 'slotlock_partner_unsuspend_account' | 'slotlock_partner_erase_account' | 'slotlock_partner_get_usage' | 'slotlock_partner_reconcile_usage';
|
|
5
|
+
export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_update_resource' | 'slotlock_list_locations' | 'slotlock_create_location' | 'slotlock_update_location' | 'slotlock_delete_location' | 'slotlock_list_groups' | 'slotlock_create_group' | 'slotlock_delete_group' | 'slotlock_add_group_member' | 'slotlock_remove_group_member' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_get_availability_defaults' | 'slotlock_set_location_availability' | 'slotlock_list_availability_exceptions' | 'slotlock_create_availability_exception' | 'slotlock_delete_availability_exception' | 'slotlock_import_holidays' | 'slotlock_list_holiday_calendars' | 'slotlock_delete_holiday_calendar' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_set_resource_parent' | 'slotlock_list_pools' | 'slotlock_set_pool_order' | 'slotlock_get_booking_rule_defaults' | 'slotlock_set_tenant_booking_rules' | 'slotlock_set_pool_booking_rules' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_put_source_busy' | 'slotlock_report_coverage_conflict' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_mark_hold_paid' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_put_contact' | 'slotlock_get_contact' | 'slotlock_list_contacts' | 'slotlock_delete_contact' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_decline_waitlist_offer' | 'slotlock_tenant_book_together' | 'slotlock_tenant_book_pool' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook' | 'slotlock_get_usage' | 'slotlock_list_audit_events' | 'slotlock_verify_audit_log' | 'slotlock_export_subject' | 'slotlock_erase_subject' | 'slotlock_create_tenant_export' | 'slotlock_list_tenant_exports' | 'slotlock_get_tenant_export' | 'slotlock_get_tenant_export_part' | 'slotlock_delete_tenant_export' | 'slotlock_list_approval_policies' | 'slotlock_put_approval_policy' | 'slotlock_delete_approval_policy' | 'slotlock_list_approvals' | 'slotlock_get_approval' | 'slotlock_decide_approval' | 'slotlock_sandbox_seed' | 'slotlock_sandbox_reset' | 'slotlock_sandbox_fast_forward' | 'slotlock_sandbox_race' | 'slotlock_partner_put_account' | 'slotlock_partner_list_accounts' | 'slotlock_partner_get_account' | 'slotlock_partner_list_keys' | 'slotlock_partner_create_key' | 'slotlock_partner_rotate_key' | 'slotlock_partner_revoke_key' | 'slotlock_partner_suspend_account' | 'slotlock_partner_unsuspend_account' | 'slotlock_partner_erase_account' | 'slotlock_partner_get_usage' | 'slotlock_partner_reconcile_usage';
|
|
6
6
|
/** The scope an application operation needs, but the partner API's (`slotlockApplicationScope`). */
|
|
7
7
|
export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
|
|
8
8
|
/** The scope one application operation needs: `partner` for the partner API's, else `admin`. */
|
|
@@ -113,6 +113,15 @@ export interface SlotlockApplicationBackend {
|
|
|
113
113
|
deleteWebhook: Handler;
|
|
114
114
|
enableWebhook: Handler;
|
|
115
115
|
getUsage: Handler;
|
|
116
|
+
listAuditEvents: Handler;
|
|
117
|
+
verifyAuditLog: Handler;
|
|
118
|
+
exportSubject: Handler;
|
|
119
|
+
eraseSubject: Handler;
|
|
120
|
+
createTenantExport: Handler;
|
|
121
|
+
listTenantExports: Handler;
|
|
122
|
+
getTenantExport: Handler;
|
|
123
|
+
getTenantExportPart: Handler;
|
|
124
|
+
deleteTenantExport: Handler;
|
|
116
125
|
listApprovalPolicies: Handler;
|
|
117
126
|
putApprovalPolicy: Handler;
|
|
118
127
|
deleteApprovalPolicy: Handler;
|
package/application-api.js
CHANGED
|
@@ -14,6 +14,7 @@ import { SLOTLOCK_APPROVAL_KINDS, SLOTLOCK_MAX_APPROVAL_MINUTES, SLOTLOCK_MAX_AP
|
|
|
14
14
|
import { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_PLAN } from './partner-names.js';
|
|
15
15
|
import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
|
|
16
16
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX } from './rules.js';
|
|
17
|
+
import { SLOTLOCK_SUBJECT_TABLES, SLOTLOCK_TENANT_EXPORT_LEFT_OUT, SLOTLOCK_TENANT_EXPORT_TABLES, } from './types.js';
|
|
17
18
|
import { SLOTLOCK_GROUPING_REF, SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_LABEL, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
|
|
18
19
|
/** The scope an application operation needs, but the partner API's (`slotlockApplicationScope`). */
|
|
19
20
|
export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
|
|
@@ -27,6 +28,8 @@ export function slotlockApplicationScope(definition) {
|
|
|
27
28
|
*/
|
|
28
29
|
const NOT_DRY_RUNNABLE = new Set([
|
|
29
30
|
'slotlock_erase_tenant',
|
|
31
|
+
// Built after the answer, outside the request's transaction.
|
|
32
|
+
'slotlock_create_tenant_export',
|
|
30
33
|
'slotlock_run_maintenance',
|
|
31
34
|
'slotlock_sandbox_seed',
|
|
32
35
|
'slotlock_sandbox_reset',
|
|
@@ -207,6 +210,17 @@ const BOOKING_RULES = z
|
|
|
207
210
|
})
|
|
208
211
|
.strict()
|
|
209
212
|
.optional(),
|
|
213
|
+
/**
|
|
214
|
+
* What holding it costs, your price (RFC 0004 C3): an agent's hold waits for this payment,
|
|
215
|
+
* whatever it asks, and an agent books it only from such a hold, once paid.
|
|
216
|
+
*/
|
|
217
|
+
hold_price: z
|
|
218
|
+
.object({
|
|
219
|
+
amount_minor: z.number().int().min(1).max(1_000_000_000_000),
|
|
220
|
+
currency: z.string().regex(/^[A-Z]{3}$/),
|
|
221
|
+
})
|
|
222
|
+
.strict()
|
|
223
|
+
.optional(),
|
|
210
224
|
})
|
|
211
225
|
.strict();
|
|
212
226
|
/** Default booking rules for the tenant or a pool: a resource's own rules but check-in. */
|
|
@@ -530,6 +544,67 @@ const HOLD = z
|
|
|
530
544
|
paid_amount: PAYMENT_AMOUNT.nullable().optional(),
|
|
531
545
|
})
|
|
532
546
|
.strict();
|
|
547
|
+
/** An event of the workspace audit log (RFC 0004 D2): who did what, to what, from where. */
|
|
548
|
+
const AUDIT_EVENT = z
|
|
549
|
+
.object({
|
|
550
|
+
id: z.string().uuid(),
|
|
551
|
+
occurred_at: DATE_TIME,
|
|
552
|
+
/** `<area>.<verb>`, such as `api_key.created`, `authz.denied` or `subject.erased`. */
|
|
553
|
+
action: z.string().min(3).max(100),
|
|
554
|
+
actor_type: z.enum(['person', 'api_key', 'assistant', 'system']),
|
|
555
|
+
/** `github:<id>`, `google:<subject>`, `api_key:<id>`: a reference, never a name or an email. */
|
|
556
|
+
actor_ref: z.string().min(1).max(500),
|
|
557
|
+
actor_role: z.enum(['owner', 'admin', 'developer', 'member', 'viewer', 'billing']).nullable(),
|
|
558
|
+
target_type: z.string().min(1).max(32).nullable(),
|
|
559
|
+
target_ref: z.string().min(1).max(500).nullable(),
|
|
560
|
+
outcome: z.enum(['success', 'denied']),
|
|
561
|
+
reason: z.string().min(1).max(200).nullable(),
|
|
562
|
+
ip_address: z.string().min(1).max(64).nullable(),
|
|
563
|
+
user_agent: z.string().min(1).max(256).nullable(),
|
|
564
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
565
|
+
/** Its place in the tenant's chain, and its SHA-256 (hex) over the one before and itself. */
|
|
566
|
+
chain_index: z.number().int().min(1).nullable(),
|
|
567
|
+
hash: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
|
|
568
|
+
})
|
|
569
|
+
.strict();
|
|
570
|
+
/** Whom an access or erasure request is about (RFC 0004 D3): exactly one identifier. */
|
|
571
|
+
const SUBJECT = z
|
|
572
|
+
.object({
|
|
573
|
+
/** Contacts, event attendees and organizers with this email (ignoring case). */
|
|
574
|
+
email: z.string().trim().min(3).max(320).regex(/^[^\s@]+@[^\s@]+$/, 'must be an email address').optional(),
|
|
575
|
+
/** Holds, bookings and waitlist entries held under this holder reference. */
|
|
576
|
+
holder: z.string().min(1).max(500).optional(),
|
|
577
|
+
/** The contact kept under this reference (slotlock_put_contact). */
|
|
578
|
+
contact: z
|
|
579
|
+
.string()
|
|
580
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/, 'must be 1-200 letters, digits, . _ : or -, starting with a letter or digit')
|
|
581
|
+
.optional(),
|
|
582
|
+
/** The booking or event with this reference. */
|
|
583
|
+
reference: z.string().min(1).max(500).optional(),
|
|
584
|
+
})
|
|
585
|
+
.strict()
|
|
586
|
+
.refine((input) => Object.values(input).filter((value) => value !== undefined).length === 1, 'Name the person by exactly one of email, holder, contact or reference');
|
|
587
|
+
/** A copy of everything the tenant keeps (RFC 0004 D3), as built or being built. */
|
|
588
|
+
const TENANT_EXPORT = z
|
|
589
|
+
.object({
|
|
590
|
+
id: z.string().uuid(),
|
|
591
|
+
/** pending until it is built, then ready, or failed (error says why). */
|
|
592
|
+
status: z.enum(['pending', 'ready', 'failed']),
|
|
593
|
+
requested_by: z.string().min(1).max(200).nullable(),
|
|
594
|
+
created_at: DATE_TIME,
|
|
595
|
+
finished_at: DATE_TIME.nullable(),
|
|
596
|
+
/** When it is deleted. */
|
|
597
|
+
expires_at: DATE_TIME,
|
|
598
|
+
/** Parts to read, 1 to parts, with slotlock_get_tenant_export_part. */
|
|
599
|
+
parts: z.number().int().min(0),
|
|
600
|
+
rows: z.number().int().min(0),
|
|
601
|
+
bytes: z.number().int().min(0),
|
|
602
|
+
/** Rows by table, for the tables with any. */
|
|
603
|
+
tables: z.record(z.string(), z.number().int().min(0)),
|
|
604
|
+
error: z.string().min(1).max(200).nullable(),
|
|
605
|
+
})
|
|
606
|
+
.strict();
|
|
607
|
+
const EXPORT_ID = z.string().uuid();
|
|
533
608
|
/** A person a booking may name to be told about it (RFC 0004 P3), by the tenant's reference. */
|
|
534
609
|
const CONTACT_REF = z
|
|
535
610
|
.string()
|
|
@@ -1123,7 +1198,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
1123
1198
|
{
|
|
1124
1199
|
name: 'slotlock_import_holidays',
|
|
1125
1200
|
title: 'Import a holiday calendar',
|
|
1126
|
-
description: "Close the days an iCalendar holiday calendar names, for the whole tenant, one location or one resource: the text in icalendar, or an https url the server fetches (at most 1 MB, 10 seconds; private addresses refused). Each all-day event closes the days it covers, and each occurrence of a recurring one within from to to (default today to two years on, at most three years); timed and cancelled events are skipped. The calendar is kept by name at its scope (name, by default its own X-WR-CALNAME, else the url's host
|
|
1201
|
+
description: "Close the days an iCalendar holiday calendar names, for the whole tenant, one location or one resource: the text in icalendar, or an https url the server fetches (at most 1 MB, 10 seconds; private addresses refused). Each all-day event closes the days it covers, and each occurrence of a recurring one within from to to (default today to two years on, at most three years); timed and cancelled events are skipped. The calendar is kept by name at its scope (name, by default its own X-WR-CALNAME, else the url's host, else Holidays): importing it again updates its closures instead of adding more, and deletes the ones it made that it no longer names within the span (removed), so a holiday it dropped goes too. With subscribe (and a url), the server keeps the url and imports it again every 30 days. At most 50 calendars a tenant (409 holiday_calendars_limit_reached). 400 invalid_holiday_calendar for text that is not one, or one that repeats more often than daily, daily on days of the month or year, or too much to read; 400 holiday_calendar_unreachable when the url cannot be read; 400 holiday_url_not_served where the server fetches no urls.",
|
|
1127
1202
|
method: 'POST',
|
|
1128
1203
|
path: '/v1/availability/holidays',
|
|
1129
1204
|
input: z
|
|
@@ -1265,7 +1340,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
1265
1340
|
{
|
|
1266
1341
|
name: 'slotlock_mark_hold_paid',
|
|
1267
1342
|
title: 'Record a hold as paid',
|
|
1268
|
-
description: "Record that a hold made with payment_required is paid, by the payment's reference at your processor (a Stripe PaymentIntent id, say), once you have verified it: the hold may then be confirmed. Slotlock records payment state and never moves money. The same reference again answers the same hold (replayed: true)
|
|
1343
|
+
description: "Record that a hold made with payment_required is paid, by the payment's reference at your processor (a Stripe PaymentIntent id, say), once you have verified it: the hold may then be confirmed. Slotlock records payment state and never moves money. The same reference again answers the same hold (replayed: true) while it is held or once it is booked (confirmed, or booked as an event), whatever became of the booking since: the payment is kept apart from the hold. A hold that ended without a booking after it was paid (expired, or let go) answers 409 hold_ended: the payment was never applied, so refund it; the feed's hold.expired or hold.released says so too (payment_unapplied). Another reference answers 409 payment_conflict, naming the one recorded (payment_ref). When the hold names its payment, received must be at least that amount in that currency (409 payment_amount_mismatch). The feed says so (hold.paid). A hold that does not wait for payment answers 409 payment_not_required; one that expired before it was paid, 409 hold_expired (refund the payment: its time may be someone else's); one released before it was paid, or unknown, 404 hold_not_found.",
|
|
1269
1344
|
method: 'POST',
|
|
1270
1345
|
path: '/v1/holds/{hold_id}/paid',
|
|
1271
1346
|
input: z
|
|
@@ -1989,6 +2064,142 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
1989
2064
|
invoke: 'getUsage',
|
|
1990
2065
|
example: { from: '2026-11-01', to: '2026-11-30' },
|
|
1991
2066
|
},
|
|
2067
|
+
// The audit log and requests about one person (RFC 0004 D2, D3).
|
|
2068
|
+
{
|
|
2069
|
+
name: 'slotlock_list_audit_events',
|
|
2070
|
+
title: 'List audit events',
|
|
2071
|
+
description: "The workspace audit log, newest first: who did what to its people, keys, webhooks, billing and settings, from where, and whether it was refused. Actors are references (github:<id>, api_key:<id>), never names. Each event carries its place in the tenant's hash chain and its hash (slotlock_verify_audit_log checks them). Only events of action or actor_ref when given; pass next_cursor as cursor for the next page, until it is null. Events are kept 180 days, 400 on the Team plan. Where hosted plans apply, the log is read this way on the Team plan only (403 team_plan_required).",
|
|
2072
|
+
method: 'GET',
|
|
2073
|
+
path: '/v1/audit',
|
|
2074
|
+
input: z
|
|
2075
|
+
.object({
|
|
2076
|
+
limit: z.number().int().min(1).max(200).default(50),
|
|
2077
|
+
cursor: z.string().uuid().optional(),
|
|
2078
|
+
action: z.string().regex(/^[a-z][a-z_]{0,31}(\.[a-z][a-z_]{0,31}){1,2}$/, 'must be an action, like api_key.created').optional(),
|
|
2079
|
+
actor_ref: z.string().min(1).max(500).optional(),
|
|
2080
|
+
})
|
|
2081
|
+
.strict(),
|
|
2082
|
+
output: z.object({ events: z.array(AUDIT_EVENT).max(200), next_cursor: z.string().uuid().nullable() }).strict(),
|
|
2083
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
2084
|
+
invoke: 'listAuditEvents',
|
|
2085
|
+
example: { limit: 50, action: 'api_key.created' },
|
|
2086
|
+
},
|
|
2087
|
+
{
|
|
2088
|
+
name: 'slotlock_verify_audit_log',
|
|
2089
|
+
title: 'Verify the audit log',
|
|
2090
|
+
description: "Check the workspace audit log's hash chain from its oldest event kept: each event numbered after the one before, linked to its hash, and hashed over its own content. checked counts the events that held; broken_at is the number of the first that does not (an event changed, removed or reordered outside Slotlock), or null when every one does.",
|
|
2091
|
+
method: 'GET',
|
|
2092
|
+
path: '/v1/audit/verify',
|
|
2093
|
+
input: z.object({}).strict(),
|
|
2094
|
+
output: z.object({ checked: z.number().int().min(0), broken_at: z.number().int().min(1).nullable() }).strict(),
|
|
2095
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
2096
|
+
invoke: 'verifyAuditLog',
|
|
2097
|
+
example: {},
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
name: 'slotlock_export_subject',
|
|
2101
|
+
title: "Export a person's data",
|
|
2102
|
+
description: `What the tenant keeps about one person, for an access request: the rows that name them, as kept, by table (${SLOTLOCK_SUBJECT_TABLES.join(', ')}), up to 1,000 a table (truncated: true when a table had more). Name them by exactly one of email (contacts, event attendees and organizers, ignoring case), holder (holds, bookings and waitlist entries), contact (a contact's reference) or reference (a booking's or an event's). It lists what slotlock_erase_subject would delete or take them out of: the bookings, changes and approvals that name their contacts too. An event lists its time and title, and an approval its decision, never the other people they name. 400 invalid_subject when no identifier, or more than one, is given.`,
|
|
2103
|
+
method: 'POST',
|
|
2104
|
+
path: '/v1/subjects/export',
|
|
2105
|
+
input: SUBJECT,
|
|
2106
|
+
output: z
|
|
2107
|
+
.object({
|
|
2108
|
+
records: z
|
|
2109
|
+
.object(Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, z.array(z.record(z.string(), z.unknown())).max(1_000)])))
|
|
2110
|
+
.strict(),
|
|
2111
|
+
truncated: z.boolean(),
|
|
2112
|
+
})
|
|
2113
|
+
.strict(),
|
|
2114
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
2115
|
+
invoke: 'exportSubject',
|
|
2116
|
+
example: { email: 'ada@example.com' },
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
name: 'slotlock_erase_subject',
|
|
2120
|
+
title: 'Erase a person',
|
|
2121
|
+
description: "Take one person out of the tenant's data, for an erasure request, at once: their contacts, attendee entries and waitlist entries are deleted; their email leaves events' attendee and organizer lists and approvals' requests; their holder reference and booking reference become one erased-<32 hex> name wherever they were (holds, bookings, ended bookings, events, the change history, approvals), and their contact's reference leaves bookings and changes. Bookings stay, as time taken, and nobody is told: the feed gets no change. Name them as for slotlock_export_subject. erased counts, by table, the rows deleted or changed; erasing again changes nothing. Recorded in the audit log (subject.erased) without the identifier. 400 invalid_subject when no identifier, or more than one, is given.",
|
|
2122
|
+
method: 'POST',
|
|
2123
|
+
path: '/v1/subjects/erase',
|
|
2124
|
+
input: SUBJECT,
|
|
2125
|
+
output: z
|
|
2126
|
+
.object({
|
|
2127
|
+
erased: z
|
|
2128
|
+
.object(Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, z.number().int().min(0)])))
|
|
2129
|
+
.strict(),
|
|
2130
|
+
})
|
|
2131
|
+
.strict(),
|
|
2132
|
+
risk: { readOnly: false, destructive: true, idempotent: true },
|
|
2133
|
+
invoke: 'eraseSubject',
|
|
2134
|
+
example: { email: 'ada@example.com' },
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
name: 'slotlock_create_tenant_export',
|
|
2138
|
+
title: 'Export the tenant',
|
|
2139
|
+
description: `Ask for a copy of everything the tenant keeps, to download: every table's rows as kept, read at one moment (${SLOTLOCK_TENANT_EXPORT_TABLES.join(', ')}). Secrets stay out: webhooks' signing secrets, the assistant's provider key and API keys' digests. ${Object.keys(SLOTLOCK_TENANT_EXPORT_LEFT_OUT).length} tables are left out as derived, internal or one member's own (each member's conversations with the assistant). It answers pending at once and is built in the background: slotlock_get_tenant_export says when it is ready, then read its parts with slotlock_get_tenant_export_part. Kept 24 hours once ready, then deleted. One is built at a time (409 export_in_progress) and five are kept (409 export_limit_reached); one past 512 MB fails (error export_too_large) and its operator runs slotlock tenant export. Recorded in the audit log (export.created).`,
|
|
2140
|
+
method: 'POST',
|
|
2141
|
+
path: '/v1/tenant/exports',
|
|
2142
|
+
input: z.object({}).strict(),
|
|
2143
|
+
output: z.object({ export: TENANT_EXPORT }).strict(),
|
|
2144
|
+
risk: { readOnly: false, destructive: false, idempotent: false },
|
|
2145
|
+
invoke: 'createTenantExport',
|
|
2146
|
+
example: {},
|
|
2147
|
+
},
|
|
2148
|
+
{
|
|
2149
|
+
name: 'slotlock_list_tenant_exports',
|
|
2150
|
+
title: 'List tenant exports',
|
|
2151
|
+
description: "The tenant's exports, newest first, until each is deleted: pending, ready (with how many parts and rows) or failed.",
|
|
2152
|
+
method: 'GET',
|
|
2153
|
+
path: '/v1/tenant/exports',
|
|
2154
|
+
input: z.object({}).strict(),
|
|
2155
|
+
output: z.object({ exports: z.array(TENANT_EXPORT).max(100) }).strict(),
|
|
2156
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
2157
|
+
invoke: 'listTenantExports',
|
|
2158
|
+
example: {},
|
|
2159
|
+
},
|
|
2160
|
+
{
|
|
2161
|
+
name: 'slotlock_get_tenant_export',
|
|
2162
|
+
title: 'Get a tenant export',
|
|
2163
|
+
description: 'One of the tenant\'s exports: whether it is ready, its parts, rows and bytes, and rows by table. An unknown or deleted one answers 404 export_not_found.',
|
|
2164
|
+
method: 'GET',
|
|
2165
|
+
path: '/v1/tenant/exports/{export_id}',
|
|
2166
|
+
input: z.object({ export_id: EXPORT_ID }).strict(),
|
|
2167
|
+
output: z.object({ export: TENANT_EXPORT }).strict(),
|
|
2168
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
2169
|
+
invoke: 'getTenantExport',
|
|
2170
|
+
example: { export_id: '0b6c8d1e-4f2a-4c3b-9d5e-7f8a9b0c1d2e' },
|
|
2171
|
+
},
|
|
2172
|
+
{
|
|
2173
|
+
name: 'slotlock_get_tenant_export_part',
|
|
2174
|
+
title: 'Read a part of a tenant export',
|
|
2175
|
+
description: 'One part of a ready export, 1 to its parts: one table\'s rows, each an object as Slotlock keeps it (without the tenant). Write each part\'s rows as JSON lines to its table\'s file to keep the export as NDJSON. 409 export_not_ready before it is ready; 404 export_not_found or export_part_not_found.',
|
|
2176
|
+
method: 'GET',
|
|
2177
|
+
path: '/v1/tenant/exports/{export_id}/parts/{part}',
|
|
2178
|
+
input: z.object({ export_id: EXPORT_ID, part: z.string().regex(/^[1-9][0-9]{0,6}$/, 'must be a part number, 1 or more') }).strict(),
|
|
2179
|
+
output: z
|
|
2180
|
+
.object({
|
|
2181
|
+
export_id: EXPORT_ID,
|
|
2182
|
+
part: z.number().int().min(1),
|
|
2183
|
+
table: z.string().min(1).max(63),
|
|
2184
|
+
rows: z.array(z.record(z.string(), z.unknown())).max(5_000),
|
|
2185
|
+
})
|
|
2186
|
+
.strict(),
|
|
2187
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
2188
|
+
invoke: 'getTenantExportPart',
|
|
2189
|
+
example: { export_id: '0b6c8d1e-4f2a-4c3b-9d5e-7f8a9b0c1d2e', part: '1' },
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
name: 'slotlock_delete_tenant_export',
|
|
2193
|
+
title: 'Delete a tenant export',
|
|
2194
|
+
description: 'Delete one of the tenant\'s exports at once, with every part, rather than waiting for it to expire. deleted is false when there was none.',
|
|
2195
|
+
method: 'DELETE',
|
|
2196
|
+
path: '/v1/tenant/exports/{export_id}',
|
|
2197
|
+
input: z.object({ export_id: EXPORT_ID }).strict(),
|
|
2198
|
+
output: z.object({ deleted: z.boolean() }).strict(),
|
|
2199
|
+
risk: { readOnly: false, destructive: true, idempotent: true },
|
|
2200
|
+
invoke: 'deleteTenantExport',
|
|
2201
|
+
example: { export_id: '0b6c8d1e-4f2a-4c3b-9d5e-7f8a9b0c1d2e' },
|
|
2202
|
+
},
|
|
1992
2203
|
// Approval policies (RFC 0004 A4): writes a person approves before they are made.
|
|
1993
2204
|
{
|
|
1994
2205
|
name: 'slotlock_list_approval_policies',
|