@treeseed/core 0.6.18 → 0.6.20

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/dist/api/app.js CHANGED
@@ -235,6 +235,35 @@ function createTreeseedApiApp(options = {}) {
235
235
  await runtimeProviders.auth.revokePersonalAccessToken(principal.id, c.req.param("id"));
236
236
  return c.json({ ok: true });
237
237
  });
238
+ app.post("/auth/admin/users", async (c) => {
239
+ const unauthorized = requirePermission(c, "users:manage:global");
240
+ if (unauthorized) return unauthorized;
241
+ if (!runtimeProviders.auth.createUser) {
242
+ return jsonError(c, 501, "User management is unavailable for this auth provider.");
243
+ }
244
+ const body = await c.req.json().catch(() => ({}));
245
+ return c.json({
246
+ ok: true,
247
+ payload: await runtimeProviders.auth.createUser({
248
+ email: body.email ?? null,
249
+ displayName: body.displayName ?? null,
250
+ metadata: typeof body.metadata === "object" && body.metadata ? body.metadata : {}
251
+ })
252
+ });
253
+ });
254
+ app.post("/auth/admin/users/:userId/roles", async (c) => {
255
+ const unauthorized = requirePermission(c, "roles:manage:global");
256
+ if (unauthorized) return unauthorized;
257
+ if (!runtimeProviders.auth.setUserRoles) {
258
+ return jsonError(c, 501, "Role management is unavailable for this auth provider.");
259
+ }
260
+ const body = await c.req.json().catch(() => ({}));
261
+ const roles = Array.isArray(body.roles) ? body.roles.map(String) : [];
262
+ return c.json({
263
+ ok: true,
264
+ payload: await runtimeProviders.auth.setUserRoles(c.req.param("userId"), roles)
265
+ });
266
+ });
238
267
  }
239
268
  app.post("/internal/auth/web/sync-user", async (c) => {
240
269
  if (c.get("actorType") !== "service") {
@@ -1,5 +1,5 @@
1
1
  import { CloudflareHttpD1Database } from "@treeseed/sdk";
2
- import { WranglerD1Database } from "@treeseed/sdk/wrangler-d1";
2
+ import { NodeSqliteD1Database } from "@treeseed/sdk/db/node-sqlite";
3
3
  function resolveApiD1Database(config) {
4
4
  if (config.cloudflareAccountId && config.cloudflareApiToken && config.d1DatabaseId) {
5
5
  return new CloudflareHttpD1Database({
@@ -8,15 +8,11 @@ function resolveApiD1Database(config) {
8
8
  databaseId: config.d1DatabaseId
9
9
  });
10
10
  }
11
- if (config.d1DatabaseName) {
12
- return new WranglerD1Database(
13
- config.d1DatabaseName,
14
- config.repoRoot,
15
- config.d1LocalPersistTo || void 0
16
- );
11
+ if (config.d1LocalPersistTo || config.d1DatabaseName) {
12
+ return new NodeSqliteD1Database(config.d1LocalPersistTo);
17
13
  }
18
14
  throw new Error(
19
- "Treeseed API auth requires either CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN + TREESEED_API_D1_DATABASE_ID for remote D1 access, or TREESEED_API_D1_DATABASE_NAME for local Wrangler-backed D1 access."
15
+ "Treeseed API auth requires either CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN + TREESEED_API_D1_DATABASE_ID for remote D1 access, or TREESEED_API_D1_LOCAL_PERSIST_TO for local SQLite-backed D1-compatible access."
20
16
  );
21
17
  }
22
18
  export {
@@ -47,6 +47,18 @@ export declare class D1AuthProvider implements ApiAuthProvider {
47
47
  principal: ApiPrincipal;
48
48
  userId: string;
49
49
  }>;
50
+ createUser(input: {
51
+ email?: string | null;
52
+ displayName?: string | null;
53
+ metadata?: Record<string, unknown>;
54
+ }): Promise<{
55
+ principal: ApiPrincipal;
56
+ userId: string;
57
+ }>;
58
+ setUserRoles(userId: string, roles: string[]): Promise<{
59
+ principal: ApiPrincipal;
60
+ userId: string;
61
+ }>;
50
62
  createServiceToken(input: {
51
63
  serviceId: string;
52
64
  name: string;
@@ -1,5 +1,4 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
- import { resolveApiD1Database } from "./d1-database.js";
3
2
  import { D1AuthStore } from "./d1-store.js";
4
3
  function encodePayload(payload) {
5
4
  return Buffer.from(JSON.stringify(payload)).toString("base64url");
@@ -18,7 +17,10 @@ function safeEqual(left, right) {
18
17
  class D1AuthProvider {
19
18
  constructor(config, options = {}) {
20
19
  this.config = config;
21
- this.store = new D1AuthStore(config, options.db ?? resolveApiD1Database(config));
20
+ if (!options.db) {
21
+ throw new Error("D1AuthProvider requires an explicit database binding or adapter.");
22
+ }
23
+ this.store = new D1AuthStore(config, options.db);
22
24
  }
23
25
  config;
24
26
  id = "d1";
@@ -53,6 +55,12 @@ class D1AuthProvider {
53
55
  syncUserIdentity(identity) {
54
56
  return this.store.syncUser(identity);
55
57
  }
58
+ createUser(input) {
59
+ return this.store.createUser(input);
60
+ }
61
+ setUserRoles(userId, roles) {
62
+ return this.store.setUserRoles(userId, roles);
63
+ }
56
64
  createServiceToken(input) {
57
65
  return this.store.createServiceCredential(input);
58
66
  }
@@ -1,5 +1,9 @@
1
1
  import type { D1DatabaseLike } from '@treeseed/sdk/types/cloudflare';
2
2
  import type { ApiConfig, ApiCredential, ApiPrincipal, DeviceCodeApproveRequest, DeviceCodePollRequest, DeviceCodePollResponse, DeviceCodeStartRequest, DeviceCodeStartResponse, TokenRefreshRequest, TokenRefreshResponse, TrustedUserAssertionClaims, UserIdentityProfileInput } from '../types.ts';
3
+ type PrincipalRecord = {
4
+ principal: ApiPrincipal;
5
+ userId: string;
6
+ };
3
7
  export interface PersonalAccessTokenResult {
4
8
  id: string;
5
9
  token: string;
@@ -21,23 +25,34 @@ export declare class D1AuthStore {
21
25
  private first;
22
26
  private all;
23
27
  private ensureInitialized;
28
+ private ensureAuthSchema;
24
29
  private seedCatalog;
25
30
  private seedConfiguredServices;
26
31
  private loadUser;
27
32
  private loadIdentityByProvider;
33
+ private loadUserByVerifiedEmail;
28
34
  private rolesForUser;
29
35
  private permissionsForUser;
30
36
  private permissionsForRoles;
31
37
  private scopesForPrincipal;
32
38
  private principalForUser;
33
39
  private assignRole;
40
+ private replaceRoles;
34
41
  private bootstrapRolesForUser;
35
42
  private writeAuditEvent;
43
+ private userMetadata;
36
44
  syncUser(identity: UserIdentityProfileInput): Promise<{
37
45
  identityId: string;
38
46
  principal: ApiPrincipal;
39
47
  userId: string;
40
48
  }>;
49
+ createUser(input: {
50
+ email?: string | null;
51
+ username?: string | null;
52
+ displayName?: string | null;
53
+ metadata?: Record<string, unknown>;
54
+ }): Promise<PrincipalRecord>;
55
+ setUserRoles(userId: string, roles: string[]): Promise<PrincipalRecord>;
41
56
  startDeviceFlow(request: DeviceCodeStartRequest): Promise<DeviceCodeStartResponse>;
42
57
  approveDeviceFlow(request: DeviceCodeApproveRequest): Promise<{
43
58
  ok: true;
@@ -96,3 +111,4 @@ export declare class D1AuthStore {
96
111
  principal: ApiPrincipal;
97
112
  }>;
98
113
  }
114
+ export {};
@@ -1,6 +1,136 @@
1
1
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { DEFAULT_PERMISSIONS, DEFAULT_ROLES } from "./rbac.js";
3
3
  import { createAccessToken, nextOpaqueToken, principalFromAccessTokenPayload, verifyAccessToken } from "./tokens.js";
4
+ const AUTH_SCHEMA_SQL = [
5
+ `CREATE TABLE IF NOT EXISTS users (
6
+ id TEXT PRIMARY KEY,
7
+ email TEXT,
8
+ username TEXT UNIQUE,
9
+ display_name TEXT,
10
+ status TEXT NOT NULL DEFAULT 'active',
11
+ metadata_json TEXT,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL
14
+ )`,
15
+ `CREATE TABLE IF NOT EXISTS user_identities (
16
+ id TEXT PRIMARY KEY,
17
+ user_id TEXT NOT NULL,
18
+ provider TEXT NOT NULL,
19
+ provider_subject TEXT NOT NULL,
20
+ email TEXT,
21
+ email_verified INTEGER NOT NULL DEFAULT 0,
22
+ profile_json TEXT,
23
+ created_at TEXT NOT NULL,
24
+ updated_at TEXT NOT NULL,
25
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
26
+ )`,
27
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_user_identities_provider_subject
28
+ ON user_identities(provider, provider_subject)`,
29
+ `CREATE TABLE IF NOT EXISTS roles (
30
+ id TEXT PRIMARY KEY,
31
+ key TEXT NOT NULL UNIQUE,
32
+ description TEXT,
33
+ created_at TEXT NOT NULL
34
+ )`,
35
+ `CREATE TABLE IF NOT EXISTS permissions (
36
+ id TEXT PRIMARY KEY,
37
+ key TEXT NOT NULL UNIQUE,
38
+ resource TEXT NOT NULL,
39
+ action TEXT NOT NULL,
40
+ scope TEXT NOT NULL,
41
+ description TEXT,
42
+ created_at TEXT NOT NULL
43
+ )`,
44
+ `CREATE TABLE IF NOT EXISTS role_permissions (
45
+ role_id TEXT NOT NULL,
46
+ permission_id TEXT NOT NULL,
47
+ created_at TEXT NOT NULL,
48
+ PRIMARY KEY (role_id, permission_id),
49
+ FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
50
+ FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
51
+ )`,
52
+ `CREATE TABLE IF NOT EXISTS user_role_bindings (
53
+ id TEXT PRIMARY KEY,
54
+ user_id TEXT NOT NULL,
55
+ role_id TEXT NOT NULL,
56
+ created_at TEXT NOT NULL,
57
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
58
+ FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
59
+ )`,
60
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_user_role_bindings_user_role
61
+ ON user_role_bindings(user_id, role_id)`,
62
+ `CREATE TABLE IF NOT EXISTS api_tokens (
63
+ id TEXT PRIMARY KEY,
64
+ user_id TEXT NOT NULL,
65
+ kind TEXT NOT NULL,
66
+ name TEXT NOT NULL,
67
+ token_prefix TEXT NOT NULL,
68
+ token_hash TEXT NOT NULL,
69
+ scopes_json TEXT NOT NULL,
70
+ expires_at TEXT,
71
+ last_used_at TEXT,
72
+ revoked_at TEXT,
73
+ metadata_json TEXT,
74
+ created_at TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL,
76
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
77
+ )`,
78
+ `CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id
79
+ ON api_tokens(user_id)`,
80
+ `CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix
81
+ ON api_tokens(token_prefix)`,
82
+ `CREATE TABLE IF NOT EXISTS service_credentials (
83
+ id TEXT PRIMARY KEY,
84
+ service_id TEXT NOT NULL UNIQUE,
85
+ name TEXT NOT NULL,
86
+ secret_hash TEXT NOT NULL,
87
+ roles_json TEXT NOT NULL,
88
+ permissions_json TEXT NOT NULL,
89
+ revoked_at TEXT,
90
+ created_at TEXT NOT NULL,
91
+ updated_at TEXT NOT NULL,
92
+ last_used_at TEXT
93
+ )`,
94
+ `CREATE TABLE IF NOT EXISTS auth_sessions (
95
+ id TEXT PRIMARY KEY,
96
+ user_id TEXT NOT NULL,
97
+ session_type TEXT NOT NULL,
98
+ refresh_token_hash TEXT NOT NULL,
99
+ scopes_json TEXT NOT NULL,
100
+ expires_at TEXT NOT NULL,
101
+ revoked_at TEXT,
102
+ data_json TEXT,
103
+ created_at TEXT NOT NULL,
104
+ updated_at TEXT NOT NULL,
105
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
106
+ )`,
107
+ `CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id
108
+ ON auth_sessions(user_id)`,
109
+ `CREATE TABLE IF NOT EXISTS audit_events (
110
+ id TEXT PRIMARY KEY,
111
+ actor_type TEXT NOT NULL,
112
+ actor_id TEXT,
113
+ event_type TEXT NOT NULL,
114
+ target_type TEXT,
115
+ target_id TEXT,
116
+ data_json TEXT,
117
+ created_at TEXT NOT NULL
118
+ )`,
119
+ `CREATE INDEX IF NOT EXISTS idx_audit_events_target
120
+ ON audit_events(target_type, target_id)`,
121
+ `CREATE TABLE IF NOT EXISTS device_codes (
122
+ id TEXT PRIMARY KEY,
123
+ device_code TEXT NOT NULL UNIQUE,
124
+ user_code TEXT NOT NULL UNIQUE,
125
+ requested_scopes_json TEXT NOT NULL,
126
+ expires_at TEXT NOT NULL,
127
+ interval_seconds INTEGER NOT NULL,
128
+ status TEXT NOT NULL,
129
+ user_id TEXT,
130
+ created_at TEXT NOT NULL,
131
+ updated_at TEXT NOT NULL
132
+ )`
133
+ ];
4
134
  function now() {
5
135
  return /* @__PURE__ */ new Date();
6
136
  }
@@ -46,12 +176,30 @@ class D1AuthStore {
46
176
  }
47
177
  ensureInitialized() {
48
178
  if (!this.initializationPromise) {
49
- this.initializationPromise = this.seedCatalog().then(() => this.seedConfiguredServices());
179
+ this.initializationPromise = this.ensureAuthSchema().then(() => this.seedCatalog()).then(() => this.seedConfiguredServices());
50
180
  }
51
181
  return this.initializationPromise;
52
182
  }
183
+ async ensureAuthSchema() {
184
+ for (const statement of AUTH_SCHEMA_SQL) await this.run(statement);
185
+ const result = await this.db.prepare("PRAGMA table_info(users)").all();
186
+ const columns = new Set((result.results ?? []).map((row) => row.name));
187
+ if (!columns.has("username")) {
188
+ await this.run("ALTER TABLE users ADD COLUMN username TEXT");
189
+ }
190
+ await this.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)");
191
+ }
53
192
  async seedCatalog() {
54
193
  const createdAt = isoNow();
194
+ const seeded = await this.first(
195
+ `SELECT key FROM permissions WHERE key = '*:*:*' LIMIT 1`
196
+ );
197
+ const adminRole = await this.first(
198
+ `SELECT key FROM roles WHERE key = 'platform_admin' LIMIT 1`
199
+ );
200
+ if (seeded?.key && adminRole?.key) {
201
+ return;
202
+ }
55
203
  for (const permission of DEFAULT_PERMISSIONS) {
56
204
  await this.run(
57
205
  `INSERT OR IGNORE INTO permissions (id, key, resource, action, scope, description, created_at)
@@ -98,6 +246,12 @@ class D1AuthStore {
98
246
  [provider, providerSubject]
99
247
  );
100
248
  }
249
+ async loadUserByVerifiedEmail(email) {
250
+ return this.first(
251
+ `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND status = 'active' LIMIT 1`,
252
+ [email]
253
+ );
254
+ }
101
255
  async rolesForUser(userId) {
102
256
  const rows = await this.all(
103
257
  `SELECT roles.key AS key
@@ -156,7 +310,10 @@ class D1AuthStore {
156
310
  roles,
157
311
  permissions,
158
312
  scopes: this.scopesForPrincipal(permissions),
159
- metadata: parseJson(user.metadata_json, {})
313
+ metadata: {
314
+ ...parseJson(user.metadata_json, {}),
315
+ username: user.username ?? void 0
316
+ }
160
317
  }
161
318
  };
162
319
  }
@@ -169,6 +326,12 @@ class D1AuthStore {
169
326
  [randomUUID(), userId, role.id, isoNow()]
170
327
  );
171
328
  }
329
+ async replaceRoles(userId, roleKeys) {
330
+ await this.run(`DELETE FROM user_role_bindings WHERE user_id = ?`, [userId]);
331
+ for (const roleKey of roleKeys) {
332
+ await this.assignRole(userId, roleKey);
333
+ }
334
+ }
172
335
  async bootstrapRolesForUser(userId, identity) {
173
336
  await this.assignRole(userId, "member");
174
337
  if ((await this.rolesForUser(userId)).includes("platform_admin")) return;
@@ -203,25 +366,57 @@ class D1AuthStore {
203
366
  ]
204
367
  );
205
368
  }
369
+ userMetadata(identity, existingUsername = null) {
370
+ const profile = identity.profile ?? {};
371
+ return {
372
+ emailVerified: identity.emailVerified ?? false,
373
+ authProvider: identity.provider,
374
+ username: identity.username ?? existingUsername,
375
+ firstName: typeof profile.firstName === "string" ? profile.firstName : null,
376
+ lastName: typeof profile.lastName === "string" ? profile.lastName : null
377
+ };
378
+ }
206
379
  async syncUser(identity) {
207
380
  await this.ensureInitialized();
208
381
  const nowIso = isoNow();
209
382
  const existingIdentity = await this.loadIdentityByProvider(identity.provider, identity.providerSubject);
210
383
  let userId = existingIdentity?.user_id;
211
384
  if (!userId) {
212
- userId = randomUUID();
213
- await this.run(
214
- `INSERT INTO users (id, email, display_name, status, metadata_json, created_at, updated_at)
215
- VALUES (?, ?, ?, 'active', ?, ?, ?)`,
216
- [
217
- userId,
218
- identity.email ?? null,
219
- identity.displayName ?? null,
220
- JSON.stringify({ emailVerified: identity.emailVerified ?? false, authProvider: identity.provider }),
221
- nowIso,
222
- nowIso
223
- ]
224
- );
385
+ const linkedUser = identity.email && identity.emailVerified ? await this.loadUserByVerifiedEmail(identity.email) : null;
386
+ userId = linkedUser?.id ?? randomUUID();
387
+ if (linkedUser) {
388
+ await this.run(
389
+ `UPDATE users
390
+ SET email = COALESCE(?, email),
391
+ username = COALESCE(username, ?),
392
+ display_name = COALESCE(?, display_name),
393
+ metadata_json = ?,
394
+ updated_at = ?
395
+ WHERE id = ?`,
396
+ [
397
+ identity.email ?? null,
398
+ identity.username ?? null,
399
+ identity.displayName ?? null,
400
+ JSON.stringify(this.userMetadata(identity, linkedUser.username ?? null)),
401
+ nowIso,
402
+ userId
403
+ ]
404
+ );
405
+ } else {
406
+ await this.run(
407
+ `INSERT INTO users (id, email, username, display_name, status, metadata_json, created_at, updated_at)
408
+ VALUES (?, ?, ?, ?, 'active', ?, ?, ?)`,
409
+ [
410
+ userId,
411
+ identity.email ?? null,
412
+ identity.username ?? null,
413
+ identity.displayName ?? null,
414
+ JSON.stringify(this.userMetadata(identity)),
415
+ nowIso,
416
+ nowIso
417
+ ]
418
+ );
419
+ }
225
420
  await this.run(
226
421
  `INSERT INTO user_identities (id, user_id, provider, provider_subject, email, email_verified, profile_json, created_at, updated_at)
227
422
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -241,14 +436,16 @@ class D1AuthStore {
241
436
  await this.run(
242
437
  `UPDATE users
243
438
  SET email = COALESCE(?, email),
439
+ username = COALESCE(username, ?),
244
440
  display_name = COALESCE(?, display_name),
245
441
  metadata_json = ?,
246
442
  updated_at = ?
247
443
  WHERE id = ?`,
248
444
  [
249
445
  identity.email ?? null,
446
+ identity.username ?? null,
250
447
  identity.displayName ?? null,
251
- JSON.stringify({ emailVerified: identity.emailVerified ?? false, authProvider: identity.provider }),
448
+ JSON.stringify(this.userMetadata(identity)),
252
449
  nowIso,
253
450
  userId
254
451
  ]
@@ -283,6 +480,48 @@ class D1AuthStore {
283
480
  identityId: syncedIdentity?.id ?? null
284
481
  };
285
482
  }
483
+ async createUser(input) {
484
+ await this.ensureInitialized();
485
+ const timestamp = isoNow();
486
+ const userId = randomUUID();
487
+ await this.run(
488
+ `INSERT INTO users (id, email, username, display_name, status, metadata_json, created_at, updated_at)
489
+ VALUES (?, ?, ?, ?, 'active', ?, ?, ?)`,
490
+ [
491
+ userId,
492
+ input.email?.trim() || null,
493
+ input.username?.trim().toLowerCase() || null,
494
+ input.displayName?.trim() || null,
495
+ JSON.stringify(input.metadata ?? {}),
496
+ timestamp,
497
+ timestamp
498
+ ]
499
+ );
500
+ await this.assignRole(userId, "member");
501
+ await this.writeAuditEvent({
502
+ actorType: "service",
503
+ actorId: this.config.webServiceId,
504
+ eventType: "auth.user_created",
505
+ targetType: "user",
506
+ targetId: userId,
507
+ data: { source: "admin" }
508
+ });
509
+ return this.principalForUser(userId);
510
+ }
511
+ async setUserRoles(userId, roles) {
512
+ await this.ensureInitialized();
513
+ const requestedRoles = [...new Set(roles.map((role) => role.trim()).filter(Boolean))];
514
+ await this.replaceRoles(userId, requestedRoles.length > 0 ? requestedRoles : ["member"]);
515
+ await this.writeAuditEvent({
516
+ actorType: "service",
517
+ actorId: this.config.webServiceId,
518
+ eventType: "auth.user_roles_set",
519
+ targetType: "user",
520
+ targetId: userId,
521
+ data: { roles: requestedRoles }
522
+ });
523
+ return this.principalForUser(userId);
524
+ }
286
525
  async startDeviceFlow(request) {
287
526
  await this.ensureInitialized();
288
527
  const current = now();
@@ -42,7 +42,11 @@ export declare class MemoryDeviceCodeAuthProvider implements ApiAuthProvider {
42
42
  scopes: string[];
43
43
  roles: string[];
44
44
  permissions: string[];
45
- metadata: Record<string, unknown>;
45
+ metadata: {
46
+ username: string;
47
+ firstName: string;
48
+ lastName: string;
49
+ };
46
50
  };
47
51
  }>;
48
52
  createServiceToken(_input: {
@@ -179,7 +179,12 @@ class MemoryDeviceCodeAuthProvider {
179
179
  scopes: ["auth:me"],
180
180
  roles: ["member"],
181
181
  permissions: ["auth:read:self"],
182
- metadata: identity.profile
182
+ metadata: {
183
+ ...identity.profile ?? {},
184
+ username: identity.username ?? void 0,
185
+ firstName: typeof identity.profile?.firstName === "string" ? identity.profile.firstName : void 0,
186
+ lastName: typeof identity.profile?.lastName === "string" ? identity.profile.lastName : void 0
187
+ }
183
188
  }
184
189
  };
185
190
  }
@@ -52,6 +52,7 @@ const permissionDefinitions = [
52
52
  contentPermission("services", "impersonate", "global", "Allow trusted web and service impersonation flows."),
53
53
  contentPermission("services", "manage", "global", "Manage service credentials and internal service auth."),
54
54
  contentPermission("users", "read", "global", "Read user records."),
55
+ contentPermission("users", "manage", "global", "Manage user records."),
55
56
  contentPermission("roles", "manage", "global", "Manage role assignments."),
56
57
  contentPermission("audit", "read", "global", "Read audit events."),
57
58
  contentPermission("jobs", "manage", "global", "Manage internal job and worker control surfaces."),
@@ -1,9 +1,16 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { resolve } from "node:path";
2
3
  function parseInteger(value, fallback) {
3
4
  if (!value) return fallback;
4
5
  const parsed = Number.parseInt(value, 10);
5
6
  return Number.isFinite(parsed) ? parsed : fallback;
6
7
  }
8
+ function resolveLocalWranglerConfigPath(repoRoot, env) {
9
+ const explicit = env.TREESEED_API_D1_WRANGLER_CONFIG?.trim() || env.TREESEED_LOCAL_WRANGLER_CONFIG?.trim();
10
+ if (explicit) return resolve(repoRoot, explicit);
11
+ const generated = resolve(repoRoot, ".treeseed", "generated", "environments", "local", "wrangler.toml");
12
+ return existsSync(generated) ? generated : void 0;
13
+ }
7
14
  function normalizeUrl(value) {
8
15
  return value.endsWith("/") ? value.slice(0, -1) : value;
9
16
  }
@@ -42,6 +49,7 @@ function resolveApiConfig(env = process.env) {
42
49
  d1DatabaseId: env.TREESEED_API_D1_DATABASE_ID?.trim() || void 0,
43
50
  d1DatabaseName: env.TREESEED_API_D1_DATABASE_NAME?.trim() || env.SITE_DATA_DB?.trim() || void 0,
44
51
  d1LocalPersistTo: env.TREESEED_API_D1_LOCAL_PERSIST_TO?.trim() || resolve(repoRoot, ".wrangler/state/v3/d1"),
52
+ d1WranglerConfigPath: resolveLocalWranglerConfigPath(repoRoot, env),
45
53
  webServiceId: env.TREESEED_API_WEB_SERVICE_ID?.trim() || "web",
46
54
  webServiceSecret: env.TREESEED_API_WEB_SERVICE_SECRET?.trim() || "treeseed-web-service-dev-secret",
47
55
  webAssertionSecret: env.TREESEED_API_WEB_ASSERTION_SECRET?.trim() || env.TREESEED_API_AUTH_SECRET?.trim() || "treeseed-web-assertion-dev-secret",
@@ -1,5 +1,6 @@
1
1
  import { MemoryDeviceCodeAuthProvider } from "./auth/memory-provider.js";
2
2
  import { D1AuthProvider } from "./auth/d1-provider.js";
3
+ import { resolveApiD1Database } from "./auth/d1-database.js";
3
4
  function addProviders(target, incoming, label) {
4
5
  for (const [id, value] of Object.entries(incoming ?? {})) {
5
6
  if (target.has(id)) {
@@ -24,7 +25,7 @@ function resolveApiRuntimeProviders(config, overrides = {}) {
24
25
  const agentVerification = /* @__PURE__ */ new Map();
25
26
  addProviders(authRegistry, {
26
27
  memory: ({ config: runtimeConfig }) => new MemoryDeviceCodeAuthProvider(runtimeConfig),
27
- d1: ({ config: runtimeConfig }) => new D1AuthProvider(runtimeConfig)
28
+ d1: ({ config: runtimeConfig }) => new D1AuthProvider(runtimeConfig, { db: resolveApiD1Database(runtimeConfig) })
28
29
  }, "auth");
29
30
  addProviders(authRegistry, overrides.auth, "auth");
30
31
  addProviders(agentExecution, { stub: { id: "stub" } }, "agent execution");
@@ -32,6 +32,7 @@ export declare function createRailwayTreeseedApiServer(options?: ApiServerOption
32
32
  d1DatabaseId?: string;
33
33
  d1DatabaseName?: string;
34
34
  d1LocalPersistTo?: string;
35
+ d1WranglerConfigPath?: string;
35
36
  webServiceId: string;
36
37
  webServiceSecret: string;
37
38
  webAssertionSecret: string;
@@ -45,6 +45,19 @@ export interface ApiAuthProvider {
45
45
  userId: string;
46
46
  identityId: string | null;
47
47
  }>;
48
+ createUser?(input: {
49
+ email?: string | null;
50
+ username?: string | null;
51
+ displayName?: string | null;
52
+ metadata?: Record<string, unknown>;
53
+ }): Promise<{
54
+ principal: ApiPrincipal;
55
+ userId: string;
56
+ }>;
57
+ setUserRoles?(userId: string, roles: string[]): Promise<{
58
+ principal: ApiPrincipal;
59
+ userId: string;
60
+ }>;
48
61
  createServiceToken(input: {
49
62
  serviceId: string;
50
63
  name: string;
@@ -98,6 +111,7 @@ export interface ApiConfig {
98
111
  d1DatabaseId?: string;
99
112
  d1DatabaseName?: string;
100
113
  d1LocalPersistTo?: string;
114
+ d1WranglerConfigPath?: string;
101
115
  webServiceId: string;
102
116
  webServiceSecret: string;
103
117
  webAssertionSecret: string;
@@ -142,6 +156,7 @@ export interface UserIdentityProfileInput {
142
156
  providerSubject: string;
143
157
  email?: string | null;
144
158
  emailVerified?: boolean;
159
+ username?: string | null;
145
160
  displayName?: string | null;
146
161
  profile?: Record<string, unknown>;
147
162
  }