@shaferllc/keel 0.83.2 → 0.83.4
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/README.md +7 -24
- package/dist/billing/billable.d.ts +1 -0
- package/dist/billing/billable.js +8 -0
- package/dist/billing/drivers/fake.d.ts +4 -0
- package/dist/billing/drivers/fake.js +5 -0
- package/dist/billing/drivers/stripe.d.ts +1 -0
- package/dist/billing/drivers/stripe.js +7 -0
- package/dist/billing/gateway.d.ts +7 -0
- package/dist/billing/index.d.ts +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.js +24 -0
- package/dist/gates/index.d.ts +10 -0
- package/dist/gates/index.js +9 -0
- package/dist/gates/models.d.ts +42 -0
- package/dist/gates/models.js +76 -0
- package/dist/gates/provider.d.ts +10 -0
- package/dist/gates/provider.js +13 -0
- package/dist/hosting/cloudflare.d.ts +43 -0
- package/dist/hosting/cloudflare.js +66 -0
- package/dist/hosting/dump.d.ts +7 -0
- package/dist/hosting/dump.js +58 -0
- package/dist/hosting/hostname.d.ts +7 -0
- package/dist/hosting/hostname.js +25 -0
- package/dist/hosting/index.d.ts +14 -0
- package/dist/hosting/index.js +12 -0
- package/dist/hosting/secrets.d.ts +15 -0
- package/dist/hosting/secrets.js +27 -0
- package/dist/mcp/cloud.d.ts +9 -0
- package/dist/mcp/cloud.js +179 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +20 -1
- package/docs/ai-manifest.json +20 -1
- package/docs/ai.md +36 -0
- package/docs/billing.md +10 -1
- package/docs/changelog.md +30 -0
- package/docs/examples/gates.ts +30 -0
- package/docs/examples/hosting.ts +37 -0
- package/docs/gates.md +75 -0
- package/docs/hosting.md +97 -0
- package/llms-full.txt +232 -1
- package/llms.txt +4 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -161,6 +161,8 @@ src/db/ Database adapters (D1, Postgres, libSQL)
|
|
|
161
161
|
src/api/ CRUD REST resources from a model
|
|
162
162
|
src/openapi/ Generates an OpenAPI spec from the routes
|
|
163
163
|
src/billing/ Subscription billing — Stripe + Paddle
|
|
164
|
+
src/gates/ Signup gates — invite codes & email allowlist
|
|
165
|
+
src/hosting/ Cloudflare / dump / secrets helpers for hosted apps
|
|
164
166
|
src/watch/ The debug dashboard
|
|
165
167
|
src/mcp/ The MCP server (docs + API for AI agents)
|
|
166
168
|
src/vite/ The Vite plugin
|
|
@@ -203,7 +205,9 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
|
|
|
203
205
|
| [Lifecycle Hooks](./docs/hooks.md) | onReady/onShutdown, graceful shutdown, onRoute |
|
|
204
206
|
| [Sessions](./docs/sessions.md) | Cookie-backed sessions, flash messages |
|
|
205
207
|
| [Authentication](./docs/authentication.md) | Session auth, guards, user provider |
|
|
206
|
-
| [
|
|
208
|
+
| [Gates](./docs/gates.md) | Signup gating — invite codes & email allowlist |
|
|
209
|
+
| [Hosting](./docs/hosting.md) | Cloudflare, SQL dump, secrets for hosted Workers |
|
|
210
|
+
| [Authorization](./docs/authorization.md) | Ability gates & policies, `can`/`authorize` |
|
|
207
211
|
| [Database](./docs/database.md) | Driver-agnostic query builder |
|
|
208
212
|
| [Models](./docs/models.md) | Active-record: find/create/save, casts, relations |
|
|
209
213
|
| [Migrations](./docs/migrations.md) | Schema builder + migrator, dialect-aware |
|
|
@@ -266,29 +270,8 @@ lines / ~91% branches**.
|
|
|
266
270
|
|
|
267
271
|
## Roadmap
|
|
268
272
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- [x] View / templating layer (Hono JSX) — **v0.2.0**
|
|
273
|
-
- [x] Cloudflare Workers–safe core — **v0.2.0**
|
|
274
|
-
- [x] Global helpers: `config()`, `app()`, `view()` — **v0.3.0 / v0.4.0**
|
|
275
|
-
- [x] Error & exception handling — **v0.5.0**
|
|
276
|
-
- [x] Request/response + container helpers — **v0.6.0–v0.9.0**
|
|
277
|
-
- [x] Validation (Zod-compatible) — **v0.10.0**
|
|
278
|
-
- [x] First-class routing (groups, resources, named routes) — **v0.11.0**
|
|
279
|
-
- [x] Domain routing, matchers, Inertia adapter — **v0.12.0**
|
|
280
|
-
- [x] Test suite (~99% coverage)
|
|
281
|
-
- [x] Query builder (driver-agnostic) — **v0.28.0**
|
|
282
|
-
- [x] Active-record Model layer — **v0.29.0**
|
|
283
|
-
- [x] Migrations (schema builder) — **v0.30.0**
|
|
284
|
-
- [x] Model relationships (hasMany / belongsTo / belongsToMany) — **v0.31.0**
|
|
285
|
-
- [x] Factories & seeders (built-in Faker) — **v0.32.0**
|
|
286
|
-
- [x] Model attribute casts + mass-assignment guarding — **v0.33.0**
|
|
287
|
-
- [x] Mail (fluent mailer, pluggable transports) — **v0.34.0**
|
|
288
|
-
- [x] Queues / background jobs (pluggable drivers) — **v0.35.0**
|
|
289
|
-
- [x] Notifications (multi-channel, queueable) — **v0.36.0**
|
|
290
|
-
- [ ] Publish `src/core` as the `@keel/core` package
|
|
291
|
-
|
|
273
|
+
The MVP core and the major subsystems are shipped. Recent focus is Keel Cloud
|
|
274
|
+
support (gates, hosting, MCP Cloud tools, billing portal) and starter kits.
|
|
292
275
|
See [CHANGELOG.md](./CHANGELOG.md) for release history.
|
|
293
276
|
|
|
294
277
|
## Contributing
|
|
@@ -67,6 +67,7 @@ export interface BillableInstance {
|
|
|
67
67
|
checkout(prices: string | PriceRef | (string | PriceRef)[], options?: ProductCheckoutOptions): Promise<CheckoutSession>;
|
|
68
68
|
createSetupIntent(): Promise<SetupIntent>;
|
|
69
69
|
paymentMethods(): Promise<PaymentMethod[]>;
|
|
70
|
+
billingPortal(returnUrl: string): Promise<import("./gateway.js").BillingPortalSession>;
|
|
70
71
|
}
|
|
71
72
|
/**
|
|
72
73
|
* The class a `Billable(Base)` call returns: billing instances plus all of
|
package/dist/billing/billable.js
CHANGED
|
@@ -171,6 +171,14 @@ export function Billable(Base) {
|
|
|
171
171
|
return [];
|
|
172
172
|
return gateway.paymentMethods(this.billing_customer_id);
|
|
173
173
|
}
|
|
174
|
+
/** Open the hosted customer portal (manage card / cancel). */
|
|
175
|
+
async billingPortal(returnUrl) {
|
|
176
|
+
const gateway = this.billingGateway();
|
|
177
|
+
if (!gateway.createBillingPortalSession) {
|
|
178
|
+
throw new BillingError(`The "${gateway.name}" gateway does not support a billing portal.`, gateway.name);
|
|
179
|
+
}
|
|
180
|
+
return gateway.createBillingPortalSession(await this.getCustomerId(), returnUrl);
|
|
181
|
+
}
|
|
174
182
|
}
|
|
175
183
|
return BillableModel;
|
|
176
184
|
}
|
|
@@ -42,6 +42,10 @@ export declare class FakeGateway implements BillingGateway {
|
|
|
42
42
|
refund(params: RefundParams): Promise<GatewayRefund>;
|
|
43
43
|
listInvoices(customer: string): Promise<GatewayInvoice[]>;
|
|
44
44
|
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
|
|
45
|
+
createBillingPortalSession(customer: string, returnUrl: string): Promise<{
|
|
46
|
+
id: string;
|
|
47
|
+
url: string;
|
|
48
|
+
}>;
|
|
45
49
|
createSetupIntent(customer: string): Promise<SetupIntent>;
|
|
46
50
|
paymentMethods(customer: string): Promise<PaymentMethod[]>;
|
|
47
51
|
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<import("../gateway.js").WebhookEvent | null>;
|
|
@@ -129,6 +129,11 @@ export class FakeGateway {
|
|
|
129
129
|
const id = this.id("cs");
|
|
130
130
|
return { id, url: `https://fake.checkout/${id}`, clientToken: `ctok_${id}` };
|
|
131
131
|
}
|
|
132
|
+
async createBillingPortalSession(customer, returnUrl) {
|
|
133
|
+
this.record("createBillingPortalSession", customer, returnUrl);
|
|
134
|
+
const id = this.id("bps");
|
|
135
|
+
return { id, url: `https://fake.portal/${id}?return=${encodeURIComponent(returnUrl)}` };
|
|
136
|
+
}
|
|
132
137
|
async createSetupIntent(customer) {
|
|
133
138
|
this.record("createSetupIntent", customer);
|
|
134
139
|
const id = this.id("seti");
|
|
@@ -28,6 +28,7 @@ export declare class StripeGateway implements BillingGateway {
|
|
|
28
28
|
createCheckoutSession(params: CheckoutParams): Promise<CheckoutSession>;
|
|
29
29
|
createSetupIntent(customer: string): Promise<SetupIntent>;
|
|
30
30
|
paymentMethods(customer: string): Promise<PaymentMethod[]>;
|
|
31
|
+
createBillingPortalSession(customer: string, returnUrl: string): Promise<import("../gateway.js").BillingPortalSession>;
|
|
31
32
|
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<WebhookEvent | null>;
|
|
32
33
|
private normalize;
|
|
33
34
|
}
|
|
@@ -219,6 +219,13 @@ export class StripeGateway {
|
|
|
219
219
|
};
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
|
+
async createBillingPortalSession(customer, returnUrl) {
|
|
223
|
+
const session = await this.call("POST", "/billing_portal/sessions", {
|
|
224
|
+
customer,
|
|
225
|
+
return_url: returnUrl,
|
|
226
|
+
});
|
|
227
|
+
return { id: String(session.id), url: String(session.url ?? "") };
|
|
228
|
+
}
|
|
222
229
|
/* ------------------------------ webhooks ------------------------------- */
|
|
223
230
|
async verifyWebhook(rawBody, headers, secret) {
|
|
224
231
|
const header = headers("stripe-signature");
|
|
@@ -162,6 +162,13 @@ export interface BillingGateway {
|
|
|
162
162
|
verifyWebhook(rawBody: string, headers: HeaderBag, secret: string): Promise<WebhookEvent | null>;
|
|
163
163
|
createSetupIntent?(customer: string): Promise<SetupIntent>;
|
|
164
164
|
paymentMethods?(customer: string): Promise<PaymentMethod[]>;
|
|
165
|
+
/** Hosted customer portal (manage card / cancel). Stripe-style. */
|
|
166
|
+
createBillingPortalSession?(customer: string, returnUrl: string): Promise<BillingPortalSession>;
|
|
167
|
+
}
|
|
168
|
+
/** A hosted billing-portal redirect. */
|
|
169
|
+
export interface BillingPortalSession {
|
|
170
|
+
id: string;
|
|
171
|
+
url: string;
|
|
165
172
|
}
|
|
166
173
|
/** Raised for gateway/config problems and unsupported-capability calls. */
|
|
167
174
|
export declare class BillingError extends Error {
|
package/dist/billing/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type { BillableTarget, CheckoutOptions } from "./builder.js";
|
|
|
16
16
|
export { BillingManager, billing, setBilling } from "./manager.js";
|
|
17
17
|
export type { GatewayFactory } from "./manager.js";
|
|
18
18
|
export { BillingError } from "./gateway.js";
|
|
19
|
-
export type { BillingGateway, HeaderBag, CustomerDetails, GatewayCustomer, PriceRef, CreateSubscriptionParams, GatewaySubscription, GatewaySubscriptionItem, SwapParams, CancelParams, ChargeParams, GatewayCharge, RefundParams, GatewayRefund, GatewayInvoice, CheckoutParams, CheckoutSession, SetupIntent, PaymentMethod, WebhookEvent, } from "./gateway.js";
|
|
19
|
+
export type { BillingGateway, HeaderBag, CustomerDetails, GatewayCustomer, PriceRef, CreateSubscriptionParams, GatewaySubscription, GatewaySubscriptionItem, SwapParams, CancelParams, ChargeParams, GatewayCharge, RefundParams, GatewayRefund, GatewayInvoice, CheckoutParams, CheckoutSession, SetupIntent, PaymentMethod, BillingPortalSession, WebhookEvent, } from "./gateway.js";
|
|
20
20
|
export { registerDefaultGateways, StripeGateway, PaddleGateway, FakeGateway } from "./drivers/index.js";
|
|
21
21
|
export type { FakeCall } from "./drivers/index.js";
|
|
22
22
|
export { handleWebhook, resolveBillableUsing } from "./webhooks.js";
|
package/dist/core/index.d.ts
CHANGED
|
@@ -102,7 +102,7 @@ export { Session, session, sessionMiddleware } from "./session.js";
|
|
|
102
102
|
export type { SessionOptions } from "./session.js";
|
|
103
103
|
export { Auth, auth, authGuard, bearerAuth, basicAuth, tokenAuth, token, tokenCan, setUserProvider } from "./auth.js";
|
|
104
104
|
export type { UserProvider, BasicVerifier } from "./auth.js";
|
|
105
|
-
export { createToken, verifyToken, revokeToken, revokeTokens, listTokens, tokenAllows, tokenDenies, setTokensTable, } from "./tokens.js";
|
|
105
|
+
export { createToken, verifyToken, revokeToken, revokeTokens, listTokens, tokenAllows, tokenDenies, setTokensTable, tokensMigration, } from "./tokens.js";
|
|
106
106
|
export type { AccessToken, CreateTokenOptions, IssuedToken } from "./tokens.js";
|
|
107
107
|
export { social, github, google, discord, oauthDriver, oauthState, OAuthDriver, OAuthError, twitter, oauth1Driver, oauth1Signature, OAuth1Driver, } from "./social.js";
|
|
108
108
|
export type { SocialUser, OAuthToken, OAuthConfig, ProviderSpec, RedirectOptions, OAuth1Config, OAuth1Token, OAuth1ProviderSpec, } from "./social.js";
|
package/dist/core/index.js
CHANGED
|
@@ -54,7 +54,7 @@ export { HttpException, BadRequestException, UnauthorizedException, PaymentRequi
|
|
|
54
54
|
export { validate, validateRequest, validated } from "./validation.js";
|
|
55
55
|
export { Session, session, sessionMiddleware } from "./session.js";
|
|
56
56
|
export { Auth, auth, authGuard, bearerAuth, basicAuth, tokenAuth, token, tokenCan, setUserProvider } from "./auth.js";
|
|
57
|
-
export { createToken, verifyToken, revokeToken, revokeTokens, listTokens, tokenAllows, tokenDenies, setTokensTable, } from "./tokens.js";
|
|
57
|
+
export { createToken, verifyToken, revokeToken, revokeTokens, listTokens, tokenAllows, tokenDenies, setTokensTable, tokensMigration, } from "./tokens.js";
|
|
58
58
|
export { social, github, google, discord, oauthDriver, oauthState, OAuthDriver, OAuthError, twitter, oauth1Driver, oauth1Signature, OAuth1Driver, } from "./social.js";
|
|
59
59
|
export { define, policy, gateBefore, gateAfter, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
|
|
60
60
|
export { Transformer } from "./transformer.js";
|
package/dist/core/tokens.d.ts
CHANGED
|
@@ -72,3 +72,5 @@ export declare function revokeToken(selector: string, connection?: string): Prom
|
|
|
72
72
|
export declare function revokeTokens(tokenableId: string | number, connection?: string): Promise<void>;
|
|
73
73
|
/** List an entity's tokens (metadata only — the secret is never stored). */
|
|
74
74
|
export declare function listTokens(tokenableId: string | number, connection?: string): Promise<AccessToken[]>;
|
|
75
|
+
/** Schema for personal access tokens (SQLite-friendly; works on Postgres with minor tweaks). */
|
|
76
|
+
export declare function tokensMigration(tableName?: string): import("./migrations.js").Migration;
|
package/dist/core/tokens.js
CHANGED
|
@@ -153,3 +153,27 @@ function parseAbilities(value) {
|
|
|
153
153
|
return ["*"];
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
|
+
/** Schema for personal access tokens (SQLite-friendly; works on Postgres with minor tweaks). */
|
|
157
|
+
export function tokensMigration(tableName = "personal_access_tokens") {
|
|
158
|
+
return {
|
|
159
|
+
name: `tokens_00_${tableName}`,
|
|
160
|
+
async up(schema) {
|
|
161
|
+
await schema.raw(`
|
|
162
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
163
|
+
selector TEXT NOT NULL UNIQUE,
|
|
164
|
+
hash TEXT NOT NULL,
|
|
165
|
+
tokenable_id TEXT NOT NULL,
|
|
166
|
+
name TEXT,
|
|
167
|
+
abilities TEXT,
|
|
168
|
+
last_used_at INTEGER,
|
|
169
|
+
expires_at INTEGER,
|
|
170
|
+
created_at INTEGER
|
|
171
|
+
)
|
|
172
|
+
`);
|
|
173
|
+
await schema.raw(`CREATE INDEX IF NOT EXISTS idx_${tableName}_tokenable ON ${tableName} (tokenable_id)`);
|
|
174
|
+
},
|
|
175
|
+
async down(schema) {
|
|
176
|
+
await schema.dropTable(tableName).catch(() => { });
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Gates — private-alpha / waitlist signup gating.
|
|
3
|
+
*
|
|
4
|
+
* import { GatesServiceProvider, canRegister, redeemInvite } from "@shaferllc/keel/gates";
|
|
5
|
+
*
|
|
6
|
+
* Not the same as team invitations in `@shaferllc/keel/teams`.
|
|
7
|
+
*/
|
|
8
|
+
export { GatesServiceProvider } from "./provider.js";
|
|
9
|
+
export { InviteCode, EmailAllowlist, canRegister, redeemInvite, gatesMigration, } from "./models.js";
|
|
10
|
+
export type { GateCheck } from "./models.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Gates — private-alpha / waitlist signup gating.
|
|
3
|
+
*
|
|
4
|
+
* import { GatesServiceProvider, canRegister, redeemInvite } from "@shaferllc/keel/gates";
|
|
5
|
+
*
|
|
6
|
+
* Not the same as team invitations in `@shaferllc/keel/teams`.
|
|
7
|
+
*/
|
|
8
|
+
export { GatesServiceProvider } from "./provider.js";
|
|
9
|
+
export { InviteCode, EmailAllowlist, canRegister, redeemInvite, gatesMigration, } from "./models.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signup gates — invite codes + email allowlist for private alpha / waitlist.
|
|
3
|
+
* Distinct from team invitations (`@shaferllc/keel/teams`).
|
|
4
|
+
*/
|
|
5
|
+
import { Model } from "../core/model.js";
|
|
6
|
+
import type { Migration } from "../core/migrations.js";
|
|
7
|
+
export declare class InviteCode extends Model {
|
|
8
|
+
static table: string;
|
|
9
|
+
static fillable: string[];
|
|
10
|
+
static timestamps: boolean;
|
|
11
|
+
id: number;
|
|
12
|
+
code: string;
|
|
13
|
+
max_uses: number;
|
|
14
|
+
uses: number;
|
|
15
|
+
created_by: number | null;
|
|
16
|
+
expires_at: string | null;
|
|
17
|
+
}
|
|
18
|
+
export declare class EmailAllowlist extends Model {
|
|
19
|
+
static table: string;
|
|
20
|
+
static fillable: string[];
|
|
21
|
+
static timestamps: boolean;
|
|
22
|
+
id: number;
|
|
23
|
+
email: string;
|
|
24
|
+
created_by: number | null;
|
|
25
|
+
}
|
|
26
|
+
export type GateCheck = {
|
|
27
|
+
ok: true;
|
|
28
|
+
via: "allowlist" | "code";
|
|
29
|
+
invite?: InviteCode;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
/** Private alpha: allowlisted email OR a valid invite code. */
|
|
35
|
+
export declare function canRegister(email: string, inviteCode?: string): Promise<GateCheck>;
|
|
36
|
+
export declare function redeemInvite(invite: InviteCode): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Schema for invite codes + allowlist.
|
|
39
|
+
* Uses IF NOT EXISTS so apps that already created these tables (e.g. early
|
|
40
|
+
* keel-cloud) can still register the provider safely.
|
|
41
|
+
*/
|
|
42
|
+
export declare function gatesMigration(): Migration;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signup gates — invite codes + email allowlist for private alpha / waitlist.
|
|
3
|
+
* Distinct from team invitations (`@shaferllc/keel/teams`).
|
|
4
|
+
*/
|
|
5
|
+
import { Model } from "../core/model.js";
|
|
6
|
+
export class InviteCode extends Model {
|
|
7
|
+
static table = "invite_codes";
|
|
8
|
+
static fillable = ["code", "max_uses", "uses", "created_by", "expires_at"];
|
|
9
|
+
static timestamps = true;
|
|
10
|
+
}
|
|
11
|
+
export class EmailAllowlist extends Model {
|
|
12
|
+
static table = "email_allowlist";
|
|
13
|
+
static fillable = ["email", "created_by"];
|
|
14
|
+
static timestamps = true;
|
|
15
|
+
}
|
|
16
|
+
/** Private alpha: allowlisted email OR a valid invite code. */
|
|
17
|
+
export async function canRegister(email, inviteCode) {
|
|
18
|
+
const normalized = email.toLowerCase().trim();
|
|
19
|
+
const allowed = await EmailAllowlist.newQuery().where("email", normalized).first();
|
|
20
|
+
if (allowed)
|
|
21
|
+
return { ok: true, via: "allowlist" };
|
|
22
|
+
const code = String(inviteCode ?? "").trim();
|
|
23
|
+
if (!code) {
|
|
24
|
+
return { ok: false, reason: "Registration requires an invite code or allowlisted email." };
|
|
25
|
+
}
|
|
26
|
+
const invite = await InviteCode.newQuery().where("code", code).first();
|
|
27
|
+
if (!invite)
|
|
28
|
+
return { ok: false, reason: "That invite code is invalid." };
|
|
29
|
+
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
|
|
30
|
+
return { ok: false, reason: "That invite code has expired." };
|
|
31
|
+
}
|
|
32
|
+
if (invite.uses >= invite.max_uses) {
|
|
33
|
+
return { ok: false, reason: "That invite code has already been used up." };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, via: "code", invite };
|
|
36
|
+
}
|
|
37
|
+
export async function redeemInvite(invite) {
|
|
38
|
+
await invite.update({ uses: invite.uses + 1 });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Schema for invite codes + allowlist.
|
|
42
|
+
* Uses IF NOT EXISTS so apps that already created these tables (e.g. early
|
|
43
|
+
* keel-cloud) can still register the provider safely.
|
|
44
|
+
*/
|
|
45
|
+
export function gatesMigration() {
|
|
46
|
+
return {
|
|
47
|
+
name: "gates_00_invite_allowlist",
|
|
48
|
+
async up(schema) {
|
|
49
|
+
await schema.raw(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS invite_codes (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
code VARCHAR(255) NOT NULL UNIQUE,
|
|
53
|
+
max_uses INTEGER DEFAULT 1,
|
|
54
|
+
uses INTEGER DEFAULT 0,
|
|
55
|
+
created_by INTEGER,
|
|
56
|
+
expires_at TIMESTAMP,
|
|
57
|
+
created_at TIMESTAMP,
|
|
58
|
+
updated_at TIMESTAMP
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
await schema.raw(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS email_allowlist (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
email VARCHAR(255) NOT NULL UNIQUE,
|
|
65
|
+
created_by INTEGER,
|
|
66
|
+
created_at TIMESTAMP,
|
|
67
|
+
updated_at TIMESTAMP
|
|
68
|
+
)
|
|
69
|
+
`);
|
|
70
|
+
},
|
|
71
|
+
async down(schema) {
|
|
72
|
+
await schema.dropTable("email_allowlist").catch(() => { });
|
|
73
|
+
await schema.dropTable("invite_codes").catch(() => { });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PackageProvider } from "../core/package.js";
|
|
2
|
+
/**
|
|
3
|
+
* Signup gates — invite codes + email allowlist.
|
|
4
|
+
*
|
|
5
|
+
* app.register(GatesServiceProvider)
|
|
6
|
+
*/
|
|
7
|
+
export declare class GatesServiceProvider extends PackageProvider {
|
|
8
|
+
readonly name = "gates";
|
|
9
|
+
register(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PackageProvider } from "../core/package.js";
|
|
2
|
+
import { gatesMigration } from "./models.js";
|
|
3
|
+
/**
|
|
4
|
+
* Signup gates — invite codes + email allowlist.
|
|
5
|
+
*
|
|
6
|
+
* app.register(GatesServiceProvider)
|
|
7
|
+
*/
|
|
8
|
+
export class GatesServiceProvider extends PackageProvider {
|
|
9
|
+
name = "gates";
|
|
10
|
+
register() {
|
|
11
|
+
this.migrations([gatesMigration()]);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare REST client — D1 + Workers Custom Domains.
|
|
3
|
+
* Credentials are constructor args (no app config coupling).
|
|
4
|
+
*/
|
|
5
|
+
export type CloudflareCredentials = {
|
|
6
|
+
accountId: string;
|
|
7
|
+
apiToken: string;
|
|
8
|
+
/** Optional pin for a primary zone name (e.g. sites.example.com). */
|
|
9
|
+
pinnedZoneId?: string;
|
|
10
|
+
pinnedZoneName?: string;
|
|
11
|
+
};
|
|
12
|
+
export type WorkerDomain = {
|
|
13
|
+
id: string;
|
|
14
|
+
hostname: string;
|
|
15
|
+
service: string;
|
|
16
|
+
zone_id?: string;
|
|
17
|
+
zone_name?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function cloudflareConfigured(creds: Partial<CloudflareCredentials>): boolean;
|
|
20
|
+
export declare class CloudflareClient {
|
|
21
|
+
private creds;
|
|
22
|
+
private baseUrl;
|
|
23
|
+
constructor(creds: CloudflareCredentials);
|
|
24
|
+
private accountId;
|
|
25
|
+
private apiToken;
|
|
26
|
+
private request;
|
|
27
|
+
createD1Database(name: string): Promise<{
|
|
28
|
+
uuid: string;
|
|
29
|
+
name: string;
|
|
30
|
+
}>;
|
|
31
|
+
resolveZoneId(zoneName: string): Promise<{
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
} | null>;
|
|
35
|
+
attachWorkerDomain(input: {
|
|
36
|
+
hostname: string;
|
|
37
|
+
service: string;
|
|
38
|
+
zoneId: string;
|
|
39
|
+
zoneName: string;
|
|
40
|
+
}): Promise<WorkerDomain>;
|
|
41
|
+
detachWorkerDomain(domainId: string): Promise<void>;
|
|
42
|
+
findWorkerDomain(hostname: string): Promise<WorkerDomain | null>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare REST client — D1 + Workers Custom Domains.
|
|
3
|
+
* Credentials are constructor args (no app config coupling).
|
|
4
|
+
*/
|
|
5
|
+
export function cloudflareConfigured(creds) {
|
|
6
|
+
return Boolean(creds.accountId && creds.apiToken);
|
|
7
|
+
}
|
|
8
|
+
export class CloudflareClient {
|
|
9
|
+
creds;
|
|
10
|
+
baseUrl = "https://api.cloudflare.com/client/v4";
|
|
11
|
+
constructor(creds) {
|
|
12
|
+
this.creds = creds;
|
|
13
|
+
}
|
|
14
|
+
accountId() {
|
|
15
|
+
return this.creds.accountId;
|
|
16
|
+
}
|
|
17
|
+
apiToken() {
|
|
18
|
+
return this.creds.apiToken;
|
|
19
|
+
}
|
|
20
|
+
async request(method, path, body) {
|
|
21
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
authorization: `Bearer ${this.apiToken()}`,
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
const payload = (await response.json());
|
|
30
|
+
if (!response.ok || !payload.success) {
|
|
31
|
+
const message = payload.errors?.map((e) => `${e.code}: ${e.message}`).join("; ") ??
|
|
32
|
+
`Cloudflare request failed (${response.status})`;
|
|
33
|
+
throw new Error(message);
|
|
34
|
+
}
|
|
35
|
+
return payload.result;
|
|
36
|
+
}
|
|
37
|
+
async createD1Database(name) {
|
|
38
|
+
return this.request("POST", `/accounts/${this.accountId()}/d1/database`, { name });
|
|
39
|
+
}
|
|
40
|
+
async resolveZoneId(zoneName) {
|
|
41
|
+
const pinned = this.creds.pinnedZoneId;
|
|
42
|
+
const pinnedName = this.creds.pinnedZoneName;
|
|
43
|
+
if (pinned && pinnedName && zoneName === pinnedName) {
|
|
44
|
+
return { id: pinned, name: zoneName };
|
|
45
|
+
}
|
|
46
|
+
const result = await this.request("GET", `/zones?name=${encodeURIComponent(zoneName)}&status=active&per_page=1`);
|
|
47
|
+
const zone = result?.[0];
|
|
48
|
+
return zone ? { id: zone.id, name: zone.name } : null;
|
|
49
|
+
}
|
|
50
|
+
async attachWorkerDomain(input) {
|
|
51
|
+
return this.request("PUT", `/accounts/${this.accountId()}/workers/domains`, {
|
|
52
|
+
hostname: input.hostname,
|
|
53
|
+
service: input.service,
|
|
54
|
+
environment: "production",
|
|
55
|
+
zone_id: input.zoneId,
|
|
56
|
+
zone_name: input.zoneName,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async detachWorkerDomain(domainId) {
|
|
60
|
+
await this.request("DELETE", `/accounts/${this.accountId()}/workers/domains/${domainId}`);
|
|
61
|
+
}
|
|
62
|
+
async findWorkerDomain(hostname) {
|
|
63
|
+
const result = await this.request("GET", `/accounts/${this.accountId()}/workers/domains?hostname=${encodeURIComponent(hostname)}`);
|
|
64
|
+
return result?.[0] ?? null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dump a SQLite-compatible connection to a portable .sql script.
|
|
3
|
+
*/
|
|
4
|
+
function sqlLiteral(value) {
|
|
5
|
+
if (value === null || value === undefined)
|
|
6
|
+
return "NULL";
|
|
7
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
8
|
+
return String(value);
|
|
9
|
+
if (typeof value === "bigint")
|
|
10
|
+
return String(value);
|
|
11
|
+
if (typeof value === "boolean")
|
|
12
|
+
return value ? "1" : "0";
|
|
13
|
+
if (value instanceof Uint8Array) {
|
|
14
|
+
const hex = Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15
|
+
return `X'${hex}'`;
|
|
16
|
+
}
|
|
17
|
+
const str = String(value);
|
|
18
|
+
return `'${str.replace(/'/g, "''")}'`;
|
|
19
|
+
}
|
|
20
|
+
function quoteIdent(name) {
|
|
21
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
22
|
+
}
|
|
23
|
+
export async function dumpConnection(conn, header, opts = {}) {
|
|
24
|
+
const by = opts.generatedBy ?? "Keel";
|
|
25
|
+
const lines = [
|
|
26
|
+
`-- ${header}`,
|
|
27
|
+
`-- Generated by ${by} at ${new Date().toISOString()}`,
|
|
28
|
+
"PRAGMA foreign_keys=OFF;",
|
|
29
|
+
"BEGIN TRANSACTION;",
|
|
30
|
+
"",
|
|
31
|
+
];
|
|
32
|
+
const objects = await conn.select("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name", []);
|
|
33
|
+
const tables = objects.filter((o) => o.type === "table" && typeof o.name === "string");
|
|
34
|
+
for (const table of tables) {
|
|
35
|
+
const name = String(table.name);
|
|
36
|
+
if (table.sql) {
|
|
37
|
+
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(name)};`);
|
|
38
|
+
lines.push(`${String(table.sql)};`);
|
|
39
|
+
lines.push("");
|
|
40
|
+
}
|
|
41
|
+
const rows = await conn.select(`SELECT * FROM ${quoteIdent(name)}`, []);
|
|
42
|
+
if (rows.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
const columns = Object.keys(rows[0]);
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
const values = columns.map((col) => sqlLiteral(row[col]));
|
|
47
|
+
lines.push(`INSERT INTO ${quoteIdent(name)} (${columns.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")});`);
|
|
48
|
+
}
|
|
49
|
+
lines.push("");
|
|
50
|
+
}
|
|
51
|
+
const indexes = objects.filter((o) => o.type === "index" && o.sql);
|
|
52
|
+
for (const index of indexes) {
|
|
53
|
+
lines.push(`${String(index.sql)};`);
|
|
54
|
+
}
|
|
55
|
+
lines.push("COMMIT;");
|
|
56
|
+
lines.push("");
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostname helpers for custom domains / Workers Custom Domains.
|
|
3
|
+
*/
|
|
4
|
+
export declare function normalizeHostname(raw: string): string;
|
|
5
|
+
export declare function isValidHostname(hostname: string): boolean;
|
|
6
|
+
/** Zone name candidates from most specific to apex (e.g. app.ex.com → app.ex.com, ex.com). */
|
|
7
|
+
export declare function zoneCandidates(hostname: string): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostname helpers for custom domains / Workers Custom Domains.
|
|
3
|
+
*/
|
|
4
|
+
const HOSTNAME_RE = /^(?=.{1,253}$)(?!-)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.(?!-)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i;
|
|
5
|
+
export function normalizeHostname(raw) {
|
|
6
|
+
return raw.trim().toLowerCase().replace(/\.$/, "").replace(/^https?:\/\//, "").split("/")[0];
|
|
7
|
+
}
|
|
8
|
+
export function isValidHostname(hostname) {
|
|
9
|
+
if (!HOSTNAME_RE.test(hostname))
|
|
10
|
+
return false;
|
|
11
|
+
if (hostname.includes(".."))
|
|
12
|
+
return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
/** Zone name candidates from most specific to apex (e.g. app.ex.com → app.ex.com, ex.com). */
|
|
16
|
+
export function zoneCandidates(hostname) {
|
|
17
|
+
const parts = hostname.split(".").filter(Boolean);
|
|
18
|
+
if (parts.length < 2)
|
|
19
|
+
return [];
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i <= parts.length - 2; i++) {
|
|
22
|
+
out.push(parts.slice(i).join("."));
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Hosting — Cloudflare client, hostname helpers, SQL dump, secrets vault.
|
|
3
|
+
*
|
|
4
|
+
* import { CloudflareClient, dumpConnection, normalizeHostname } from "@shaferllc/keel/hosting";
|
|
5
|
+
*
|
|
6
|
+
* This is infrastructure for hosted Workers/D1 apps — not a full control plane.
|
|
7
|
+
* Product orchestration (sites, plans, deploy loops) stays in the app.
|
|
8
|
+
*/
|
|
9
|
+
export { CloudflareClient, cloudflareConfigured, } from "./cloudflare.js";
|
|
10
|
+
export type { CloudflareCredentials, WorkerDomain } from "./cloudflare.js";
|
|
11
|
+
export { normalizeHostname, isValidHostname, zoneCandidates } from "./hostname.js";
|
|
12
|
+
export { dumpConnection } from "./dump.js";
|
|
13
|
+
export { normalizeSecretKey, encryptSecretValue, decryptSecretValue, resolveSecretRows, } from "./secrets.js";
|
|
14
|
+
export type { SecretRow } from "./secrets.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Hosting — Cloudflare client, hostname helpers, SQL dump, secrets vault.
|
|
3
|
+
*
|
|
4
|
+
* import { CloudflareClient, dumpConnection, normalizeHostname } from "@shaferllc/keel/hosting";
|
|
5
|
+
*
|
|
6
|
+
* This is infrastructure for hosted Workers/D1 apps — not a full control plane.
|
|
7
|
+
* Product orchestration (sites, plans, deploy loops) stays in the app.
|
|
8
|
+
*/
|
|
9
|
+
export { CloudflareClient, cloudflareConfigured, } from "./cloudflare.js";
|
|
10
|
+
export { normalizeHostname, isValidHostname, zoneCandidates } from "./hostname.js";
|
|
11
|
+
export { dumpConnection } from "./dump.js";
|
|
12
|
+
export { normalizeSecretKey, encryptSecretValue, decryptSecretValue, resolveSecretRows, } from "./secrets.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted key/value secrets vault — purpose-scoped encryption via Keel core.
|
|
3
|
+
*
|
|
4
|
+
* Apps supply a table of rows with `owner_id`, `key`, `value_encrypted`.
|
|
5
|
+
* This helper doesn't own the model; it encrypts/decrypts values.
|
|
6
|
+
*/
|
|
7
|
+
export type SecretRow = {
|
|
8
|
+
key: string;
|
|
9
|
+
value_encrypted: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function normalizeSecretKey(key: string): string;
|
|
12
|
+
export declare function encryptSecretValue(value: string, purpose?: string): Promise<string>;
|
|
13
|
+
export declare function decryptSecretValue(payload: string, purpose?: string): Promise<string | null>;
|
|
14
|
+
/** Decrypt a list of encrypted rows into a plain key→value map. */
|
|
15
|
+
export declare function resolveSecretRows(rows: SecretRow[], purpose?: string): Promise<Record<string, string>>;
|