@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 +29 -0
- package/dist/api/auth/d1-database.js +4 -8
- package/dist/api/auth/d1-provider.d.ts +12 -0
- package/dist/api/auth/d1-provider.js +10 -2
- package/dist/api/auth/d1-store.d.ts +16 -0
- package/dist/api/auth/d1-store.js +255 -16
- package/dist/api/auth/memory-provider.d.ts +5 -1
- package/dist/api/auth/memory-provider.js +6 -1
- package/dist/api/auth/rbac.js +1 -0
- package/dist/api/config.js +8 -0
- package/dist/api/providers.js +2 -1
- package/dist/api/railway.d.ts +1 -0
- package/dist/api/types.d.ts +15 -0
- package/dist/dev.d.ts +42 -1
- package/dist/dev.js +392 -27
- package/dist/env.yaml +1 -0
- package/dist/pages/api/form/submit.js +5 -7
- package/dist/scripts/dev-platform.js +1 -0
- package/dist/site.js +1 -0
- package/dist/utils/forms/service.js +2 -2
- package/dist/worker/forms-worker.js +1 -2
- package/package.json +14 -3
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
}
|
package/dist/api/auth/rbac.js
CHANGED
|
@@ -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."),
|
package/dist/api/config.js
CHANGED
|
@@ -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",
|
package/dist/api/providers.js
CHANGED
|
@@ -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");
|
package/dist/api/railway.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/types.d.ts
CHANGED
|
@@ -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
|
}
|