slotlock 0.6.3 → 0.7.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 +467 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +40 -0
- package/assistant.js +115 -0
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1545 -64
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
package/billing.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type SlotlockPlan, type SlotlockPlanStatus } from './plans.js';
|
|
2
|
+
import type { SlotlockSql } from './store.js';
|
|
3
|
+
export type SlotlockBillingInterval = 'month' | 'year';
|
|
4
|
+
/**
|
|
5
|
+
* Stripe answered with an error, or did not answer, or (`subscription_exists`) the customer already
|
|
6
|
+
* pays for a subscription. The message never holds the key.
|
|
7
|
+
*/
|
|
8
|
+
export declare class SlotlockBillingError extends Error {
|
|
9
|
+
readonly status?: number | undefined;
|
|
10
|
+
readonly code?: 'subscription_exists' | undefined;
|
|
11
|
+
constructor(message: string, status?: number | undefined, code?: 'subscription_exists' | undefined);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The Stripe-Version every request sends unless `apiVersion` says otherwise: Managed Payments needs
|
|
15
|
+
* 2025-03-31.basil or later and became generally available in this one (docs.stripe.com changelog,
|
|
16
|
+
* read 2026-11-09). Pinned, so the account's default version never changes what is sent or read.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SLOTLOCK_STRIPE_API_VERSION = "2026-04-22.dahlia";
|
|
19
|
+
export interface SlotlockStripeOptions {
|
|
20
|
+
/** A restricted key: write on Checkout Sessions and portal sessions, read on Subscriptions. */
|
|
21
|
+
secretKey: string;
|
|
22
|
+
/** The Developer plan's price ids, monthly and yearly. */
|
|
23
|
+
prices: Readonly<Record<SlotlockBillingInterval, string>>;
|
|
24
|
+
/** The Team plan's price ids, monthly and yearly (Teams on Cloud): an organization's checkout. */
|
|
25
|
+
teamPrices?: Readonly<Record<SlotlockBillingInterval, string>>;
|
|
26
|
+
/**
|
|
27
|
+
* Metered overage (overage.ts, SLOTLOCK_OVERAGE): a metered price on a Billing Meter, added to a
|
|
28
|
+
* Developer checkout so operations above the quota can be billed. Off without it.
|
|
29
|
+
*/
|
|
30
|
+
overagePrice?: string;
|
|
31
|
+
/** Sent as Stripe-Version; SLOTLOCK_STRIPE_API_VERSION when unset. */
|
|
32
|
+
apiVersion?: string;
|
|
33
|
+
apiBase?: string;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/** The parts of a Stripe subscription the plan is read from. */
|
|
38
|
+
export interface SlotlockStripeSubscription {
|
|
39
|
+
id: string;
|
|
40
|
+
status: string;
|
|
41
|
+
customer: string | {
|
|
42
|
+
id: string;
|
|
43
|
+
};
|
|
44
|
+
metadata?: Record<string, string>;
|
|
45
|
+
current_period_end?: number | null;
|
|
46
|
+
items?: {
|
|
47
|
+
data?: Array<{
|
|
48
|
+
current_period_end?: number | null;
|
|
49
|
+
price?: {
|
|
50
|
+
id?: string;
|
|
51
|
+
} | string | null;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export interface SlotlockStripe {
|
|
56
|
+
/** A Checkout URL for a Developer (or, with team prices, Team) subscription, tagged with the tenant. */
|
|
57
|
+
checkout(input: {
|
|
58
|
+
tenantRef: string;
|
|
59
|
+
interval: SlotlockBillingInterval;
|
|
60
|
+
/** Developer unless `team`, which needs the Team prices. */
|
|
61
|
+
plan?: 'developer' | 'team';
|
|
62
|
+
customer: string | null;
|
|
63
|
+
successUrl: string;
|
|
64
|
+
cancelUrl: string;
|
|
65
|
+
}): Promise<string>;
|
|
66
|
+
/** A customer portal URL, to change or cancel the subscription and see invoices. */
|
|
67
|
+
portal(input: {
|
|
68
|
+
customer: string;
|
|
69
|
+
returnUrl: string;
|
|
70
|
+
}): Promise<string>;
|
|
71
|
+
subscription(id: string): Promise<SlotlockStripeSubscription>;
|
|
72
|
+
/** The customer's subscriptions that give a plan (`active`, `trialing`, `past_due`). */
|
|
73
|
+
liveSubscriptionsOfCustomer(customer: string): Promise<SlotlockStripeSubscription[]>;
|
|
74
|
+
/**
|
|
75
|
+
* The tenant's subscriptions that give a plan, found by their `tenant_ref` metadata under any
|
|
76
|
+
* customer (Stripe's search, which can lag a minute behind a subscription just created).
|
|
77
|
+
*/
|
|
78
|
+
liveSubscriptionsOfTenant(tenantRef: string): Promise<SlotlockStripeSubscription[]>;
|
|
79
|
+
/** The plan a subscription gives, by its price: Team for a Team price, else Developer. */
|
|
80
|
+
planOf?(subscription: SlotlockStripeSubscription): {
|
|
81
|
+
plan: SlotlockPlan;
|
|
82
|
+
status: SlotlockPlanStatus;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Whether the subscription gives its tenant a plan. */
|
|
86
|
+
export declare function slotlockSubscriptionIsLive(subscription: SlotlockStripeSubscription): boolean;
|
|
87
|
+
export declare function createSlotlockStripe(options: SlotlockStripeOptions): SlotlockStripe;
|
|
88
|
+
/**
|
|
89
|
+
* Whether a webhook's body was signed by Stripe with this endpoint's secret
|
|
90
|
+
* (docs.stripe.com/webhooks#verify-manually): `t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">`,
|
|
91
|
+
* any v1 matching, within `toleranceSeconds` of now.
|
|
92
|
+
*/
|
|
93
|
+
export declare function verifySlotlockStripeSignature(body: string, header: string | null, secret: string, nowMs: number, toleranceSeconds?: number): boolean;
|
|
94
|
+
/** The plan a subscription gives its tenant. */
|
|
95
|
+
export declare function slotlockPlanOfSubscription(subscription: SlotlockStripeSubscription, teamPrices?: Readonly<Record<SlotlockBillingInterval, string>>): {
|
|
96
|
+
plan: SlotlockPlan;
|
|
97
|
+
status: SlotlockPlanStatus;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Handles one Stripe webhook delivery: checks its signature, then, for any event about a
|
|
101
|
+
* subscription, reads that subscription from Stripe (not the event, which may be stale or arrive out
|
|
102
|
+
* of order) and sets its tenant's plan. A tenant that bought twice (two tabs, before either webhook
|
|
103
|
+
* arrived) keeps Developer until its last live subscription ends, and the second purchase is
|
|
104
|
+
* reported through `onDuplicate`, by subscription ids only, for a refund. Answers 400 to an unsigned
|
|
105
|
+
* or unreadable body, 500 when Stripe or the database fails (Stripe retries), and 200 otherwise.
|
|
106
|
+
*/
|
|
107
|
+
export declare function handleSlotlockStripeWebhook(request: Request, deps: {
|
|
108
|
+
secret: string;
|
|
109
|
+
stripe: SlotlockStripe;
|
|
110
|
+
sql: SlotlockSql;
|
|
111
|
+
now?: () => number;
|
|
112
|
+
onError?: (error: unknown) => void;
|
|
113
|
+
onDuplicate?: (subscriptionIds: readonly string[]) => void;
|
|
114
|
+
}): Promise<Response>;
|
package/billing.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Hosted Slotlock's billing through Stripe: Checkout (with Managed Payments, Stripe as merchant of
|
|
2
|
+
// record) to start a Developer subscription, the customer portal to manage one, and the webhook that
|
|
3
|
+
// keeps a tenant's plan (plans.ts) in step with its subscription. Plain fetch, so it runs on a
|
|
4
|
+
// Worker. Self-hosted Slotlock never uses it.
|
|
5
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { readSlotlockTenantPlan, setSlotlockTenantPlan, } from './plans.js';
|
|
7
|
+
/**
|
|
8
|
+
* Stripe answered with an error, or did not answer, or (`subscription_exists`) the customer already
|
|
9
|
+
* pays for a subscription. The message never holds the key.
|
|
10
|
+
*/
|
|
11
|
+
export class SlotlockBillingError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
code;
|
|
14
|
+
constructor(message, status, code) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'SlotlockBillingError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The Stripe-Version every request sends unless `apiVersion` says otherwise: Managed Payments needs
|
|
23
|
+
* 2025-03-31.basil or later and became generally available in this one (docs.stripe.com changelog,
|
|
24
|
+
* read 2026-11-09). Pinned, so the account's default version never changes what is sent or read.
|
|
25
|
+
*/
|
|
26
|
+
export const SLOTLOCK_STRIPE_API_VERSION = '2026-04-22.dahlia';
|
|
27
|
+
/** Statuses in which a subscription gives its tenant the Developer plan (slotlockPlanOfSubscription). */
|
|
28
|
+
const LIVE = new Set(['active', 'trialing', 'past_due']);
|
|
29
|
+
/** Whether the subscription gives its tenant a plan. */
|
|
30
|
+
export function slotlockSubscriptionIsLive(subscription) {
|
|
31
|
+
return LIVE.has(subscription.status);
|
|
32
|
+
}
|
|
33
|
+
const STRIPE_ID = /^[A-Za-z0-9_]{1,255}$/;
|
|
34
|
+
/** A tenant reference as Stripe's search may quote it: no quote, backslash or control character. */
|
|
35
|
+
const SEARCHABLE = /^[^'"\\\p{Cc}]{1,500}$/u;
|
|
36
|
+
export function createSlotlockStripe(options) {
|
|
37
|
+
const base = options.apiBase ?? 'https://api.stripe.com';
|
|
38
|
+
const send = options.fetch ?? fetch;
|
|
39
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
40
|
+
async function call(method, path, body) {
|
|
41
|
+
const headers = {
|
|
42
|
+
authorization: `Bearer ${options.secretKey}`,
|
|
43
|
+
'stripe-version': options.apiVersion ?? SLOTLOCK_STRIPE_API_VERSION,
|
|
44
|
+
};
|
|
45
|
+
if (body)
|
|
46
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
47
|
+
let response;
|
|
48
|
+
try {
|
|
49
|
+
response = await send(`${base}${path}`, {
|
|
50
|
+
method,
|
|
51
|
+
headers,
|
|
52
|
+
...(body ? { body: body.toString() } : {}),
|
|
53
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new SlotlockBillingError('Stripe did not answer');
|
|
58
|
+
}
|
|
59
|
+
const parsed = (await response.json().catch(() => null));
|
|
60
|
+
if (!response.ok || !parsed) {
|
|
61
|
+
const reason = [parsed?.error?.type, parsed?.error?.code].filter(Boolean).join(': ');
|
|
62
|
+
throw new SlotlockBillingError(`Stripe refused ${method} ${path.split('?')[0]} (${String(response.status)}${reason ? `, ${reason}` : ''})`, response.status);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
async function liveSubscriptionsOfCustomer(customer) {
|
|
67
|
+
if (!STRIPE_ID.test(customer))
|
|
68
|
+
throw new SlotlockBillingError('Not a customer id');
|
|
69
|
+
// Without a status, Stripe lists every subscription that is not canceled.
|
|
70
|
+
const page = await call('GET', `/v1/subscriptions?${new URLSearchParams({ customer, limit: '100' }).toString()}`);
|
|
71
|
+
return (page.data ?? []).filter(slotlockSubscriptionIsLive);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
async checkout({ tenantRef, interval, plan = 'developer', customer, successUrl, cancelUrl }) {
|
|
75
|
+
if (plan === 'team' && !options.teamPrices)
|
|
76
|
+
throw new SlotlockBillingError('There is no Team price');
|
|
77
|
+
const price = plan === 'team' ? options.teamPrices[interval] : options.prices[interval];
|
|
78
|
+
// A customer who already pays changes the plan in the portal: a second Checkout would charge
|
|
79
|
+
// twice for one tenant. (A first purchase has no customer yet; the webhook catches that case.)
|
|
80
|
+
if (customer && (await liveSubscriptionsOfCustomer(customer)).length > 0) {
|
|
81
|
+
throw new SlotlockBillingError('The customer has a live subscription already', 409, 'subscription_exists');
|
|
82
|
+
}
|
|
83
|
+
const form = new URLSearchParams({
|
|
84
|
+
mode: 'subscription',
|
|
85
|
+
'line_items[0][price]': price,
|
|
86
|
+
'line_items[0][quantity]': '1',
|
|
87
|
+
// A metered price takes no quantity: what the meter reports is billed.
|
|
88
|
+
...(plan === 'developer' && options.overagePrice ? { 'line_items[1][price]': options.overagePrice } : {}),
|
|
89
|
+
'managed_payments[enabled]': 'true',
|
|
90
|
+
success_url: successUrl,
|
|
91
|
+
cancel_url: cancelUrl,
|
|
92
|
+
client_reference_id: tenantRef.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 200),
|
|
93
|
+
'metadata[tenant_ref]': tenantRef,
|
|
94
|
+
'subscription_data[metadata][tenant_ref]': tenantRef,
|
|
95
|
+
});
|
|
96
|
+
if (customer)
|
|
97
|
+
form.set('customer', customer);
|
|
98
|
+
const session = await call('POST', '/v1/checkout/sessions', form);
|
|
99
|
+
if (!session.url)
|
|
100
|
+
throw new SlotlockBillingError('Stripe gave no Checkout URL');
|
|
101
|
+
return session.url;
|
|
102
|
+
},
|
|
103
|
+
async portal({ customer, returnUrl }) {
|
|
104
|
+
const session = await call('POST', '/v1/billing_portal/sessions', new URLSearchParams({ customer, return_url: returnUrl }));
|
|
105
|
+
if (!session.url)
|
|
106
|
+
throw new SlotlockBillingError('Stripe gave no portal URL');
|
|
107
|
+
return session.url;
|
|
108
|
+
},
|
|
109
|
+
async subscription(id) {
|
|
110
|
+
if (!STRIPE_ID.test(id))
|
|
111
|
+
throw new SlotlockBillingError('Not a subscription id');
|
|
112
|
+
return call('GET', `/v1/subscriptions/${id}`);
|
|
113
|
+
},
|
|
114
|
+
liveSubscriptionsOfCustomer,
|
|
115
|
+
planOf: (subscription) => slotlockPlanOfSubscription(subscription, options.teamPrices),
|
|
116
|
+
async liveSubscriptionsOfTenant(tenantRef) {
|
|
117
|
+
if (!SEARCHABLE.test(tenantRef))
|
|
118
|
+
throw new SlotlockBillingError('Not a searchable tenant');
|
|
119
|
+
const page = await call('GET', `/v1/subscriptions/search?${new URLSearchParams({
|
|
120
|
+
query: `metadata['tenant_ref']:'${tenantRef}'`,
|
|
121
|
+
limit: '100',
|
|
122
|
+
}).toString()}`);
|
|
123
|
+
return (page.data ?? []).filter((found) => slotlockSubscriptionIsLive(found) && found.metadata?.tenant_ref === tenantRef);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a webhook's body was signed by Stripe with this endpoint's secret
|
|
129
|
+
* (docs.stripe.com/webhooks#verify-manually): `t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">`,
|
|
130
|
+
* any v1 matching, within `toleranceSeconds` of now.
|
|
131
|
+
*/
|
|
132
|
+
export function verifySlotlockStripeSignature(body, header, secret, nowMs, toleranceSeconds = 300) {
|
|
133
|
+
if (!header || !secret)
|
|
134
|
+
return false;
|
|
135
|
+
let timestamp;
|
|
136
|
+
const signatures = [];
|
|
137
|
+
for (const part of header.split(',')) {
|
|
138
|
+
const at = part.indexOf('=');
|
|
139
|
+
const key = part.slice(0, at).trim();
|
|
140
|
+
const value = part.slice(at + 1).trim();
|
|
141
|
+
if (key === 't' && /^\d{1,12}$/.test(value))
|
|
142
|
+
timestamp = Number(value);
|
|
143
|
+
if (key === 'v1' && /^[0-9a-f]{64}$/.test(value))
|
|
144
|
+
signatures.push(value);
|
|
145
|
+
}
|
|
146
|
+
if (timestamp === undefined || signatures.length === 0)
|
|
147
|
+
return false;
|
|
148
|
+
if (Math.abs(nowMs / 1000 - timestamp) > toleranceSeconds)
|
|
149
|
+
return false;
|
|
150
|
+
const expected = createHmac('sha256', secret).update(`${String(timestamp)}.${body}`).digest();
|
|
151
|
+
return signatures.some((signature) => timingSafeEqual(Buffer.from(signature, 'hex'), expected));
|
|
152
|
+
}
|
|
153
|
+
/** The plan a subscription gives its tenant. */
|
|
154
|
+
export function slotlockPlanOfSubscription(subscription, teamPrices) {
|
|
155
|
+
// Team when any item is at a Team price: changed to it in the portal, or bought as it.
|
|
156
|
+
const team = teamPrices !== undefined &&
|
|
157
|
+
(subscription.items?.data ?? []).some((item) => {
|
|
158
|
+
const id = typeof item.price === 'string' ? item.price : item.price?.id;
|
|
159
|
+
return id === teamPrices.month || id === teamPrices.year;
|
|
160
|
+
});
|
|
161
|
+
const paid = team ? 'team' : 'developer';
|
|
162
|
+
switch (subscription.status) {
|
|
163
|
+
case 'active':
|
|
164
|
+
case 'trialing':
|
|
165
|
+
return { plan: paid, status: 'active' };
|
|
166
|
+
case 'past_due':
|
|
167
|
+
// Stripe is retrying the payment: keep the plan while it does.
|
|
168
|
+
return { plan: paid, status: 'past_due' };
|
|
169
|
+
default:
|
|
170
|
+
// canceled, unpaid, incomplete, incomplete_expired, paused: the Free limits.
|
|
171
|
+
return { plan: 'free', status: 'canceled' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** The subscription a webhook event is about, if any. */
|
|
175
|
+
function subscriptionOf(event) {
|
|
176
|
+
const object = event?.data?.object;
|
|
177
|
+
if (!object)
|
|
178
|
+
return null;
|
|
179
|
+
if (object.object === 'subscription' && typeof object.id === 'string')
|
|
180
|
+
return object.id;
|
|
181
|
+
const field = object.subscription;
|
|
182
|
+
if (typeof field === 'string')
|
|
183
|
+
return field;
|
|
184
|
+
if (field && typeof field.id === 'string')
|
|
185
|
+
return field.id;
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Handles one Stripe webhook delivery: checks its signature, then, for any event about a
|
|
190
|
+
* subscription, reads that subscription from Stripe (not the event, which may be stale or arrive out
|
|
191
|
+
* of order) and sets its tenant's plan. A tenant that bought twice (two tabs, before either webhook
|
|
192
|
+
* arrived) keeps Developer until its last live subscription ends, and the second purchase is
|
|
193
|
+
* reported through `onDuplicate`, by subscription ids only, for a refund. Answers 400 to an unsigned
|
|
194
|
+
* or unreadable body, 500 when Stripe or the database fails (Stripe retries), and 200 otherwise.
|
|
195
|
+
*/
|
|
196
|
+
export async function handleSlotlockStripeWebhook(request, deps) {
|
|
197
|
+
const text = (status, body) => new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
198
|
+
if (request.method !== 'POST')
|
|
199
|
+
return text(405, 'Use POST.');
|
|
200
|
+
if (Number(request.headers.get('content-length') ?? 0) > 1_000_000)
|
|
201
|
+
return text(413, 'Too large.');
|
|
202
|
+
const body = await request.text();
|
|
203
|
+
if (body.length > 1_000_000)
|
|
204
|
+
return text(413, 'Too large.');
|
|
205
|
+
const signed = verifySlotlockStripeSignature(body, request.headers.get('stripe-signature'), deps.secret, (deps.now ?? Date.now)());
|
|
206
|
+
if (!signed)
|
|
207
|
+
return text(400, 'Bad signature.');
|
|
208
|
+
let event;
|
|
209
|
+
try {
|
|
210
|
+
event = JSON.parse(body);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return text(400, 'Not JSON.');
|
|
214
|
+
}
|
|
215
|
+
const id = subscriptionOf(event);
|
|
216
|
+
if (!id)
|
|
217
|
+
return text(200, 'Ignored.');
|
|
218
|
+
try {
|
|
219
|
+
const subscription = await deps.stripe.subscription(id);
|
|
220
|
+
const tenantRef = subscription.metadata?.tenant_ref;
|
|
221
|
+
// Not one of the dashboard's: nothing to set.
|
|
222
|
+
if (!tenantRef)
|
|
223
|
+
return text(200, 'Ignored.');
|
|
224
|
+
const current = await readSlotlockTenantPlan(deps.sql, tenantRef);
|
|
225
|
+
const named = current?.stripeSubscription ?? null;
|
|
226
|
+
let chosen = subscription;
|
|
227
|
+
if (!slotlockSubscriptionIsLive(subscription)) {
|
|
228
|
+
// An ended subscription only ends the plan it gave: not one the tenant has started since.
|
|
229
|
+
if (named && named !== subscription.id)
|
|
230
|
+
return text(200, 'Ignored.');
|
|
231
|
+
// Nor one the tenant still pays for under another subscription.
|
|
232
|
+
const other = (await deps.stripe.liveSubscriptionsOfTenant(tenantRef)).find((found) => found.id !== subscription.id);
|
|
233
|
+
if (other)
|
|
234
|
+
chosen = other;
|
|
235
|
+
}
|
|
236
|
+
else if (named && named !== subscription.id) {
|
|
237
|
+
const previous = await deps.stripe.subscription(named).catch(() => null);
|
|
238
|
+
if (previous && slotlockSubscriptionIsLive(previous))
|
|
239
|
+
deps.onDuplicate?.([previous.id, subscription.id]);
|
|
240
|
+
}
|
|
241
|
+
const { plan, status } = deps.stripe.planOf?.(chosen) ?? slotlockPlanOfSubscription(chosen);
|
|
242
|
+
const customer = typeof chosen.customer === 'string' ? chosen.customer : chosen.customer.id;
|
|
243
|
+
const end = chosen.items?.data?.[0]?.current_period_end ?? chosen.current_period_end ?? null;
|
|
244
|
+
await setSlotlockTenantPlan(deps.sql, {
|
|
245
|
+
tenantRef,
|
|
246
|
+
plan,
|
|
247
|
+
status,
|
|
248
|
+
stripeCustomer: customer,
|
|
249
|
+
stripeSubscription: plan === 'free' ? null : chosen.id,
|
|
250
|
+
currentPeriodEnd: plan === 'free' || !end ? null : new Date(end * 1000),
|
|
251
|
+
});
|
|
252
|
+
return text(200, 'Updated.');
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
deps.onError?.(error);
|
|
256
|
+
return text(500, 'Try again.');
|
|
257
|
+
}
|
|
258
|
+
}
|
package/config.d.ts
CHANGED
|
@@ -30,17 +30,38 @@ export interface SlotlockServeDashboardConfig {
|
|
|
30
30
|
githubClientId: string;
|
|
31
31
|
githubClientSecret: string;
|
|
32
32
|
sessionSecret: string;
|
|
33
|
-
/** GitHub user ids that may sign in, or `'*'` for
|
|
33
|
+
/** GitHub user ids and Google accounts (`google:<subject>`) that may sign in, or `'*'` for everyone. */
|
|
34
34
|
allowedUsers: '*' | string[];
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/** Sign-in with Google too (SLOTLOCK_GOOGLE_CLIENT_ID and SLOTLOCK_GOOGLE_CLIENT_SECRET). */
|
|
36
|
+
google?: {
|
|
37
|
+
clientId: string;
|
|
38
|
+
clientSecret: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* GitHub organization ids whose members share a tenant each (SLOTLOCK_DASHBOARD_ORGS), or
|
|
42
|
+
* `'enabled'`: those an owner turned on from the dashboard (Teams on Cloud, teams.ts).
|
|
43
|
+
*/
|
|
44
|
+
organizations?: string[] | 'enabled';
|
|
37
45
|
/** What the deployment is called in the dashboard (SLOTLOCK_DASHBOARD_NAME). */
|
|
38
46
|
name?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The terms people accept by signing in (SLOTLOCK_DASHBOARD_TERMS_URL): the sign-in page links
|
|
49
|
+
* them beside the button. Without it, the page names no terms.
|
|
50
|
+
*/
|
|
51
|
+
termsUrl?: string;
|
|
52
|
+
/** What the sign-in page links as the privacy notice (SLOTLOCK_DASHBOARD_PRIVACY_URL). */
|
|
53
|
+
privacyUrl?: string;
|
|
39
54
|
/**
|
|
40
55
|
* Seals each tenant's model API key for the dashboard assistant (SLOTLOCK_ASSISTANT_SECRET).
|
|
41
56
|
* Without it the assistant is off.
|
|
42
57
|
*/
|
|
43
58
|
assistantSecret?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Secrets model keys may still be sealed under, newest first (SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS,
|
|
61
|
+
* comma-separated): each key one opens is sealed again under SLOTLOCK_ASSISTANT_SECRET when next
|
|
62
|
+
* used.
|
|
63
|
+
*/
|
|
64
|
+
assistantPreviousSecrets?: string[];
|
|
44
65
|
/**
|
|
45
66
|
* Let the assistant reach a model at a local or private network address
|
|
46
67
|
* (SLOTLOCK_ASSISTANT_PRIVATE_URLS=on): a self-hosted model on the same machine or network.
|
|
@@ -63,7 +84,7 @@ export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDash
|
|
|
63
84
|
* well: a server whose dashboard runs elsewhere (on Cloudflare Workers) still takes those
|
|
64
85
|
* organizations' keys, and only theirs.
|
|
65
86
|
*/
|
|
66
|
-
export declare function readDashboardOrganizations(env: SlotlockEnv): string[] | undefined;
|
|
87
|
+
export declare function readDashboardOrganizations(env: SlotlockEnv): string[] | 'enabled' | undefined;
|
|
67
88
|
/** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
|
|
68
89
|
export interface SlotlockRate {
|
|
69
90
|
perSecond: number;
|
package/config.js
CHANGED
|
@@ -114,8 +114,9 @@ export function readDashboardUsers(env) {
|
|
|
114
114
|
const users = optional(env, 'SLOTLOCK_DASHBOARD_USERS')?.replaceAll(' ', '');
|
|
115
115
|
if (users === undefined)
|
|
116
116
|
return undefined;
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const person = '(?:[1-9]\\d{0,19}|google:[A-Za-z0-9._-]{1,255})';
|
|
118
|
+
if (users !== '*' && !new RegExp(`^${person}(,${person})*$`).test(users)) {
|
|
119
|
+
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_USERS must be * for every account, or a comma-separated list of GitHub user ids (gh api users/<login> --jq .id) and Google accounts as google:<subject>');
|
|
119
120
|
}
|
|
120
121
|
return users === '*' ? '*' : [...new Set(users.split(','))];
|
|
121
122
|
}
|
|
@@ -135,6 +136,14 @@ export function readDashboardConfig(env) {
|
|
|
135
136
|
if (githubClientSecret === undefined) {
|
|
136
137
|
throw new SlotlockConfigError('SLOTLOCK_GITHUB_CLIENT_SECRET is required to serve the dashboard: a client secret of that GitHub OAuth app');
|
|
137
138
|
}
|
|
139
|
+
const googleClientId = optional(env, 'SLOTLOCK_GOOGLE_CLIENT_ID');
|
|
140
|
+
const googleClientSecret = readSecret(env, 'SLOTLOCK_GOOGLE_CLIENT_SECRET');
|
|
141
|
+
if ((googleClientId === undefined) !== (googleClientSecret === undefined)) {
|
|
142
|
+
throw new SlotlockConfigError('SLOTLOCK_GOOGLE_CLIENT_ID and SLOTLOCK_GOOGLE_CLIENT_SECRET go together: an OAuth client of a Google Cloud project whose redirect URI is <SLOTLOCK_PUBLIC_URL>/dashboard/callback');
|
|
143
|
+
}
|
|
144
|
+
if (googleClientId !== undefined && !/^[A-Za-z0-9._-]{1,200}$/.test(googleClientId)) {
|
|
145
|
+
throw new SlotlockConfigError('SLOTLOCK_GOOGLE_CLIENT_ID must be a Google OAuth client id');
|
|
146
|
+
}
|
|
138
147
|
const sessionSecret = readSecret(env, 'SLOTLOCK_SESSION_SECRET');
|
|
139
148
|
if (sessionSecret === undefined) {
|
|
140
149
|
throw new SlotlockConfigError(`SLOTLOCK_SESSION_SECRET is required to serve the dashboard: it signs sign-in sessions; ${SECRET_HINT}`);
|
|
@@ -143,6 +152,7 @@ export function readDashboardConfig(env) {
|
|
|
143
152
|
env.SLOTLOCK_GITHUB_CLIENT_SECRET,
|
|
144
153
|
env.SLOTLOCK_AUTH_TOKEN,
|
|
145
154
|
env.SLOTLOCK_CONFIRMATION_SECRET,
|
|
155
|
+
...(googleClientSecret !== undefined ? [googleClientSecret] : []),
|
|
146
156
|
];
|
|
147
157
|
if (others.includes(sessionSecret)) {
|
|
148
158
|
throw new SlotlockConfigError('SLOTLOCK_SESSION_SECRET must differ from every other secret');
|
|
@@ -162,26 +172,83 @@ export function readDashboardConfig(env) {
|
|
|
162
172
|
[sessionSecret, ...others].includes(assistantSecret)) {
|
|
163
173
|
throw new SlotlockConfigError('SLOTLOCK_ASSISTANT_SECRET must differ from every other secret');
|
|
164
174
|
}
|
|
175
|
+
const previousSecrets = readAssistantPreviousSecrets(env, assistantSecret, [sessionSecret, ...others]);
|
|
165
176
|
const privateUrls = optional(env, 'SLOTLOCK_ASSISTANT_PRIVATE_URLS') ?? 'off';
|
|
166
177
|
if (privateUrls !== 'on' && privateUrls !== 'off') {
|
|
167
178
|
throw new SlotlockConfigError('SLOTLOCK_ASSISTANT_PRIVATE_URLS must be on or off');
|
|
168
179
|
}
|
|
169
180
|
const orgs = readDashboardOrganizations(env);
|
|
181
|
+
if (Array.isArray(orgs) && googleClientId !== undefined) {
|
|
182
|
+
throw new SlotlockConfigError('SLOTLOCK_GOOGLE_CLIENT_ID does not go with a list of SLOTLOCK_DASHBOARD_ORGS: those organizations are GitHub\'s; use SLOTLOCK_DASHBOARD_ORGS=enabled to have both');
|
|
183
|
+
}
|
|
170
184
|
const name = optional(env, 'SLOTLOCK_DASHBOARD_NAME')?.trim();
|
|
171
185
|
if (name !== undefined && name !== '' && !SLOTLOCK_DASHBOARD_NAME_PATTERN.test(name)) {
|
|
172
186
|
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_NAME must be 1-60 characters, without <, > or control or invisible characters');
|
|
173
187
|
}
|
|
188
|
+
const termsUrl = readPolicyUrl(env, 'SLOTLOCK_DASHBOARD_TERMS_URL');
|
|
189
|
+
const privacyUrl = readPolicyUrl(env, 'SLOTLOCK_DASHBOARD_PRIVACY_URL');
|
|
174
190
|
return {
|
|
175
191
|
githubClientId,
|
|
176
192
|
githubClientSecret,
|
|
177
193
|
sessionSecret,
|
|
178
194
|
allowedUsers: users,
|
|
195
|
+
...(googleClientId !== undefined && googleClientSecret !== undefined
|
|
196
|
+
? { google: { clientId: googleClientId, clientSecret: googleClientSecret } }
|
|
197
|
+
: {}),
|
|
179
198
|
...(orgs !== undefined ? { organizations: orgs } : {}),
|
|
180
199
|
...(name !== undefined && name !== '' ? { name } : {}),
|
|
200
|
+
...(termsUrl !== undefined ? { termsUrl } : {}),
|
|
201
|
+
...(privacyUrl !== undefined ? { privacyUrl } : {}),
|
|
181
202
|
...(assistantSecret !== undefined ? { assistantSecret } : {}),
|
|
203
|
+
...(previousSecrets.length > 0 ? { assistantPreviousSecrets: previousSecrets } : {}),
|
|
182
204
|
...(assistantSecret !== undefined && privateUrls === 'on' ? { assistantPrivateUrls: true } : {}),
|
|
183
205
|
};
|
|
184
206
|
}
|
|
207
|
+
/** Previous assistant secrets a deployment may keep (assistant.ts SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS). */
|
|
208
|
+
const MAX_ASSISTANT_PREVIOUS_SECRETS = 3;
|
|
209
|
+
/**
|
|
210
|
+
* SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS: up to three earlier values of SLOTLOCK_ASSISTANT_SECRET,
|
|
211
|
+
* comma-separated, newest first, each as strong as a secret must be and unlike every other secret.
|
|
212
|
+
* Only with SLOTLOCK_ASSISTANT_SECRET: without a current secret nothing would seal the keys again.
|
|
213
|
+
*/
|
|
214
|
+
function readAssistantPreviousSecrets(env, current, others) {
|
|
215
|
+
const name = 'SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS';
|
|
216
|
+
const value = env[name];
|
|
217
|
+
if (value === undefined || value.trim() === '')
|
|
218
|
+
return [];
|
|
219
|
+
if (current === undefined) {
|
|
220
|
+
throw new SlotlockConfigError(`${name} needs SLOTLOCK_ASSISTANT_SECRET: the secret keys are sealed again under`);
|
|
221
|
+
}
|
|
222
|
+
const secrets = value.split(',').map((part) => readSecret({ [name]: part }, name));
|
|
223
|
+
if (secrets.some((secret) => secret === undefined)) {
|
|
224
|
+
throw new SlotlockConfigError(`${name} must be comma-separated secrets, with no empty one`);
|
|
225
|
+
}
|
|
226
|
+
const listed = secrets;
|
|
227
|
+
if (listed.length > MAX_ASSISTANT_PREVIOUS_SECRETS) {
|
|
228
|
+
throw new SlotlockConfigError(`${name} takes at most ${String(MAX_ASSISTANT_PREVIOUS_SECRETS)} secrets`);
|
|
229
|
+
}
|
|
230
|
+
if (new Set(listed).size !== listed.length || listed.some((secret) => secret === current || others.includes(secret))) {
|
|
231
|
+
throw new SlotlockConfigError(`${name} must differ from each other, from SLOTLOCK_ASSISTANT_SECRET and from every other secret`);
|
|
232
|
+
}
|
|
233
|
+
return listed;
|
|
234
|
+
}
|
|
235
|
+
/** A policy page the sign-in page links: an https URL, or undefined when the setting is empty. */
|
|
236
|
+
function readPolicyUrl(env, name) {
|
|
237
|
+
const value = optional(env, name)?.trim();
|
|
238
|
+
if (value === undefined || value === '')
|
|
239
|
+
return undefined;
|
|
240
|
+
let url;
|
|
241
|
+
try {
|
|
242
|
+
url = new URL(value);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
throw new SlotlockConfigError(`${name} must be an https URL`);
|
|
246
|
+
}
|
|
247
|
+
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') {
|
|
248
|
+
throw new SlotlockConfigError(`${name} must be an https URL`);
|
|
249
|
+
}
|
|
250
|
+
return url.href;
|
|
251
|
+
}
|
|
185
252
|
/**
|
|
186
253
|
* The GitHub organization ids of SLOTLOCK_DASHBOARD_ORGS, or undefined when unset. Read on its own as
|
|
187
254
|
* well: a server whose dashboard runs elsewhere (on Cloudflare Workers) still takes those
|
|
@@ -191,8 +258,10 @@ export function readDashboardOrganizations(env) {
|
|
|
191
258
|
const orgs = optional(env, 'SLOTLOCK_DASHBOARD_ORGS')?.replaceAll(' ', '');
|
|
192
259
|
if (orgs === undefined)
|
|
193
260
|
return undefined;
|
|
261
|
+
if (orgs === 'enabled')
|
|
262
|
+
return 'enabled';
|
|
194
263
|
if (!/^[1-9]\d{0,19}(,[1-9]\d{0,19})*$/.test(orgs)) {
|
|
195
|
-
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_ORGS must be a comma-separated list of GitHub organization ids (gh api orgs/<login> --jq .id)');
|
|
264
|
+
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_ORGS must be a comma-separated list of GitHub organization ids (gh api orgs/<login> --jq .id), or enabled');
|
|
196
265
|
}
|
|
197
266
|
return [...new Set(orgs.split(','))];
|
|
198
267
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SLOTLOCK_DASHBOARD_ICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"14\" fill=\"#111110\"/><g transform=\"translate(12.2 14.1) scale(0.465)\"><path fill=\"#F6F4EE\" d=\"M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z\"/><path fill=\"#FF5B2E\" d=\"M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z\"/></g></svg>\n";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The Slotlock mark the dashboard serves as its favicon. A copy of site/public/favicon.svg, which a
|
|
2
|
+
// test keeps identical; the dashboard's Content-Security-Policy allows images from its own origin
|
|
3
|
+
// only, so the mark is served, not inlined.
|
|
4
|
+
export const SLOTLOCK_DASHBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#111110"/><g transform="translate(12.2 14.1) scale(0.465)"><path fill="#F6F4EE" d="M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z"/><path fill="#FF5B2E" d="M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z"/></g></svg>
|
|
5
|
+
`;
|