slotlock 0.6.3 → 0.8.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 +511 -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 +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- 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,21 @@
|
|
|
1
|
+
/** How a step reads while it runs, once it has, and when it could not. */
|
|
2
|
+
export type SlotlockAssistantStepState = 'run' | 'ok' | 'bad';
|
|
3
|
+
/** A tool step in plain words: "Checking availability…", "Checked availability", "Couldn't check availability". */
|
|
4
|
+
export declare function slotlockAssistantStepLabel(name: string, title: string, state: SlotlockAssistantStepState, write?: boolean): string;
|
|
5
|
+
/** The verb a proposed change's card is approved with, and whether it removes something. */
|
|
6
|
+
export declare function slotlockAssistantActionVerb(operation: string): {
|
|
7
|
+
verb: string;
|
|
8
|
+
busy: string;
|
|
9
|
+
done: string;
|
|
10
|
+
danger: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** The chat's styles: one centred column, the composer docked under it, quiet steps and cards. */
|
|
13
|
+
export declare const SLOTLOCK_ASSISTANT_CSS = "\n.assistant-page { display: grid; }\n.assistant { position: relative; display: flex; flex-direction: column; width: min(100%, 720px); margin: 0 auto; min-height: calc(100dvh - 180px); }\n.assistant .thread { flex: 1; }\n.assistant[data-empty] { justify-content: center; padding-bottom: 12vh; }\n.assistant[data-empty] .thread { display: none; }\n.assistant[data-empty] .dock { order: 1; position: static; padding: 0; background: none; }\n.assistant[data-empty] .assistant-start { order: 2; padding-top: var(--space-3); }\n.assistant:not([data-empty]) .assistant-start { display: none; }\n.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }\n.chips form { margin: 0; }\n.chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 var(--space-3); border-radius: var(--radius-full); border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: inherit; font-size: var(--text-md); cursor: pointer; transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease); }\n.chip:hover { background: var(--hover); border-color: var(--line-strong); color: var(--ink); }\n.thread { list-style: none; margin: 0; padding: var(--space-2) 0 var(--space-6); display: grid; gap: var(--space-6); align-content: start; }\n.turn { margin: 0; min-width: 0; }\n.turn.mine { justify-self: end; max-width: 80%; }\n.bubble { padding: 10px 14px; border-radius: 18px; background: var(--sunken); color: var(--ink); font-size: var(--text-lg); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }\n.turn.bot { justify-self: stretch; }\n.prose { max-width: 68ch; color: var(--ink); font-size: var(--text-lg); line-height: 1.6; overflow-wrap: anywhere; }\n.prose > :first-child { margin-top: 0; }\n.prose > :last-child { margin-bottom: 0; }\n.prose p { margin: 0 0 var(--space-3); color: inherit; }\n.prose strong { font-weight: 600; }\n.prose h4 { margin: var(--space-4) 0 var(--space-2); font-size: var(--text-lg); font-weight: 600; }\n.prose ul, .prose ol { margin: 0 0 var(--space-3); padding-left: 20px; }\n.prose li { margin: 4px 0; }\n.prose li > ul, .prose li > ol { margin: 4px 0 0; }\n.prose a { color: var(--accent); text-underline-offset: 2px; }\n.prose code { font: 400 12.5px/1.4 var(--mono); background: var(--sunken); border-radius: 4px; padding: 1px 5px; }\n.prose pre { margin: 0 0 var(--space-3); padding: var(--space-3); background: var(--sunken); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }\n.prose pre code { background: none; padding: 0; font-size: 12.5px; }\n.prose blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-3); border-left: 2px solid var(--line-strong); color: var(--ink-2); }\n.prose hr { border: 0; border-top: 1px solid var(--line); margin: var(--space-4) 0; }\n.md-table { overflow-x: auto; margin: 0 0 var(--space-3); }\n.md-table table { border-collapse: collapse; font-size: var(--text-md); min-width: 50%; }\n.md-table table { width: auto; }\n.md-table th { font: 600 var(--text-md)/1.4 var(--font); text-transform: none; letter-spacing: 0; background: none; text-align: left; color: var(--ink-2); padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line); white-space: nowrap; }\n.md-table td { padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line-soft); vertical-align: top; }\n.md-table tr:last-child td { border-bottom: 0; }\n.change details.more { margin: 0; }\n.change details.more > summary { cursor: pointer; color: var(--ink-3); font-size: var(--text-sm); list-style: none; }\n.change details.more > summary::-webkit-details-marker { display: none; }\n.change details.more > summary:hover { color: var(--ink-2); }\n.change details.more[open] > summary { margin-bottom: var(--space-2); }\n.turn.steps { justify-self: start; }\ndetails.steps > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--ink-3); font-size: var(--text-md); padding: 2px 0; }\ndetails.steps > summary::-webkit-details-marker { display: none; }\ndetails.steps > summary::after { content: \"\"; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform var(--fast) var(--ease); margin-left: 2px; }\ndetails.steps[open] > summary::after { transform: rotate(45deg); }\ndetails.steps > summary:hover { color: var(--ink-2); }\ndetails.steps.has-bad > summary::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--bad); }\n.step-list { list-style: none; margin: var(--space-2) 0 0; padding: 0 0 0 var(--space-3); border-left: 1px solid var(--line); display: grid; gap: 6px; }\n.step { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.step::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--good); transform: translateY(-1px); }\n.step.bad::before { background: var(--bad); }\n.step.run::before { background: var(--ink-3); }\n.step .why { color: var(--ink-3); font-size: var(--text-sm); }\n.live-steps { display: grid; gap: 6px; }\n.live-steps .step.run, .thinking { background: linear-gradient(90deg, var(--ink-3) 0%, var(--ink) 50%, var(--ink-3) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: assistant-shimmer 1.6s linear infinite; }\n.thinking { font-size: var(--text-md); justify-self: start; }\n@keyframes assistant-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }\n.prose.streaming > :last-child::after { content: \"\"; display: inline-block; width: 8px; height: 8px; margin-left: 4px; border-radius: 50%; background: var(--ink-3); vertical-align: baseline; animation: assistant-pulse 1s ease-in-out infinite; }\n@keyframes assistant-pulse { 50% { opacity: 0.25; } }\n.turn.note { color: var(--ink-3); font-size: var(--text-md); }\n.turn.error { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 10px 12px; border: 1px solid var(--bad-line); background: var(--bad-wash); border-radius: var(--radius); font-size: var(--text-md); color: var(--ink); }\n.turn.error::before { content: \"\"; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }\n.turn.error > span { flex: 1; }\n.turn.error form, .turn.note form { margin: 0; }\n.text-button { appearance: none; border: 0; background: none; padding: 0; font: inherit; font-size: var(--text-md); color: var(--accent); cursor: pointer; }\n.text-button:hover { text-decoration: underline; text-underline-offset: 2px; }\n.turn.action { justify-self: start; width: min(100%, 560px); }\n.change { padding: var(--space-4) var(--space-5); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-lg); display: grid; gap: var(--space-3); }\n.change-title { margin: 0; font-size: var(--text-lg); font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px var(--space-4); margin: 0; font-size: var(--text-md); }\n.change .facts dt { color: var(--ink-3); font-size: var(--text-sm); padding-top: 1px; }\n.change .facts dd { margin: 0; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts dd pre { margin: 0; white-space: pre-wrap; font: 400 12px/1.5 var(--mono); }\n.change .facts .was { color: var(--ink-3); text-decoration: line-through; }\n.change-buttons { display: flex; gap: var(--space-2); flex-wrap: wrap; }\n.change-buttons form { margin: 0; }\n.change-buttons button { height: 32px; }\nbutton.danger { background: var(--bad); color: var(--bad-ink); border-color: var(--bad); }\nbutton.danger:hover { filter: brightness(1.08); }\nbutton.ghost { background: transparent; border-color: transparent; color: var(--ink-2); }\nbutton.ghost:hover { background: var(--hover); color: var(--ink); }\n.receipt { margin: 0; display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.receipt::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); transform: translateY(-1px); }\n.receipt.ok { color: var(--ink); }\n.receipt.ok::before { background: var(--good); }\n.receipt.bad::before { background: var(--bad); }\n.receipt .why { color: var(--ink-3); }\n.dock { position: sticky; bottom: 0; padding: var(--space-6) 0 var(--space-6); background: linear-gradient(to bottom, transparent, var(--canvas) 24px); z-index: 2; }\n.composer { display: grid; margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease); }\n.composer:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--ring); }\n.composer textarea { display: block; width: 100%; min-height: 26px; max-height: 200px; resize: none; border: 0; outline: 0; background: transparent; padding: 14px 16px 0; color: var(--ink); font: inherit; font-size: var(--text-lg); line-height: 1.5; box-shadow: none; }\n.composer textarea:focus { box-shadow: none; border: 0; }\n.composer-bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); padding: var(--space-2); }\n.composer-bar .count { margin-right: auto; padding-left: var(--space-2); font-size: var(--text-sm); color: var(--ink-3); }\n.composer-bar .count.over { color: var(--warn); }\n.send { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: var(--button); color: var(--button-ink); cursor: pointer; transition: opacity var(--fast) var(--ease), background var(--fast) var(--ease); }\n.send:hover { background: var(--button-hover); }\n.send:disabled { opacity: 0.4; cursor: default; }\n.send svg { width: 16px; height: 16px; }\n.send .stop { display: none; }\n.assistant.streaming .send .arrow { display: none; }\n.assistant.streaming .send .stop { display: block; }\n.jump { position: absolute; left: 50%; transform: translate(-50%, 0); top: -8px; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; box-shadow: var(--pop-shadow); }\n.jump[hidden] { display: none; }\n.new-chat svg { width: 14px; height: 14px; }\n.assistant-page[data-empty] .new-chat { display: none; }\n@media (prefers-reduced-motion: reduce) {\n .live-steps .step.run, .thinking { animation: none; color: var(--ink-3); background: none; }\n .prose.streaming > :last-child::after { animation: none; }\n}\n@media (max-width: 760px) {\n .assistant { min-height: calc(100dvh - 140px); }\n .assistant[data-empty] { justify-content: flex-end; padding-bottom: 0; }\n .assistant[data-empty] .assistant-start { order: 1; padding: 0 0 var(--space-3); }\n .assistant[data-empty] .dock { order: 2; position: sticky; padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .chips { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 2px; scrollbar-width: none; }\n .chips::-webkit-scrollbar { display: none; }\n .chip { flex: none; }\n .turn.mine { max-width: 88%; }\n .md-table table { display: table; }\n .md-table thead { display: table-header-group; }\n .md-table tbody { display: table-row-group; }\n .md-table tr { display: table-row; padding: 0; border: 0; }\n .md-table th, .md-table td { display: table-cell; padding: 6px 12px 6px 0; }\n .md-table td { border-bottom: 1px solid var(--line-soft); }\n .composer textarea { font-size: 16px; }\n .dock { padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .change { padding: var(--space-4); }\n .change .facts { grid-template-columns: minmax(0, 1fr); gap: 2px; }\n .change .facts dd { margin-bottom: var(--space-2); }\n .change-buttons form { flex: 1; }\n .change-buttons button { width: 100%; height: 40px; }\n}\n";
|
|
14
|
+
/**
|
|
15
|
+
* The browser side of the chat, appended to app.js: Enter sends, the box grows, a write chip fills
|
|
16
|
+
* the box instead of sending, and a reply streams in from `<root>/assistant/stream` (newline-
|
|
17
|
+
* delimited JSON: text deltas, steps, then the turn as the server renders it). Without it the
|
|
18
|
+
* page still works: every form posts and the page reloads at the latest message.
|
|
19
|
+
* `markdownSource` is renderSlotlockMarkdown's source, so a reply renders as it will once stored.
|
|
20
|
+
*/
|
|
21
|
+
export declare function slotlockAssistantScript(markdownSource: string): string;
|