@kuratchi/auth 0.0.1

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,152 @@
1
+ /**
2
+ * @kuratchi/auth — Guards API
3
+ *
4
+ * Route protection that runs before route handlers.
5
+ * Configure in kuratchi.config.ts, or call requireAuth() in server functions.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // In kuratchi.config.ts:
10
+ * auth: {
11
+ * guards: {
12
+ * paths: ['/admin/*', '/dashboard/*'],
13
+ * exclude: ['/admin/login'],
14
+ * redirectTo: '/auth/login',
15
+ * }
16
+ * }
17
+ *
18
+ * // Or call directly in a server function:
19
+ * import { requireAuth } from '@kuratchi/auth';
20
+ * const user = await requireAuth(); // throws redirect if not authenticated
21
+ * ```
22
+ */
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ export interface GuardsConfig {
29
+ /** Paths to protect (glob patterns). If empty, protects all paths. */
30
+ paths?: string[];
31
+ /** Paths to exclude from protection (glob patterns) */
32
+ exclude?: string[];
33
+ /** Redirect URL if not authenticated (default: '/auth/login') */
34
+ redirectTo?: string;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Module state
39
+ // ============================================================================
40
+
41
+ let _config: GuardsConfig | null = null;
42
+
43
+ /**
44
+ * Configure route guards. Called automatically by the compiler from kuratchi.config.ts.
45
+ */
46
+ export function configureGuards(config: GuardsConfig): void {
47
+ _config = config;
48
+ }
49
+
50
+ // ============================================================================
51
+ // Framework context
52
+ // ============================================================================
53
+
54
+ function _getContext() {
55
+ const dezContext = (globalThis as any).__kuratchi_context__;
56
+ return {
57
+ request: dezContext?.request as Request | undefined,
58
+ locals: dezContext?.locals as Record<string, any> ?? {},
59
+ };
60
+ }
61
+
62
+ // ============================================================================
63
+ // Pattern matching
64
+ // ============================================================================
65
+
66
+ function _matchPattern(pathname: string, pattern: string): boolean {
67
+ // /admin/* should match /admin, /admin/, /admin/roles, etc.
68
+ if (pattern.endsWith('/*')) {
69
+ const base = pattern.slice(0, -2); // '/admin'
70
+ if (pathname === base || pathname.startsWith(base + '/')) return true;
71
+ return false;
72
+ }
73
+ const regexStr = pattern
74
+ .replace(/\*/g, '.*')
75
+ .replace(/\//g, '\\/');
76
+ return new RegExp(`^${regexStr}$`).test(pathname);
77
+ }
78
+
79
+ // ============================================================================
80
+ // Public API
81
+ // ============================================================================
82
+
83
+ /**
84
+ * Check if the current request should be guarded.
85
+ * Called by the compiler-generated worker entry before route handlers.
86
+ * Returns a redirect Response if not authenticated, or null to proceed.
87
+ */
88
+ export function checkGuard(): Response | null {
89
+ if (!_config) return null;
90
+
91
+ const { request, locals } = _getContext();
92
+ if (!request) return null;
93
+
94
+ const url = new URL(request.url);
95
+ const pathname = url.pathname;
96
+
97
+ // Skip internal asset routes
98
+ if (pathname.startsWith('/_assets/')) return null;
99
+
100
+ // Check if path is protected
101
+ const paths = _config.paths || [];
102
+ const exclude = _config.exclude || [];
103
+
104
+ if (paths.length > 0) {
105
+ const included = paths.some(p => _matchPattern(pathname, p));
106
+ if (!included) return null;
107
+ }
108
+
109
+ if (exclude.length > 0) {
110
+ const excluded = exclude.some(p => _matchPattern(pathname, p));
111
+ if (excluded) return null;
112
+ }
113
+
114
+ // Check if authenticated (session cookie exists)
115
+ const sessionCookie = locals.auth?.sessionCookie;
116
+ if (sessionCookie) return null; // Has session cookie — allow through
117
+
118
+ // Not authenticated — redirect
119
+ const redirectTo = _config.redirectTo || '/auth/login';
120
+ return new Response(null, {
121
+ status: 302,
122
+ headers: { Location: redirectTo },
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Callable guard for use in server functions.
128
+ * Returns the current user or redirects if not authenticated.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * const user = await requireAuth();
133
+ * // If we get here, user is authenticated
134
+ * ```
135
+ */
136
+ export async function requireAuth(options?: {
137
+ redirectTo?: string;
138
+ }): Promise<Record<string, any>> {
139
+ const { getCurrentUser } = await import('./credentials.js');
140
+ const user = await getCurrentUser();
141
+
142
+ if (!user) {
143
+ const { locals } = _getContext();
144
+ locals.__redirectTo = options?.redirectTo || _config?.redirectTo || '/auth/login';
145
+ throw new Error('Authentication required');
146
+ }
147
+
148
+ return user;
149
+ }
150
+
151
+
152
+
@@ -0,0 +1,348 @@
1
+ /**
2
+ * @kuratchi/auth — OAuth API
3
+ *
4
+ * Ready-to-use server functions for OAuth flows.
5
+ * Configure providers once, then startOAuth/handleOAuthCallback just work.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { configureOAuth, startOAuth, handleOAuthCallback, getOAuthData } from '@kuratchi/auth';
10
+ *
11
+ * configureOAuth({
12
+ * providers: {
13
+ * github: {
14
+ * clientId: env.GITHUB_CLIENT_ID,
15
+ * clientSecret: env.GITHUB_CLIENT_SECRET,
16
+ * },
17
+ * },
18
+ * });
19
+ * ```
20
+ */
21
+
22
+ import {
23
+ signState,
24
+ verifyState,
25
+ hashToken,
26
+ buildSessionCookie,
27
+ generateSessionToken,
28
+ } from '../utils/crypto.js';
29
+
30
+ // ============================================================================
31
+ // Types
32
+ // ============================================================================
33
+
34
+ export interface OAuthProviderConfig {
35
+ clientId: string;
36
+ clientSecret: string;
37
+ scopes?: string[];
38
+ }
39
+
40
+ export interface OAuthConfig {
41
+ providers: Record<string, OAuthProviderConfig>;
42
+ /** Redirect after successful OAuth login (default: '/admin') */
43
+ loginRedirect?: string;
44
+ /** Session duration in ms (default: 30 days) */
45
+ sessionDuration?: number;
46
+ }
47
+
48
+ interface ProviderEndpoints {
49
+ authorizeUrl: string;
50
+ tokenUrl: string;
51
+ profileUrl: string;
52
+ defaultScopes: string[];
53
+ parseProfile: (data: any) => { id: string; email: string; name: string | null; image: string | null };
54
+ }
55
+
56
+ // ============================================================================
57
+ // Known providers
58
+ // ============================================================================
59
+
60
+ const PROVIDERS: Record<string, ProviderEndpoints> = {
61
+ github: {
62
+ authorizeUrl: 'https://github.com/login/oauth/authorize',
63
+ tokenUrl: 'https://github.com/login/oauth/access_token',
64
+ profileUrl: 'https://api.github.com/user',
65
+ defaultScopes: ['read:user', 'user:email'],
66
+ parseProfile: (data: any) => ({
67
+ id: String(data.id),
68
+ email: data.email?.toLowerCase() || `${data.login}@github.noemail`,
69
+ name: data.name || data.login || null,
70
+ image: data.avatar_url || null,
71
+ }),
72
+ },
73
+ google: {
74
+ authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
75
+ tokenUrl: 'https://oauth2.googleapis.com/token',
76
+ profileUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
77
+ defaultScopes: ['openid', 'email', 'profile'],
78
+ parseProfile: (data: any) => ({
79
+ id: String(data.id),
80
+ email: data.email?.toLowerCase() || '',
81
+ name: data.name || null,
82
+ image: data.picture || null,
83
+ }),
84
+ },
85
+ };
86
+
87
+ // ============================================================================
88
+ // Module state
89
+ // ============================================================================
90
+
91
+ let _config: OAuthConfig | null = null;
92
+
93
+ /**
94
+ * Configure OAuth providers. Call once at module scope.
95
+ */
96
+ export function configureOAuth(config: OAuthConfig): void {
97
+ _config = config;
98
+ }
99
+
100
+ /**
101
+ * Get configured OAuth provider info (for UI rendering).
102
+ */
103
+ export function getOAuthProviders(): string[] {
104
+ if (!_config) return [];
105
+ return Object.keys(_config.providers).filter(p => {
106
+ const c = _config!.providers[p];
107
+ return !!c.clientId;
108
+ });
109
+ }
110
+
111
+ // ============================================================================
112
+ // Framework context
113
+ // ============================================================================
114
+
115
+ function _getEnv(): Record<string, any> {
116
+ return (globalThis as any).__cloudflare_env__ ?? {};
117
+ }
118
+
119
+ function _getContext() {
120
+ const dezContext = (globalThis as any).__kuratchi_context__;
121
+ return {
122
+ request: dezContext?.request as Request | undefined,
123
+ locals: dezContext?.locals as Record<string, any> ?? {},
124
+ };
125
+ }
126
+
127
+ function _getDb(): any {
128
+ const env = _getEnv();
129
+ if (!env.DB) throw new Error('[kuratchi/auth] No DB binding found.');
130
+ return env.DB;
131
+ }
132
+
133
+ function _getSecret(): string {
134
+ const secret = _getEnv().AUTH_SECRET;
135
+ if (!secret) {
136
+ throw new Error(
137
+ '[kuratchi/auth] AUTH_SECRET is not set. Add it to .dev.vars (local) or Workers secrets (production). '
138
+ + 'Auth operations cannot proceed without a secret.'
139
+ );
140
+ }
141
+ return secret;
142
+ }
143
+
144
+ function _getCookieName(): string {
145
+ const { locals } = _getContext();
146
+ return locals.auth?.cookieName || 'kuratchi_session';
147
+ }
148
+
149
+ function _setRedirect(path: string) {
150
+ const { locals } = _getContext();
151
+ locals.__redirectTo = path;
152
+ }
153
+
154
+ function _pushSetCookie(header: string) {
155
+ const { locals } = _getContext();
156
+ if (!locals.__setCookieHeaders) locals.__setCookieHeaders = [];
157
+ locals.__setCookieHeaders.push(header);
158
+ }
159
+
160
+ function _buildSetCookieHeader(name: string, value: string, opts: {
161
+ expires?: Date; httpOnly?: boolean; secure?: boolean; sameSite?: string; path?: string;
162
+ }): string {
163
+ const parts = [`${name}=${value}`];
164
+ parts.push(`Path=${opts.path || '/'}`);
165
+ if (opts.httpOnly !== false) parts.push('HttpOnly');
166
+ if (opts.secure !== false) parts.push('Secure');
167
+ parts.push(`SameSite=${opts.sameSite || 'Lax'}`);
168
+ if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
169
+ return parts.join('; ');
170
+ }
171
+
172
+ // ============================================================================
173
+ // Public API
174
+ // ============================================================================
175
+
176
+ /**
177
+ * Get OAuth data for the OAuth page (which providers are configured).
178
+ */
179
+ export async function getOAuthData() {
180
+ const { request } = _getContext();
181
+ const url = new URL(request?.url || 'http://localhost');
182
+ const origin = `${url.protocol}//${url.host}`;
183
+
184
+ const providers = getOAuthProviders();
185
+
186
+ return {
187
+ providers,
188
+ hasGithub: providers.includes('github'),
189
+ hasGoogle: providers.includes('google'),
190
+ origin,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Start an OAuth flow for a given provider.
196
+ * Reads provider name from FormData or defaults to 'github'.
197
+ */
198
+ export async function startOAuth(formData: FormData): Promise<void> {
199
+ const providerName = (formData.get('provider') as string) || 'github';
200
+ if (!_config) throw new Error('[kuratchi/auth] OAuth not configured. Call configureOAuth() first.');
201
+
202
+ const providerConfig = _config.providers[providerName];
203
+ if (!providerConfig?.clientId) throw new Error(`OAuth provider '${providerName}' not configured.`);
204
+
205
+ const endpoints = PROVIDERS[providerName];
206
+ if (!endpoints) throw new Error(`Unknown OAuth provider: ${providerName}`);
207
+
208
+ const { request } = _getContext();
209
+ const url = new URL(request?.url || 'http://localhost');
210
+ const origin = `${url.protocol}//${url.host}`;
211
+ const secret = _getSecret();
212
+
213
+ const state = await signState(secret, {
214
+ provider: providerName,
215
+ redirectTo: _config.loginRedirect || '/admin',
216
+ ts: Date.now(),
217
+ n: crypto.randomUUID(),
218
+ });
219
+
220
+ const authUrl = new URL(endpoints.authorizeUrl);
221
+ authUrl.searchParams.set('client_id', providerConfig.clientId);
222
+ authUrl.searchParams.set('redirect_uri', `${origin}/auth/oauth/${providerName}/callback`);
223
+ authUrl.searchParams.set('scope', (providerConfig.scopes || endpoints.defaultScopes).join(' '));
224
+ authUrl.searchParams.set('state', state);
225
+ if (providerName === 'google') {
226
+ authUrl.searchParams.set('response_type', 'code');
227
+ authUrl.searchParams.set('access_type', 'offline');
228
+ }
229
+
230
+ _setRedirect(authUrl.toString());
231
+ }
232
+
233
+ /**
234
+ * Handle OAuth callback. Exchanges code for token, fetches profile,
235
+ * creates/links user, creates session, sets cookie.
236
+ */
237
+ export async function handleOAuthCallback(): Promise<{ success?: boolean; error?: string; redirectTo?: string }> {
238
+ const { request } = _getContext();
239
+ const url = new URL(request?.url || 'http://localhost');
240
+ const origin = `${url.protocol}//${url.host}`;
241
+
242
+ const code = url.searchParams.get('code');
243
+ const stateParam = url.searchParams.get('state');
244
+ const secret = _getSecret();
245
+
246
+ if (!code || !stateParam) return { error: 'Missing code or state parameter' };
247
+ if (!_config) return { error: 'OAuth not configured' };
248
+
249
+ // Verify state
250
+ const payload = await verifyState(secret, stateParam);
251
+ if (!payload) return { error: 'Invalid or expired state' };
252
+
253
+ const stateData = payload as Record<string, any>;
254
+ if (stateData.ts && Date.now() - stateData.ts > 600000) return { error: 'State expired' };
255
+
256
+ const providerName = stateData.provider || 'github';
257
+ const providerConfig = _config.providers[providerName];
258
+ const endpoints = PROVIDERS[providerName];
259
+ if (!providerConfig || !endpoints) return { error: `Unknown provider: ${providerName}` };
260
+
261
+ // Exchange code for token
262
+ const tokenBody: Record<string, string> = {
263
+ code,
264
+ client_id: providerConfig.clientId,
265
+ client_secret: providerConfig.clientSecret,
266
+ redirect_uri: `${origin}/auth/oauth/${providerName}/callback`,
267
+ };
268
+ if (providerName === 'google') tokenBody.grant_type = 'authorization_code';
269
+
270
+ const tokenRes = await fetch(endpoints.tokenUrl, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
273
+ body: new URLSearchParams(tokenBody),
274
+ });
275
+
276
+ if (!tokenRes.ok) return { error: `Token exchange failed: ${await tokenRes.text()}` };
277
+
278
+ const tokenJson: any = await tokenRes.json();
279
+ const access_token = tokenJson.access_token;
280
+ if (!access_token) return { error: 'No access token received' };
281
+
282
+ // Fetch profile
283
+ const profileRes = await fetch(endpoints.profileUrl, {
284
+ headers: { 'Authorization': `Bearer ${access_token}`, 'Accept': 'application/json' },
285
+ });
286
+ if (!profileRes.ok) return { error: `Failed to fetch profile: ${await profileRes.text()}` };
287
+
288
+ const profileData: any = await profileRes.json();
289
+ const profile = endpoints.parseProfile(profileData);
290
+
291
+ const db = _getDb();
292
+
293
+ // Check if OAuth account already linked
294
+ const existingOAuth = await db.prepare(
295
+ 'SELECT userId FROM oauthAccounts WHERE provider = ? AND providerAccountId = ?'
296
+ ).bind(providerName, profile.id).first();
297
+
298
+ let user: any;
299
+
300
+ if (existingOAuth?.userId) {
301
+ user = await db.prepare('SELECT * FROM users WHERE id = ?').bind(existingOAuth.userId).first();
302
+ } else {
303
+ // Check by email
304
+ user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(profile.email).first();
305
+
306
+ if (!user) {
307
+ // Create new user
308
+ await db.prepare(
309
+ 'INSERT INTO users (email, name, passwordHash, role, image, emailVerified) VALUES (?, ?, ?, ?, ?, ?)'
310
+ ).bind(profile.email, profile.name, null, 'user', profile.image, Date.now()).run();
311
+ user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(profile.email).first();
312
+ }
313
+
314
+ // Link OAuth account
315
+ if (user) {
316
+ await db.prepare(
317
+ 'INSERT INTO oauthAccounts (userId, provider, providerAccountId, accessToken, refreshToken, idToken) VALUES (?, ?, ?, ?, ?, ?)'
318
+ ).bind(user.id, providerName, profile.id, access_token, tokenJson.refresh_token || null, null).run();
319
+ }
320
+ }
321
+
322
+ if (!user) return { error: 'Failed to create or find user' };
323
+
324
+ // Create session
325
+ const sessionToken = generateSessionToken();
326
+ const sessionTokenHash = await hashToken(sessionToken);
327
+ const now = new Date();
328
+ const duration = _config.sessionDuration ?? 30 * 24 * 60 * 60 * 1000;
329
+ const expires = new Date(now.getTime() + duration);
330
+
331
+ await db.prepare(
332
+ 'INSERT INTO sessions (sessionToken, userId, expires) VALUES (?, ?, ?)'
333
+ ).bind(sessionTokenHash, user.id, expires.getTime()).run();
334
+
335
+ const sessionCookie = await buildSessionCookie(secret, 'default', sessionTokenHash);
336
+ const cookieName = _getCookieName();
337
+ _pushSetCookie(_buildSetCookieHeader(cookieName, sessionCookie, {
338
+ expires, httpOnly: true, secure: true, sameSite: 'lax',
339
+ }));
340
+
341
+ const redirectTo = stateData.redirectTo || _config.loginRedirect || '/admin';
342
+ _setRedirect(redirectTo);
343
+
344
+ return { success: true, redirectTo };
345
+ }
346
+
347
+
348
+
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @kuratchi/auth — Organization API
3
+ *
4
+ * Multi-tenant DO-backed database orchestrator.
5
+ * Manages the DO namespace binding and provides stub access.
6
+ *
7
+ * The DO exposes explicit RPC methods (createUser, getUserByEmail,
8
+ * createSession, getSession, deleteSession) that use the ORM
9
+ * internally. No proxy needed — credentials.ts calls them directly.
10
+ *
11
+ * Configuration lives in `auth.organizations` in kuratchi.config.ts:
12
+ * ```ts
13
+ * organizations: {
14
+ * binding: 'ORG_DB',
15
+ * }
16
+ * ```
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { getOrgClient, getOrgStubByName } from '@kuratchi/auth';
21
+ *
22
+ * // Get stub by DO name (from session cookie or admin lookup)
23
+ * const stub = getOrgStubByName(doName);
24
+ * const user = await stub.getUserByEmail('user@example.com');
25
+ *
26
+ * // Get stub by org ID (resolves via admin DB)
27
+ * const stub = await getOrgClient(organizationId);
28
+ * const sites = await stub.query({ table: 'sites', method: 'many', args: [{}] });
29
+ * ```
30
+ */
31
+
32
+ import { kuratchiORM } from '@kuratchi/orm';
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ export interface OrganizationConfig {
39
+ /** DO namespace binding name in env (e.g. 'ORG_DB') */
40
+ binding: string;
41
+ }
42
+
43
+ export interface OrgDatabaseInfo {
44
+ databaseName: string;
45
+ organizationId: string;
46
+ }
47
+
48
+ // ============================================================================
49
+ // Module state
50
+ // ============================================================================
51
+
52
+ let _config: OrganizationConfig | null = null;
53
+
54
+ /**
55
+ * Configure organization multi-tenancy. Called by the compiler.
56
+ */
57
+ export function configureOrganization(config: OrganizationConfig): void {
58
+ _config = config;
59
+ }
60
+
61
+ // ============================================================================
62
+ // Framework context
63
+ // ============================================================================
64
+
65
+ function _getEnv(): Record<string, any> {
66
+ return (globalThis as any).__cloudflare_env__ ?? {};
67
+ }
68
+
69
+ function _getDoNamespace(): any {
70
+ if (!_config) return null;
71
+ const env = _getEnv();
72
+ return env[_config.binding] || null;
73
+ }
74
+
75
+ function _getAdminDb(): Record<string, any> {
76
+ return kuratchiORM(() => {
77
+ const env = _getEnv();
78
+ if (!env.DB) throw new Error('[kuratchi/auth] No DB binding found.');
79
+ return env.DB;
80
+ });
81
+ }
82
+
83
+ // ============================================================================
84
+ // Public API
85
+ // ============================================================================
86
+
87
+ /**
88
+ * Get a DO stub by its DO name (for direct RPC calls).
89
+ * The stub exposes the OrganizationDO's RPC methods:
90
+ * stub.createUser(), stub.getUserByEmail(), stub.createSession(),
91
+ * stub.getSession(), stub.deleteSession(), stub.query()
92
+ */
93
+ export function getOrgStubByName(doName: string): any | null {
94
+ const doNamespace = _getDoNamespace();
95
+ if (!doNamespace) return null;
96
+ const doId = doNamespace.idFromName(doName);
97
+ return doNamespace.get(doId);
98
+ }
99
+
100
+ /**
101
+ * Resolve the DO name for an organization by its ID.
102
+ * Looks up the `organizations` table in the admin D1 via ORM.
103
+ */
104
+ export async function resolveOrgDatabaseName(organizationId: string): Promise<string | null> {
105
+ try {
106
+ const adminDb = _getAdminDb();
107
+ const result = await adminDb.organizations.where({ id: organizationId }).first();
108
+ return result.data?.doName || null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get a DO stub for an organization by its organization ID.
116
+ * Resolves the DO name from admin D1, then returns the stub.
117
+ */
118
+ export async function getOrgClient(organizationId: string): Promise<any | null> {
119
+ const doName = await resolveOrgDatabaseName(organizationId);
120
+ if (!doName) {
121
+ console.warn(`[kuratchi/auth organization] No database found for org: ${organizationId}`);
122
+ return null;
123
+ }
124
+ return getOrgStubByName(doName);
125
+ }
126
+
127
+ /**
128
+ * Create a new organization database (DO provisioning).
129
+ * Returns the generated database name.
130
+ */
131
+ export async function createOrgDatabase(params: {
132
+ organizationId: string;
133
+ organizationName: string;
134
+ }): Promise<{ databaseName: string }> {
135
+ const doNamespace = _getDoNamespace();
136
+ if (!doNamespace) {
137
+ throw new Error('[kuratchi/auth organization] DO namespace not available');
138
+ }
139
+
140
+ const sanitizedName = params.organizationName
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9-]/g, '-')
143
+ .replace(/-+/g, '-')
144
+ .substring(0, 32);
145
+ const databaseName = `org-${sanitizedName}-${crypto.randomUUID().substring(0, 8)}`;
146
+
147
+ // Touch the DO to provision it (constructor runs initDO → creates tables)
148
+ const doId = doNamespace.idFromName(databaseName);
149
+ doNamespace.get(doId);
150
+
151
+ return { databaseName };
152
+ }
153
+
154
+ /**
155
+ * Get database info for an organization.
156
+ */
157
+ export async function getOrgDatabaseInfo(organizationId: string): Promise<OrgDatabaseInfo | null> {
158
+ const databaseName = await resolveOrgDatabaseName(organizationId);
159
+ if (!databaseName) return null;
160
+ return { databaseName, organizationId };
161
+ }
162
+
163
+ /**
164
+ * Check if the organizations plugin is configured and DO namespace is available.
165
+ */
166
+ export function isOrgAvailable(): boolean {
167
+ return !!_getDoNamespace();
168
+ }
169
+
170
+
171
+