@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.
Files changed (44) hide show
  1. package/README.md +7 -24
  2. package/dist/billing/billable.d.ts +1 -0
  3. package/dist/billing/billable.js +8 -0
  4. package/dist/billing/drivers/fake.d.ts +4 -0
  5. package/dist/billing/drivers/fake.js +5 -0
  6. package/dist/billing/drivers/stripe.d.ts +1 -0
  7. package/dist/billing/drivers/stripe.js +7 -0
  8. package/dist/billing/gateway.d.ts +7 -0
  9. package/dist/billing/index.d.ts +1 -1
  10. package/dist/core/index.d.ts +1 -1
  11. package/dist/core/index.js +1 -1
  12. package/dist/core/tokens.d.ts +2 -0
  13. package/dist/core/tokens.js +24 -0
  14. package/dist/gates/index.d.ts +10 -0
  15. package/dist/gates/index.js +9 -0
  16. package/dist/gates/models.d.ts +42 -0
  17. package/dist/gates/models.js +76 -0
  18. package/dist/gates/provider.d.ts +10 -0
  19. package/dist/gates/provider.js +13 -0
  20. package/dist/hosting/cloudflare.d.ts +43 -0
  21. package/dist/hosting/cloudflare.js +66 -0
  22. package/dist/hosting/dump.d.ts +7 -0
  23. package/dist/hosting/dump.js +58 -0
  24. package/dist/hosting/hostname.d.ts +7 -0
  25. package/dist/hosting/hostname.js +25 -0
  26. package/dist/hosting/index.d.ts +14 -0
  27. package/dist/hosting/index.js +12 -0
  28. package/dist/hosting/secrets.d.ts +15 -0
  29. package/dist/hosting/secrets.js +27 -0
  30. package/dist/mcp/cloud.d.ts +9 -0
  31. package/dist/mcp/cloud.js +179 -0
  32. package/dist/mcp/server.d.ts +2 -0
  33. package/dist/mcp/server.js +20 -1
  34. package/docs/ai-manifest.json +20 -1
  35. package/docs/ai.md +36 -0
  36. package/docs/billing.md +10 -1
  37. package/docs/changelog.md +30 -0
  38. package/docs/examples/gates.ts +30 -0
  39. package/docs/examples/hosting.ts +37 -0
  40. package/docs/gates.md +75 -0
  41. package/docs/hosting.md +97 -0
  42. package/llms-full.txt +232 -1
  43. package/llms.txt +4 -0
  44. package/package.json +9 -1
@@ -0,0 +1,27 @@
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
+ import { encryption } from "../core/crypto.js";
8
+ export function normalizeSecretKey(key) {
9
+ return key.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
10
+ }
11
+ export async function encryptSecretValue(value, purpose = "app-secret") {
12
+ return encryption.encrypt(value, { purpose });
13
+ }
14
+ export async function decryptSecretValue(payload, purpose = "app-secret") {
15
+ const value = await encryption.decrypt(payload, { purpose });
16
+ return typeof value === "string" ? value : null;
17
+ }
18
+ /** Decrypt a list of encrypted rows into a plain key→value map. */
19
+ export async function resolveSecretRows(rows, purpose = "app-secret") {
20
+ const out = {};
21
+ for (const row of rows) {
22
+ const value = await decryptSecretValue(row.value_encrypted, purpose);
23
+ if (value !== null)
24
+ out[row.key] = value;
25
+ }
26
+ return out;
27
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Keel Cloud MCP tools — registered only when KEEL_CLOUD_TOKEN is set.
3
+ *
4
+ * Talks to a Keel Cloud control plane over HTTP (Bearer token). Agents use these
5
+ * to create sites, preview/publish, and manage secrets without leaving the IDE.
6
+ */
7
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ /** Register Cloud tools on an existing MCP server when a Cloud token is present. */
9
+ export declare function registerCloudTools(server: McpServer): boolean;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Keel Cloud MCP tools — registered only when KEEL_CLOUD_TOKEN is set.
3
+ *
4
+ * Talks to a Keel Cloud control plane over HTTP (Bearer token). Agents use these
5
+ * to create sites, preview/publish, and manage secrets without leaving the IDE.
6
+ */
7
+ import { z } from "zod";
8
+ const PRESETS = ["minimal", "api", "app", "saas"];
9
+ function cloudConfig() {
10
+ const token = String(process.env.KEEL_CLOUD_TOKEN ?? "").trim();
11
+ if (!token)
12
+ return null;
13
+ const url = String(process.env.KEEL_CLOUD_URL ?? "http://localhost:3000").replace(/\/$/, "");
14
+ return { url, token };
15
+ }
16
+ async function cloudFetch(path, init = {}) {
17
+ const cfg = cloudConfig();
18
+ if (!cfg)
19
+ throw new Error("KEEL_CLOUD_TOKEN is not set");
20
+ const response = await fetch(`${cfg.url}${path}`, {
21
+ ...init,
22
+ headers: {
23
+ authorization: `Bearer ${cfg.token}`,
24
+ accept: "application/json",
25
+ ...(init.body ? { "content-type": "application/json" } : {}),
26
+ ...(init.headers ?? {}),
27
+ },
28
+ });
29
+ const text = await response.text();
30
+ let body = text;
31
+ try {
32
+ body = text ? JSON.parse(text) : null;
33
+ }
34
+ catch {
35
+ /* keep text */
36
+ }
37
+ return { ok: response.ok, status: response.status, body };
38
+ }
39
+ function jsonText(data, isError = false) {
40
+ return {
41
+ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }],
42
+ ...(isError ? { isError: true } : {}),
43
+ };
44
+ }
45
+ /** Register Cloud tools on an existing MCP server when a Cloud token is present. */
46
+ export function registerCloudTools(server) {
47
+ if (!cloudConfig())
48
+ return false;
49
+ server.registerTool("keel_cloud_me", {
50
+ title: "Keel Cloud — who am I",
51
+ description: "Return the Keel Cloud user authenticated by KEEL_CLOUD_TOKEN.",
52
+ inputSchema: {},
53
+ }, async () => {
54
+ const res = await cloudFetch("/api/v1/me");
55
+ return jsonText(res.body, !res.ok);
56
+ });
57
+ server.registerTool("keel_cloud_list_sites", {
58
+ title: "Keel Cloud — list sites",
59
+ description: "List sites on the current Keel Cloud team. Includes storage_path for local editing and hostnames.",
60
+ inputSchema: {},
61
+ }, async () => {
62
+ const res = await cloudFetch("/api/v1/sites");
63
+ return jsonText(res.body, !res.ok);
64
+ });
65
+ server.registerTool("keel_cloud_get_site", {
66
+ title: "Keel Cloud — get site",
67
+ description: "Fetch one Keel Cloud site by id (paths, hostnames, status).",
68
+ inputSchema: {
69
+ site_id: z.number().int().describe("Site id from keel_cloud_list_sites"),
70
+ },
71
+ }, async ({ site_id }) => {
72
+ const res = await cloudFetch(`/api/v1/sites/${site_id}`);
73
+ return jsonText(res.body, !res.ok);
74
+ });
75
+ server.registerTool("keel_cloud_create_site", {
76
+ title: "Keel Cloud — create site",
77
+ description: "Create a new Keel Cloud site from a preset (minimal|api|app|saas). Scaffolds source under storage/sites/{slug} and returns storage_path for editing.",
78
+ inputSchema: {
79
+ name: z.string().min(1).describe("Human-readable site name"),
80
+ preset: z.enum(PRESETS).optional().describe("Keel preset (default minimal)"),
81
+ },
82
+ }, async ({ name, preset }) => {
83
+ const res = await cloudFetch("/api/v1/sites", {
84
+ method: "POST",
85
+ body: JSON.stringify({ name, preset: preset ?? "minimal" }),
86
+ });
87
+ return jsonText(res.body, !res.ok);
88
+ });
89
+ server.registerTool("keel_cloud_preview", {
90
+ title: "Keel Cloud — deploy preview",
91
+ description: "Deploy the site's preview Worker (and attach preview hostname when Cloudflare is configured). Safe to call freely while iterating.",
92
+ inputSchema: {
93
+ site_id: z.number().int().describe("Site id"),
94
+ },
95
+ }, async ({ site_id }) => {
96
+ const res = await cloudFetch(`/api/v1/sites/${site_id}/preview`, { method: "POST", body: "{}" });
97
+ return jsonText(res.body, !res.ok);
98
+ });
99
+ server.registerTool("keel_cloud_publish", {
100
+ title: "Keel Cloud — publish production",
101
+ description: "Publish the site to its production Worker/hostname. Requires confirm=true (explicit user approval).",
102
+ inputSchema: {
103
+ site_id: z.number().int().describe("Site id"),
104
+ confirm: z
105
+ .boolean()
106
+ .describe("Must be true to publish — ask the user before setting this"),
107
+ },
108
+ }, async ({ site_id, confirm }) => {
109
+ if (!confirm) {
110
+ return jsonText({
111
+ error: "Publish requires confirm=true. Ask the user to confirm before calling again.",
112
+ }, true);
113
+ }
114
+ const res = await cloudFetch(`/api/v1/sites/${site_id}/publish`, {
115
+ method: "POST",
116
+ body: JSON.stringify({ confirm: true }),
117
+ });
118
+ return jsonText(res.body, !res.ok);
119
+ });
120
+ server.registerTool("keel_cloud_deploys", {
121
+ title: "Keel Cloud — deploy history",
122
+ description: "List recent preview/production deploys and logs for a site.",
123
+ inputSchema: {
124
+ site_id: z.number().int().describe("Site id"),
125
+ },
126
+ }, async ({ site_id }) => {
127
+ const res = await cloudFetch(`/api/v1/sites/${site_id}/deploys`);
128
+ return jsonText(res.body, !res.ok);
129
+ });
130
+ server.registerTool("keel_cloud_set_secret", {
131
+ title: "Keel Cloud — set secret",
132
+ description: "Store a secret in the site vault (never in git). Injected into the Worker on the next preview/publish.",
133
+ inputSchema: {
134
+ site_id: z.number().int().describe("Site id"),
135
+ key: z.string().min(1).describe("Env-style key, e.g. STRIPE_SECRET_KEY"),
136
+ value: z.string().describe("Secret value (will not be echoed by the dashboard)"),
137
+ },
138
+ }, async ({ site_id, key, value }) => {
139
+ const res = await cloudFetch(`/api/v1/sites/${site_id}/secrets`, {
140
+ method: "PUT",
141
+ body: JSON.stringify({ key, value }),
142
+ });
143
+ return jsonText(res.body, !res.ok);
144
+ });
145
+ server.registerTool("keel_cloud_export", {
146
+ title: "Keel Cloud — export manifest",
147
+ description: "Return the site's export manifest (paths, hostnames, Keel version, SQL dump URLs). Use storage_path / git_url for code; use keel_cloud_export_sql for data.",
148
+ inputSchema: {
149
+ site_id: z.number().int().describe("Site id"),
150
+ },
151
+ }, async ({ site_id }) => {
152
+ const res = await cloudFetch(`/api/v1/sites/${site_id}/export`);
153
+ return jsonText(res.body, !res.ok);
154
+ });
155
+ server.registerTool("keel_cloud_export_sql", {
156
+ title: "Keel Cloud — export SQL dump",
157
+ description: "Download a portable .sql dump for local sqlite, preview D1, or production D1. Prefer production after publish for a full escape hatch.",
158
+ inputSchema: {
159
+ site_id: z.number().int().describe("Site id"),
160
+ env: z
161
+ .enum(["local", "preview", "production"])
162
+ .optional()
163
+ .describe("Which database to dump (default local)"),
164
+ },
165
+ }, async ({ site_id, env }) => {
166
+ const environment = env ?? "local";
167
+ const res = await cloudFetch(`/api/v1/sites/${site_id}/export/sql?env=${environment}`);
168
+ if (!res.ok)
169
+ return jsonText(res.body, true);
170
+ const sql = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
171
+ return jsonText({
172
+ site_id,
173
+ env: environment,
174
+ sql,
175
+ hint: "Write this to a .sql file and restore with sqlite3 / D1 import on a real Keel app.",
176
+ });
177
+ });
178
+ return true;
179
+ }
@@ -11,6 +11,8 @@
11
11
  * keel_search_api search the public export surface
12
12
  * keel_list_generators the `keel make:*` generators
13
13
  * keel_scaffold generate a controller/provider/job/… stub (no write)
14
+ * When KEEL_CLOUD_TOKEN is set, also:
15
+ * keel_cloud_* create/list/preview/publish sites on Keel Cloud
14
16
  * Resources: keel://overview, keel://llms-full, and keel://docs/<slug> per guide.
15
17
  */
16
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -11,6 +11,8 @@
11
11
  * keel_search_api search the public export surface
12
12
  * keel_list_generators the `keel make:*` generators
13
13
  * keel_scaffold generate a controller/provider/job/… stub (no write)
14
+ * When KEEL_CLOUD_TOKEN is set, also:
15
+ * keel_cloud_* create/list/preview/publish sites on Keel Cloud
14
16
  * Resources: keel://overview, keel://llms-full, and keel://docs/<slug> per guide.
15
17
  */
16
18
  import { readFile, readdir, stat } from "node:fs/promises";
@@ -20,6 +22,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
23
  import { z } from "zod";
22
24
  import { controllerStub, resourceControllerStub, providerStub, middlewareStub, factoryStub, seederStub, jobStub, notificationStub, transformerStub, } from "../core/cli/stubs.js";
25
+ import { registerCloudTools } from "./cloud.js";
23
26
  async function exists(p) {
24
27
  try {
25
28
  await stat(p);
@@ -183,6 +186,18 @@ export async function createServer() {
183
186
  "",
184
187
  "## Generators — use keel_scaffold or `keel make:*`",
185
188
  ...manifest.generators.map((g) => `- ${g.command} → ${g.produces}`),
189
+ ...(process.env.KEEL_CLOUD_TOKEN?.trim()
190
+ ? [
191
+ "",
192
+ "## Keel Cloud (token detected)",
193
+ "Cloud tools are enabled. Typical loop:",
194
+ "1. keel_cloud_create_site { name, preset }",
195
+ "2. Edit files at the returned storage_path (real Keel app)",
196
+ "3. keel_cloud_preview { site_id }",
197
+ "4. keel_cloud_publish { site_id, confirm: true } after user approval",
198
+ "Also: keel_cloud_list_sites, keel_cloud_get_site, keel_cloud_set_secret, keel_cloud_deploys, keel_cloud_export.",
199
+ ]
200
+ : []),
186
201
  ].join("\n");
187
202
  // ---- tools ----
188
203
  server.registerTool("keel_overview", {
@@ -343,6 +358,7 @@ export async function createServer() {
343
358
  for (const doc of manifest.docs) {
344
359
  server.registerResource(`doc:${doc.slug}`, `keel://docs/${doc.slug}`, { title: doc.title, description: doc.summary || doc.title, mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, text: await readFile(join(root, doc.path), "utf8") }] }));
345
360
  }
361
+ registerCloudTools(server);
346
362
  return server;
347
363
  }
348
364
  /** Boot the server on stdio. Called by the `keel-mcp` bin and `keel mcp`. */
@@ -351,5 +367,8 @@ export async function runMcpServer() {
351
367
  const transport = new StdioServerTransport();
352
368
  await server.connect(transport);
353
369
  // stdout is the protocol channel — log to stderr only.
354
- console.error("⚓ Keel MCP server running on stdio");
370
+ const cloud = Boolean(process.env.KEEL_CLOUD_TOKEN?.trim());
371
+ console.error(cloud
372
+ ? "⚓ Keel MCP server running on stdio (Cloud tools enabled)"
373
+ : "⚓ Keel MCP server running on stdio");
355
374
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.83.2",
3
+ "version": "0.83.4",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -159,6 +159,13 @@
159
159
  "path": "docs/factories.md",
160
160
  "example": "docs/examples/factories.ts"
161
161
  },
162
+ {
163
+ "slug": "gates",
164
+ "title": "Gates",
165
+ "summary": "Keel Gates is a signup gate for private alpha / waitlist apps: an email allowlist, invite codes with use limits and expiry, and a single check that answers \"may this person register?\".",
166
+ "path": "docs/gates.md",
167
+ "example": "docs/examples/gates.ts"
168
+ },
162
169
  {
163
170
  "slug": "getting-started",
164
171
  "title": "Getting Started",
@@ -201,6 +208,13 @@
201
208
  "path": "docs/hooks.md",
202
209
  "example": "docs/examples/hooks.ts"
203
210
  },
211
+ {
212
+ "slug": "hosting",
213
+ "title": "Hosting",
214
+ "summary": "Keel Hosting is a small toolkit for hosted Workers / D1 apps: a Cloudflare REST client, hostname helpers, a SQLite-compatible SQL dump, and purpose-scoped secret encryption.",
215
+ "path": "docs/hosting.md",
216
+ "example": "docs/examples/hosting.ts"
217
+ },
204
218
  {
205
219
  "slug": "i18n",
206
220
  "title": "Internationalization",
@@ -3077,6 +3091,11 @@
3077
3091
  "kind": "value",
3078
3092
  "module": "tokens"
3079
3093
  },
3094
+ {
3095
+ "name": "tokensMigration",
3096
+ "kind": "value",
3097
+ "module": "tokens"
3098
+ },
3080
3099
  {
3081
3100
  "name": "TooManyRequestsException",
3082
3101
  "kind": "value",
package/docs/ai.md CHANGED
@@ -61,6 +61,35 @@ package, so it always matches your installed version.
61
61
  | `keel_list_generators` | The `keel make:*` generators, what they produce, and their flags. |
62
62
  | `keel_scaffold` | Generate a controller/provider/middleware/factory/seeder/job/notification/transformer stub. Returns code + target path — it does **not** write to disk. |
63
63
 
64
+ When `KEEL_CLOUD_TOKEN` (and optional `KEEL_CLOUD_URL`) is set, Cloud tools are
65
+ also registered:
66
+
67
+ | Tool | What it does |
68
+ |------|--------------|
69
+ | `keel_cloud_me` | Authenticated Cloud user |
70
+ | `keel_cloud_list_sites` / `keel_cloud_get_site` | List or fetch a site (includes `storage_path`) |
71
+ | `keel_cloud_create_site` | Create from preset (`minimal` \| `api` \| `app` \| `saas`) |
72
+ | `keel_cloud_preview` | Deploy preview Worker |
73
+ | `keel_cloud_publish` | Publish production — requires `confirm: true` |
74
+ | `keel_cloud_set_secret` | Vault a secret (injected on next deploy) |
75
+ | `keel_cloud_deploys` / `keel_cloud_export` | Deploy history and export manifest |
76
+ | `keel_cloud_export_sql` | Portable `.sql` dump (`local` \| `preview` \| `production`) |
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "keel": {
82
+ "command": "npx",
83
+ "args": ["-y", "keel-mcp"],
84
+ "env": {
85
+ "KEEL_CLOUD_TOKEN": "kc_…",
86
+ "KEEL_CLOUD_URL": "https://app.keeljs.cloud"
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
64
93
  ### Resources
65
94
 
66
95
  - `keel://overview` — the same orientation text as `keel_overview`
@@ -75,6 +104,13 @@ package, so it always matches your installed version.
75
104
  4. `keel_scaffold { kind: "controller", name: "Post", resource: true }` → get the stub.
76
105
  5. Write the file, add the route, run `npm run typecheck`.
77
106
 
107
+ ### A typical Keel Cloud loop
108
+
109
+ 1. `keel_cloud_create_site { name: "Acme", preset: "app" }`
110
+ 2. Edit the returned `storage_path` (real Keel app + git)
111
+ 3. `keel_cloud_preview { site_id }`
112
+ 4. `keel_cloud_publish { site_id, confirm: true }` after the user approves
113
+
78
114
  ## `llms.txt` and `llms-full.txt`
79
115
 
80
116
  At the package root:
package/docs/billing.md CHANGED
@@ -157,6 +157,15 @@ const intent = await user.createSetupIntent(); // return intent.clientSecret to
157
157
  const methods = await user.paymentMethods();
158
158
  ```
159
159
 
160
+ ### Customer portal (Stripe)
161
+
162
+ Send the customer to Stripe's hosted portal to update their card or cancel:
163
+
164
+ ```ts
165
+ const portal = await user.billingPortal("https://app.example.com/billing");
166
+ // redirect to portal.url
167
+ ```
168
+
160
169
  These are Stripe-only capabilities; calling them on the Paddle gateway throws a
161
170
  `BillingError` (Paddle collects cards in its own hosted checkout).
162
171
 
@@ -251,7 +260,7 @@ real gateway from `config/billing.ts` and `.env`.
251
260
  |---------|--------|--------|
252
261
  | Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row |
253
262
  | One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions |
254
- | SetupIntent / `paymentMethods()` | Supported | Throws `BillingError` (hosted checkout) |
263
+ | SetupIntent / `paymentMethods()` / `billingPortal()` | Supported | Throws `BillingError` (hosted checkout) |
255
264
  | Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) |
256
265
  | Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` |
257
266
 
package/docs/changelog.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to Keel are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
5
  adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.83.4] — 2026-07-13
8
+
9
+ ### Added
10
+
11
+ - **MCP Cloud tools** (`keel_cloud_*`) — when `KEEL_CLOUD_TOKEN` is set,
12
+ `keel-mcp` registers create/list/preview/publish/secrets/export tools against
13
+ a Keel Cloud control plane. Documented in [Building with AI](./docs/ai.md).
14
+ - **`tokensMigration()`** — personal access tokens schema helper for apps that
15
+ need the table via `keel migrate`.
16
+ - **Billing customer portal** — `user.billingPortal(returnUrl)` (Stripe + Fake
17
+ gateway); opens the hosted portal to manage card / cancel.
18
+ - **Hosting guide** — [`docs/hosting.md`](./docs/hosting.md) + example harness
19
+ for `@shaferllc/keel/hosting`.
20
+ - **Tests** for gates, hosting, billing portal, and `tokensMigration`.
21
+
22
+ [0.83.4]: https://github.com/shaferllc/keel/releases/tag/v0.83.4
23
+
24
+ ## [0.83.3] — 2026-07-12
25
+
26
+ ### Added
27
+
28
+ - **`@shaferllc/keel/gates`** — signup gating for private alpha / waitlist:
29
+ email allowlist + invite codes (`canRegister` / `redeemInvite`), shipped as a
30
+ package with a migration. Used by Keel Cloud; not the same as team invitations
31
+ or authorization gates.
32
+ - **`@shaferllc/keel/hosting`** — Cloudflare client, hostname helpers, SQL dump,
33
+ and secrets encryption helpers for hosted Workers/D1 apps.
34
+
35
+ [0.83.3]: https://github.com/shaferllc/keel/releases/tag/v0.83.3
36
+
7
37
  ## [0.83.2] — 2026-07-12
8
38
 
9
39
  ### Added
@@ -0,0 +1,30 @@
1
+ // Type-check harness for docs/gates.md. Compile-only — never executed.
2
+ import { Application, json } from "@shaferllc/keel/core";
3
+ import {
4
+ GatesServiceProvider,
5
+ InviteCode,
6
+ EmailAllowlist,
7
+ canRegister,
8
+ redeemInvite,
9
+ } from "@shaferllc/keel/gates";
10
+
11
+ export function install() {
12
+ const app = new Application();
13
+ app.register(GatesServiceProvider);
14
+ return app;
15
+ }
16
+
17
+ export async function register(email: string, inviteCode?: string) {
18
+ const gate = await canRegister(email, inviteCode);
19
+ if (!gate.ok) return json({ error: gate.reason }, 403);
20
+
21
+ if (gate.via === "code" && gate.invite) {
22
+ await redeemInvite(gate.invite);
23
+ }
24
+ return json({ ok: true, via: gate.via });
25
+ }
26
+
27
+ export async function seed() {
28
+ await InviteCode.create({ code: "ALPHA-42", max_uses: 10, uses: 0, expires_at: null });
29
+ await EmailAllowlist.create({ email: "ada@example.com" });
30
+ }
@@ -0,0 +1,37 @@
1
+ // Type-check harness for docs/hosting.md. Compile-only — never executed.
2
+ import type { Connection } from "@shaferllc/keel/core";
3
+ import {
4
+ CloudflareClient,
5
+ cloudflareConfigured,
6
+ normalizeHostname,
7
+ isValidHostname,
8
+ zoneCandidates,
9
+ dumpConnection,
10
+ normalizeSecretKey,
11
+ encryptSecretValue,
12
+ decryptSecretValue,
13
+ resolveSecretRows,
14
+ } from "@shaferllc/keel/hosting";
15
+
16
+ export function client() {
17
+ const creds = { accountId: "acct", apiToken: "tok" };
18
+ if (!cloudflareConfigured(creds)) throw new Error("missing");
19
+ return new CloudflareClient(creds);
20
+ }
21
+
22
+ export function hostnames(raw: string) {
23
+ const host = normalizeHostname(raw);
24
+ return { host, valid: isValidHostname(host), zones: zoneCandidates(host) };
25
+ }
26
+
27
+ export async function dump(conn: Connection) {
28
+ return dumpConnection(conn, "Acme local D1", { generatedBy: "Keel Cloud" });
29
+ }
30
+
31
+ export async function vault(secret: string) {
32
+ const key = normalizeSecretKey("stripe-secret-key");
33
+ const encrypted = await encryptSecretValue(secret, "app-secret");
34
+ const plain = await decryptSecretValue(encrypted, "app-secret");
35
+ const env = await resolveSecretRows([{ key, value_encrypted: encrypted }], "app-secret");
36
+ return { key, plain, env };
37
+ }
package/docs/gates.md ADDED
@@ -0,0 +1,75 @@
1
+ # Gates
2
+
3
+ Keel Gates is a **signup gate** for private alpha / waitlist apps: an email
4
+ allowlist, invite codes with use limits and expiry, and a single check that
5
+ answers "may this person register?". It ships as `@shaferllc/keel/gates`.
6
+
7
+ This is **not** authorization (`can` / policies in [authorization](./authorization.md))
8
+ and **not** team invitations ([teams](./teams.md)). Those answer different
9
+ questions. Gates answers only: *is this email allowed to create an account?*
10
+
11
+ ## Install
12
+
13
+ ```ts
14
+ // bootstrap/providers.ts
15
+ import { GatesServiceProvider } from "@shaferllc/keel/gates";
16
+
17
+ export const providers = [AppServiceProvider, GatesServiceProvider];
18
+ ```
19
+
20
+ Then migrate — the provider contributes `invite_codes` and `email_allowlist`
21
+ tables (`CREATE TABLE IF NOT EXISTS`, so existing apps stay safe):
22
+
23
+ ```bash
24
+ keel migrate
25
+ ```
26
+
27
+ ## Checking registration
28
+
29
+ ```ts
30
+ import { canRegister, redeemInvite } from "@shaferllc/keel/gates";
31
+
32
+ const gate = await canRegister(email, inviteCode);
33
+ if (!gate.ok) {
34
+ return json({ error: gate.reason }, 403);
35
+ }
36
+
37
+ // …create the user…
38
+
39
+ if (gate.via === "code" && gate.invite) {
40
+ await redeemInvite(gate.invite); // increments uses
41
+ }
42
+ ```
43
+
44
+ `canRegister` returns:
45
+
46
+ | Result | Meaning |
47
+ |--------|---------|
48
+ | `{ ok: true, via: "allowlist" }` | Email is on `email_allowlist` |
49
+ | `{ ok: true, via: "code", invite }` | Valid invite code (not expired, uses left) |
50
+ | `{ ok: false, reason }` | Rejected — show `reason` to the user |
51
+
52
+ Allowlist wins over codes: if the email is allowlisted, the code is ignored.
53
+
54
+ ## Managing codes and allowlist
55
+
56
+ The models are ordinary Keel models — create rows from an admin UI or a seeder:
57
+
58
+ ```ts
59
+ import { InviteCode, EmailAllowlist } from "@shaferllc/keel/gates";
60
+
61
+ await InviteCode.create({
62
+ code: "ALPHA-42",
63
+ max_uses: 10,
64
+ uses: 0,
65
+ expires_at: null,
66
+ });
67
+
68
+ await EmailAllowlist.create({ email: "ada@example.com" });
69
+ ```
70
+
71
+ ## Related
72
+
73
+ - [Accounts](./accounts.md) — register / login flows that call `canRegister` first
74
+ - [Teams](./teams.md) — invitations *into* a team, after the user already exists
75
+ - [Authorization](./authorization.md) — ability checks once they're signed in