@shaferllc/keel 0.83.2 → 0.83.3

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 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,8 @@ 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
- | [Authorization](./docs/authorization.md) | Gates & policies, `can`/`authorize` |
208
+ | [Gates](./docs/gates.md) | Signup gating — invite codes & email allowlist |
209
+ | [Authorization](./docs/authorization.md) | Ability gates & policies, `can`/`authorize` |
207
210
  | [Database](./docs/database.md) | Driver-agnostic query builder |
208
211
  | [Models](./docs/models.md) | Active-record: find/create/save, casts, relations |
209
212
  | [Migrations](./docs/migrations.md) | Schema builder + migrator, dialect-aware |
@@ -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,7 @@
1
+ /**
2
+ * Dump a SQLite-compatible connection to a portable .sql script.
3
+ */
4
+ import type { Connection } from "../core/database.js";
5
+ export declare function dumpConnection(conn: Connection, header: string, opts?: {
6
+ generatedBy?: string;
7
+ }): Promise<string>;
@@ -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>>;
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.83.2",
3
+ "version": "0.83.3",
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",
package/docs/changelog.md CHANGED
@@ -4,6 +4,19 @@ 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.3] — 2026-07-12
8
+
9
+ ### Added
10
+
11
+ - **`@shaferllc/keel/gates`** — signup gating for private alpha / waitlist:
12
+ email allowlist + invite codes (`canRegister` / `redeemInvite`), shipped as a
13
+ package with a migration. Used by Keel Cloud; not the same as team invitations
14
+ or authorization gates.
15
+ - **`@shaferllc/keel/hosting`** — Cloudflare client, hostname helpers, SQL dump,
16
+ and secrets encryption helpers for hosted Workers/D1 apps.
17
+
18
+ [0.83.3]: https://github.com/shaferllc/keel/releases/tag/v0.83.3
19
+
7
20
  ## [0.83.2] — 2026-07-12
8
21
 
9
22
  ### 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
+ }
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
package/llms-full.txt CHANGED
@@ -9429,6 +9429,88 @@ definition function.
9429
9429
 
9430
9430
 
9431
9431
 
9432
+ ---
9433
+
9434
+ <!-- source: docs/gates.md -->
9435
+
9436
+ # Gates
9437
+
9438
+ Keel Gates is a **signup gate** for private alpha / waitlist apps: an email
9439
+ allowlist, invite codes with use limits and expiry, and a single check that
9440
+ answers "may this person register?". It ships as `@shaferllc/keel/gates`.
9441
+
9442
+ This is **not** authorization (`can` / policies in [authorization](./authorization.md))
9443
+ and **not** team invitations ([teams](./teams.md)). Those answer different
9444
+ questions. Gates answers only: *is this email allowed to create an account?*
9445
+
9446
+ ## Install
9447
+
9448
+ ```ts
9449
+ // bootstrap/providers.ts
9450
+ import { GatesServiceProvider } from "@shaferllc/keel/gates";
9451
+
9452
+ export const providers = [AppServiceProvider, GatesServiceProvider];
9453
+ ```
9454
+
9455
+ Then migrate — the provider contributes `invite_codes` and `email_allowlist`
9456
+ tables (`CREATE TABLE IF NOT EXISTS`, so existing apps stay safe):
9457
+
9458
+ ```bash
9459
+ keel migrate
9460
+ ```
9461
+
9462
+ ## Checking registration
9463
+
9464
+ ```ts
9465
+ import { canRegister, redeemInvite } from "@shaferllc/keel/gates";
9466
+
9467
+ const gate = await canRegister(email, inviteCode);
9468
+ if (!gate.ok) {
9469
+ return json({ error: gate.reason }, 403);
9470
+ }
9471
+
9472
+ // …create the user…
9473
+
9474
+ if (gate.via === "code" && gate.invite) {
9475
+ await redeemInvite(gate.invite); // increments uses
9476
+ }
9477
+ ```
9478
+
9479
+ `canRegister` returns:
9480
+
9481
+ | Result | Meaning |
9482
+ |--------|---------|
9483
+ | `{ ok: true, via: "allowlist" }` | Email is on `email_allowlist` |
9484
+ | `{ ok: true, via: "code", invite }` | Valid invite code (not expired, uses left) |
9485
+ | `{ ok: false, reason }` | Rejected — show `reason` to the user |
9486
+
9487
+ Allowlist wins over codes: if the email is allowlisted, the code is ignored.
9488
+
9489
+ ## Managing codes and allowlist
9490
+
9491
+ The models are ordinary Keel models — create rows from an admin UI or a seeder:
9492
+
9493
+ ```ts
9494
+ import { InviteCode, EmailAllowlist } from "@shaferllc/keel/gates";
9495
+
9496
+ await InviteCode.create({
9497
+ code: "ALPHA-42",
9498
+ max_uses: 10,
9499
+ uses: 0,
9500
+ expires_at: null,
9501
+ });
9502
+
9503
+ await EmailAllowlist.create({ email: "ada@example.com" });
9504
+ ```
9505
+
9506
+ ## Related
9507
+
9508
+ - [Accounts](./accounts.md) — register / login flows that call `canRegister` first
9509
+ - [Teams](./teams.md) — invitations *into* a team, after the user already exists
9510
+ - [Authorization](./authorization.md) — ability checks once they're signed in
9511
+
9512
+
9513
+
9432
9514
  ---
9433
9515
 
9434
9516
  <!-- source: docs/hashing.md -->
package/llms.txt CHANGED
@@ -30,6 +30,7 @@ Userland imports everything from `@shaferllc/keel/core`.
30
30
  - [Errors & Exceptions](https://github.com/shaferllc/keel/blob/main/docs/errors.md): Throw an exception anywhere — a handler, middleware, or a service deep in the container — and Keel's HTTP kernel turns it into the right response.
31
31
  - [Events](https://github.com/shaferllc/keel/blob/main/docs/events.md): A tiny event emitter for decoupling — fire an event in one place, handle it in another.
32
32
  - [Factories & Seeders](https://github.com/shaferllc/keel/blob/main/docs/factories.md): Populate the database with realistic fixtures for tests and demos.
33
+ - [Gates](https://github.com/shaferllc/keel/blob/main/docs/gates.md): 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?".
33
34
  - [Getting Started](https://github.com/shaferllc/keel/blob/main/docs/getting-started.md): Keel is a house framework for Node.js — a small, legible MVC layer over Hono.
34
35
  - [Hashing & Encryption](https://github.com/shaferllc/keel/blob/main/docs/hashing.md): Password hashing and value encryption, both built on the Web Crypto API — so they run identically on Node and the edge, with no native bindings (no bcrypt to compile).
35
36
  - [Health Checks](https://github.com/shaferllc/keel/blob/main/docs/health.md): Two endpoints, answering the two questions an orchestrator — Kubernetes, Fly, Railway, a load balancer — actually asks:
@@ -97,6 +98,7 @@ Every topic has a runnable, type-checked example:
97
98
  - [Errors & Exceptions example](https://github.com/shaferllc/keel/blob/main/docs/examples/errors.ts)
98
99
  - [Events example](https://github.com/shaferllc/keel/blob/main/docs/examples/events.ts)
99
100
  - [Factories & Seeders example](https://github.com/shaferllc/keel/blob/main/docs/examples/factories.ts)
101
+ - [Gates example](https://github.com/shaferllc/keel/blob/main/docs/examples/gates.ts)
100
102
  - [Hashing & Encryption example](https://github.com/shaferllc/keel/blob/main/docs/examples/hashing.ts)
101
103
  - [Health Checks example](https://github.com/shaferllc/keel/blob/main/docs/examples/health.ts)
102
104
  - [Helpers example](https://github.com/shaferllc/keel/blob/main/docs/examples/helpers.ts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.83.2",
3
+ "version": "0.83.3",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -77,6 +77,14 @@
77
77
  "types": "./dist/billing/index.d.ts",
78
78
  "import": "./dist/billing/index.js"
79
79
  },
80
+ "./gates": {
81
+ "types": "./dist/gates/index.d.ts",
82
+ "import": "./dist/gates/index.js"
83
+ },
84
+ "./hosting": {
85
+ "types": "./dist/hosting/index.d.ts",
86
+ "import": "./dist/hosting/index.js"
87
+ },
80
88
  "./cli": {
81
89
  "types": "./dist/core/cli/index.d.ts",
82
90
  "import": "./dist/core/cli/index.js"