devlyn-cli 0.3.0 → 0.5.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.
@@ -0,0 +1,224 @@
1
+ # Database Schema Reference
2
+
3
+ Complete Drizzle ORM schema for Better Auth with multi-tenant organization support, API keys, and usage tracking.
4
+
5
+ ## Table of Contents
6
+ 1. [Auth Tables](#auth-tables) — users, sessions, accounts, verifications
7
+ 2. [Organization Tables](#organization-tables) — organizations, memberships, invitations
8
+ 3. [Project & API Key Tables](#project--api-key-tables) — projects, api_keys
9
+ 4. [Supporting Tables](#supporting-tables) — usage, billing, webhooks
10
+
11
+ ## Auth Tables
12
+
13
+ These tables are managed by Better Auth. Define them in Drizzle with the exact columns Better Auth expects, using your preferred table names (you'll map them in the adapter config).
14
+
15
+ ```typescript
16
+ import {
17
+ boolean, index, pgTable, text, timestamp,
18
+ uniqueIndex, uuid, varchar,
19
+ } from "drizzle-orm/pg-core";
20
+
21
+ // Users — core identity table
22
+ export const users = pgTable("users", {
23
+ id: uuid("id").primaryKey().defaultRandom(),
24
+ name: varchar("name", { length: 255 }),
25
+ email: varchar("email", { length: 255 }).notNull().unique(),
26
+ emailVerified: boolean("email_verified").notNull().default(false),
27
+ image: text("image"),
28
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
29
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
30
+ });
31
+
32
+ // Sessions — tracks active login sessions
33
+ export const sessions = pgTable(
34
+ "sessions",
35
+ {
36
+ id: uuid("id").primaryKey().defaultRandom(),
37
+ userId: uuid("user_id")
38
+ .notNull()
39
+ .references(() => users.id, { onDelete: "cascade" }),
40
+ token: varchar("token", { length: 255 }).notNull().unique(),
41
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
42
+ ipAddress: varchar("ip_address", { length: 45 }),
43
+ userAgent: text("user_agent"),
44
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
45
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
46
+ },
47
+ (table) => [index("idx_sessions_user_id").on(table.userId)],
48
+ );
49
+
50
+ // Accounts — OAuth and credential accounts linked to users
51
+ export const accounts = pgTable(
52
+ "accounts",
53
+ {
54
+ id: uuid("id").primaryKey().defaultRandom(),
55
+ userId: uuid("user_id")
56
+ .notNull()
57
+ .references(() => users.id, { onDelete: "cascade" }),
58
+ accountId: varchar("account_id", { length: 255 }).notNull(),
59
+ providerId: varchar("provider_id", { length: 255 }).notNull(),
60
+ accessToken: text("access_token"),
61
+ refreshToken: text("refresh_token"),
62
+ accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
63
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
64
+ scope: text("scope"),
65
+ password: text("password"),
66
+ idToken: text("id_token"),
67
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
68
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
69
+ },
70
+ (table) => [
71
+ index("idx_accounts_user_id").on(table.userId),
72
+ uniqueIndex("idx_accounts_provider").on(table.providerId, table.accountId),
73
+ ],
74
+ );
75
+
76
+ // Verifications — email verification and password reset tokens
77
+ export const verifications = pgTable(
78
+ "verifications",
79
+ {
80
+ id: uuid("id").primaryKey().defaultRandom(),
81
+ identifier: text("identifier").notNull(),
82
+ value: text("value").notNull(),
83
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
84
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
85
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
86
+ },
87
+ (table) => [index("idx_verifications_identifier").on(table.identifier)],
88
+ );
89
+ ```
90
+
91
+ ### Key Design Decisions
92
+
93
+ - **UUIDs everywhere** — Better Auth supports UUID primary keys via `defaultRandom()`. This avoids sequential ID enumeration attacks.
94
+ - **Cascade deletes on sessions/accounts** — When a user is deleted, their sessions and OAuth accounts are automatically cleaned up.
95
+ - **Unique constraint on (providerId, accountId)** — Prevents duplicate OAuth account links.
96
+ - **Index on verifications.identifier** — Email lookups during verification need to be fast.
97
+ - **`withTimezone: true`** — All timestamps stored in UTC. Prevents timezone bugs in multi-region deployments.
98
+
99
+ ## Organization Tables
100
+
101
+ Multi-tenant support with role-based membership.
102
+
103
+ ```typescript
104
+ export const organizations = pgTable(
105
+ "organizations",
106
+ {
107
+ id: uuid("id").primaryKey().defaultRandom(),
108
+ name: varchar("name", { length: 255 }).notNull(),
109
+ slug: varchar("slug", { length: 100 }).notNull().unique(),
110
+ logo: text("logo"),
111
+ metadata: text("metadata"),
112
+ plan: varchar("plan", { length: 50 }).notNull().default("free"),
113
+ // Billing provider fields (Stripe/Polar/etc.)
114
+ billingCustomerId: varchar("billing_customer_id", { length: 255 }),
115
+ billingSubscriptionId: varchar("billing_subscription_id", { length: 255 }),
116
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
117
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
118
+ },
119
+ (table) => [index("idx_organizations_billing").on(table.billingCustomerId)],
120
+ );
121
+
122
+ export const orgMemberships = pgTable(
123
+ "org_memberships",
124
+ {
125
+ id: uuid("id").primaryKey().defaultRandom(),
126
+ organizationId: uuid("organization_id")
127
+ .notNull()
128
+ .references(() => organizations.id, { onDelete: "cascade" }),
129
+ userId: uuid("user_id")
130
+ .notNull()
131
+ .references(() => users.id, { onDelete: "cascade" }),
132
+ role: varchar("role", { length: 50 }).notNull().default("member"),
133
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
134
+ },
135
+ (table) => [
136
+ uniqueIndex("idx_org_memberships_unique").on(table.organizationId, table.userId),
137
+ index("idx_org_memberships_user").on(table.userId),
138
+ index("idx_org_memberships_org").on(table.organizationId),
139
+ ],
140
+ );
141
+
142
+ export const invitations = pgTable(
143
+ "invitations",
144
+ {
145
+ id: uuid("id").primaryKey().defaultRandom(),
146
+ email: varchar("email", { length: 255 }).notNull(),
147
+ inviterId: uuid("inviter_id")
148
+ .notNull()
149
+ .references(() => users.id, { onDelete: "cascade" }),
150
+ organizationId: uuid("organization_id")
151
+ .notNull()
152
+ .references(() => organizations.id, { onDelete: "cascade" }),
153
+ role: varchar("role", { length: 50 }).notNull().default("member"),
154
+ status: varchar("status", { length: 50 }).notNull().default("pending"),
155
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
156
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
157
+ },
158
+ (table) => [
159
+ index("idx_invitations_org").on(table.organizationId),
160
+ index("idx_invitations_email").on(table.email),
161
+ ],
162
+ );
163
+ ```
164
+
165
+ ### Key Design Decisions
166
+
167
+ - **Unique (org, user) membership** — A user can only have one role per org.
168
+ - **Plan on organization** — Billing is org-level, not user-level. This simplifies plan enforcement.
169
+ - **Slug is unique** — Used in URLs (`/orgs/my-org`). Auto-generated with UUID suffix to prevent collisions.
170
+
171
+ ## Project & API Key Tables
172
+
173
+ Projects scope resources within an organization. API keys are scoped to projects.
174
+
175
+ ```typescript
176
+ export const projects = pgTable(
177
+ "projects",
178
+ {
179
+ id: uuid("id").primaryKey().defaultRandom(),
180
+ organizationId: uuid("organization_id")
181
+ .notNull()
182
+ .references(() => organizations.id, { onDelete: "cascade" }),
183
+ name: varchar("name", { length: 255 }).notNull(),
184
+ slug: varchar("slug", { length: 100 }).notNull(),
185
+ description: text("description"),
186
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
187
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
188
+ },
189
+ (table) => [
190
+ uniqueIndex("idx_projects_org_slug").on(table.organizationId, table.slug),
191
+ index("idx_projects_org").on(table.organizationId),
192
+ ],
193
+ );
194
+
195
+ export const apiKeys = pgTable(
196
+ "api_keys",
197
+ {
198
+ id: uuid("id").primaryKey().defaultRandom(),
199
+ projectId: uuid("project_id")
200
+ .notNull()
201
+ .references(() => projects.id, { onDelete: "cascade" }),
202
+ name: varchar("name", { length: 255 }).notNull(),
203
+ keyHash: varchar("key_hash", { length: 255 }).notNull(),
204
+ keyPrefix: varchar("key_prefix", { length: 12 }).notNull(),
205
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
206
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
207
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
208
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
209
+ },
210
+ (table) => [
211
+ index("idx_api_keys_project").on(table.projectId),
212
+ uniqueIndex("idx_api_keys_hash").on(table.keyHash),
213
+ index("idx_api_keys_prefix").on(table.keyPrefix),
214
+ ],
215
+ );
216
+ ```
217
+
218
+ ### Key Design Decisions
219
+
220
+ - **Project slug unique per org** — Not globally unique, scoped to organization via composite unique index.
221
+ - **keyHash unique** — Enables O(1) lookup during API key validation.
222
+ - **Soft-delete via `revokedAt`** — Revoked keys remain in the database for audit trail. The auth middleware checks `revokedAt !== null` to reject them.
223
+ - **`lastUsedAt` nullable** — Updated fire-and-forget on each API key auth. Useful for usage analytics and stale key detection.
224
+ - **No plaintext key column** — The plaintext is generated, returned to the user once, and only the SHA-256 hash is stored. This is a security requirement, not an optimization.
@@ -0,0 +1,241 @@
1
+ # Test Infrastructure Reference
2
+
3
+ Test setup, seed factories, and integration test patterns for Better Auth with Bun.
4
+
5
+ ## Table of Contents
6
+ 1. [Test Preload](#test-preload)
7
+ 2. [Seed Data Factory](#seed-data-factory)
8
+ 3. [Integration Test App](#integration-test-app)
9
+ 4. [Database Cleanup](#database-cleanup)
10
+ 5. [Key Testing Patterns](#key-testing-patterns)
11
+
12
+ ## Test Preload
13
+
14
+ Prevents the most common testing pitfall: real emails sent during test runs.
15
+
16
+ ```typescript
17
+ // src/test-utils/setup.ts — preloaded via bunfig.toml
18
+ // Without this, test signups send real emails through Resend
19
+ // when RESEND_API_KEY is in your .env file.
20
+ process.env.NODE_ENV = "test";
21
+ process.env.RESEND_API_KEY = ""; // Force email to console-log fallback
22
+
23
+ // Bridge test database — use a separate DB for tests
24
+ if (process.env.TEST_DATABASE_URL) {
25
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
26
+ }
27
+ ```
28
+
29
+ ```toml
30
+ # bunfig.toml
31
+ [test]
32
+ preload = ["./src/test-utils/setup.ts"]
33
+ ```
34
+
35
+ **Why preload?** Bun's preload runs before any test file imports. This guarantees that config validation (which runs at import time) sees the correct environment variables. Setting `RESEND_API_KEY = ""` before any module loads ensures the email module's lazy client never initializes.
36
+
37
+ ## Seed Data Factory
38
+
39
+ Creates a complete tenant hierarchy for integration tests. Every call produces unique identifiers to prevent collisions in parallel test runs.
40
+
41
+ ```typescript
42
+ // src/test-utils/db.ts
43
+ import { db } from "../db";
44
+ import {
45
+ organizations, users, orgMemberships,
46
+ projects, apiKeys, sessions, accounts,
47
+ verifications, invitations,
48
+ } from "../db/schema";
49
+
50
+ export async function seedTestData() {
51
+ const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
52
+
53
+ // Create org → user → membership → project → API key
54
+ const [org] = await db.insert(organizations).values({
55
+ name: `Test Org ${uniqueSuffix}`,
56
+ slug: `test-org-${uniqueSuffix}`,
57
+ plan: "free",
58
+ }).returning();
59
+
60
+ const [user] = await db.insert(users).values({
61
+ email: `test-${uniqueSuffix}@example.com`,
62
+ name: "Test User",
63
+ emailVerified: true,
64
+ }).returning();
65
+
66
+ await db.insert(orgMemberships).values({
67
+ organizationId: org.id,
68
+ userId: user.id,
69
+ role: "owner",
70
+ });
71
+
72
+ const [project] = await db.insert(projects).values({
73
+ organizationId: org.id,
74
+ name: `Test Project ${uniqueSuffix}`,
75
+ slug: `test-project-${uniqueSuffix}`,
76
+ }).returning();
77
+
78
+ // Generate API key
79
+ const { key, hash, prefix } = await generateTestApiKey();
80
+ const [apiKey] = await db.insert(apiKeys).values({
81
+ projectId: project.id,
82
+ name: "Test Key",
83
+ keyHash: hash,
84
+ keyPrefix: prefix,
85
+ }).returning();
86
+
87
+ return { org, user, project, apiKey, plaintextKey: key };
88
+ }
89
+
90
+ // Helper: generate a test API key
91
+ async function generateTestApiKey() {
92
+ const { API_KEY_PREFIX, base62Encode, hashKey } = await import("../lib/api-keys");
93
+ const randomBytes = crypto.getRandomValues(new Uint8Array(32));
94
+ const key = API_KEY_PREFIX + base62Encode(randomBytes);
95
+ const hash = await hashKey(key);
96
+ const prefix = key.slice(0, 12);
97
+ return { key, hash, prefix };
98
+ }
99
+ ```
100
+
101
+ **Key design decisions:**
102
+ - `uniqueSuffix` uses `Date.now()` + random chars for collision-free parallel tests
103
+ - `emailVerified: true` so tests don't need to go through email verification flow
104
+ - Returns `plaintextKey` so tests can immediately make authenticated requests
105
+
106
+ ## Integration Test App
107
+
108
+ Builds the full middleware chain matching production. This catches middleware ordering bugs before they reach production.
109
+
110
+ ```typescript
111
+ // src/test-utils/app.ts
112
+ import { Hono } from "hono";
113
+
114
+ // Lazy imports to avoid circular dependency issues during test setup
115
+ export function createIntegrationApp() {
116
+ const { authMiddleware } = require("../middleware/auth");
117
+ const { tenantContextMiddleware } = require("../middleware/tenant-context");
118
+ const { rateLimitMiddleware } = require("../middleware/rate-limit");
119
+
120
+ const app = new Hono();
121
+
122
+ // Request ID
123
+ app.use("*", async (c, next) => {
124
+ c.set("requestId", crypto.randomUUID());
125
+ await next();
126
+ });
127
+
128
+ // Auth → Tenant Context → Rate Limit (same order as production)
129
+ app.use("*", authMiddleware);
130
+ app.use("*", tenantContextMiddleware);
131
+ app.use("*", rateLimitMiddleware);
132
+
133
+ // Mount route handlers here...
134
+ return app;
135
+ }
136
+ ```
137
+
138
+ **Why lazy imports?** The auth module imports the database module, which reads `DATABASE_URL` from the environment. If imported eagerly at the top level, the test preload script might not have run yet, causing config validation to fail.
139
+
140
+ ## Database Cleanup
141
+
142
+ Delete tables in FK dependency order to avoid constraint violations.
143
+
144
+ ```typescript
145
+ // Clean tables in FK dependency order
146
+ export async function cleanupDatabase() {
147
+ // Children first, parents last
148
+ await db.delete(apiKeys);
149
+ await db.delete(projects);
150
+ await db.delete(orgMemberships);
151
+ await db.delete(sessions);
152
+ await db.delete(accounts);
153
+ await db.delete(verifications);
154
+ await db.delete(invitations);
155
+ await db.delete(organizations);
156
+ await db.delete(users);
157
+ }
158
+ ```
159
+
160
+ **Order matters.** If you try to delete `organizations` before `projects`, the FK constraint on `projects.organization_id` will reject the delete. Start from leaf tables and work toward root tables.
161
+
162
+ ## Key Testing Patterns
163
+
164
+ ### Test Auth via API Key
165
+
166
+ ```typescript
167
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
168
+
169
+ describe("Protected endpoint", () => {
170
+ let testData: Awaited<ReturnType<typeof seedTestData>>;
171
+
172
+ beforeAll(async () => {
173
+ testData = await seedTestData();
174
+ });
175
+
176
+ afterAll(async () => {
177
+ await cleanupDatabase();
178
+ });
179
+
180
+ test("returns 200 with valid API key", async () => {
181
+ const app = createIntegrationApp();
182
+ const res = await app.request("/v1/endpoint", {
183
+ headers: {
184
+ Authorization: `Bearer ${testData.plaintextKey}`,
185
+ },
186
+ });
187
+ expect(res.status).toBe(200);
188
+ });
189
+
190
+ test("returns 401 without auth", async () => {
191
+ const app = createIntegrationApp();
192
+ const res = await app.request("/v1/endpoint");
193
+ expect(res.status).toBe(401);
194
+ });
195
+ });
196
+ ```
197
+
198
+ ### Test Signup Flow
199
+
200
+ ```typescript
201
+ test("signup creates user and personal org", async () => {
202
+ const res = await app.request("/auth/sign-up/email", {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({
206
+ email: `signup-test-${Date.now()}@example.com`,
207
+ password: "secure-password-123",
208
+ name: "Test User",
209
+ }),
210
+ });
211
+
212
+ expect(res.status).toBe(200);
213
+
214
+ // Verify auto-org creation via databaseHooks
215
+ const user = await db.select().from(users)
216
+ .where(eq(users.email, email)).limit(1);
217
+
218
+ const membership = await db.select().from(orgMemberships)
219
+ .where(eq(orgMemberships.userId, user[0].id)).limit(1);
220
+
221
+ expect(membership).toHaveLength(1);
222
+ expect(membership[0].role).toBe("owner");
223
+ });
224
+ ```
225
+
226
+ ### Test Tenant Isolation
227
+
228
+ ```typescript
229
+ test("cannot access resources from another org", async () => {
230
+ const org1 = await seedTestData();
231
+ const org2 = await seedTestData();
232
+
233
+ // Use org1's API key to try accessing org2's project
234
+ const res = await app.request(`/v1/projects/${org2.project.id}`, {
235
+ headers: { Authorization: `Bearer ${org1.plaintextKey}` },
236
+ });
237
+
238
+ // Should be 404 (not 403, to prevent ID enumeration)
239
+ expect(res.status).toBe(404);
240
+ });
241
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code configuration toolkit for teams",
5
5
  "bin": {
6
6
  "devlyn": "bin/devlyn.js"
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "bin",
10
10
  "config",
11
+ "optional-commands",
11
12
  "optional-skills",
12
13
  "CLAUDE.md"
13
14
  ],