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.
- package/README.md +172 -62
- package/bin/devlyn.js +40 -4
- package/optional-commands/pencil-sync/devlyn.pencil-pull.md +123 -0
- package/optional-commands/pencil-sync/devlyn.pencil-push.md +70 -0
- package/optional-skills/better-auth-setup/SKILL.md +450 -0
- package/optional-skills/better-auth-setup/references/api-keys.md +236 -0
- package/optional-skills/better-auth-setup/references/config-and-entry.md +239 -0
- package/optional-skills/better-auth-setup/references/middleware.md +409 -0
- package/optional-skills/better-auth-setup/references/schema.md +224 -0
- package/optional-skills/better-auth-setup/references/testing.md +241 -0
- package/package.json +2 -1
|
@@ -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
|
+
"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
|
],
|