@edge-base/server 0.1.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.
- package/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,3501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin Dashboard routes — M12
|
|
3
|
+
*
|
|
4
|
+
* Two sections:
|
|
5
|
+
* 1. Auth routes (no JWT required): setup status, setup, login, refresh
|
|
6
|
+
* 2. Internal route (Service Key required): reset-password
|
|
7
|
+
* 3. Admin API routes (Admin JWT required): tables, users, storage, schema, logs, monitoring
|
|
8
|
+
*
|
|
9
|
+
* Admin accounts are managed via D1 Control Plane.
|
|
10
|
+
*/
|
|
11
|
+
import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
|
|
12
|
+
import type { Env } from '../types.js';
|
|
13
|
+
import { EdgeBaseError, getDbAccess, getTableAccess } from '@edge-base/shared';
|
|
14
|
+
import type { AuthContext } from '@edge-base/shared';
|
|
15
|
+
import {
|
|
16
|
+
signAdminAccessToken,
|
|
17
|
+
signAdminRefreshToken,
|
|
18
|
+
TokenExpiredError,
|
|
19
|
+
TokenInvalidError,
|
|
20
|
+
verifyAdminRefreshTokenWithFallback,
|
|
21
|
+
verifyAdminTokenWithFallback,
|
|
22
|
+
} from '../lib/jwt.js';
|
|
23
|
+
import { hashPassword, verifyPassword } from '../lib/password.js';
|
|
24
|
+
import { generateId } from '../lib/uuid.js';
|
|
25
|
+
import { validateKey, buildConstraintCtx, extractBearerToken, resolveServiceKeyCandidate } from '../lib/service-key.js';
|
|
26
|
+
import { parseConfig, getDbDoName, getD1BindingName, shouldRouteToD1 } from '../lib/do-router.js';
|
|
27
|
+
import { handleD1Request, d1BatchImport } from '../lib/d1-handler.js';
|
|
28
|
+
import { fetchDOWithRetry } from '../lib/do-retry.js';
|
|
29
|
+
import { dumpNamespaceTables } from '../lib/namespace-dump.js';
|
|
30
|
+
import { ensureD1Schema } from '../lib/d1-schema-init.js';
|
|
31
|
+
import { QUERY_PARAM_KEYS } from '../lib/query-engine.js';
|
|
32
|
+
import { parsePagination } from '../lib/pagination.js';
|
|
33
|
+
import { handlePgRequest } from '../lib/postgres-handler.js';
|
|
34
|
+
import { ensurePgSchema } from '../lib/postgres-schema-init.js';
|
|
35
|
+
import {
|
|
36
|
+
ensureLocalDevPostgresSchema,
|
|
37
|
+
getLocalDevPostgresExecOptions,
|
|
38
|
+
getProviderBindingName,
|
|
39
|
+
withPostgresConnection,
|
|
40
|
+
} from '../lib/postgres-executor.js';
|
|
41
|
+
import {
|
|
42
|
+
zodDefaultHook,
|
|
43
|
+
jsonResponseSchema,
|
|
44
|
+
errorResponseSchema,
|
|
45
|
+
} from '../lib/schemas.js';
|
|
46
|
+
import {
|
|
47
|
+
ensureAuthSchema,
|
|
48
|
+
adminExists,
|
|
49
|
+
createAdmin,
|
|
50
|
+
getAdminByEmail,
|
|
51
|
+
getAdminById,
|
|
52
|
+
getAdminSession,
|
|
53
|
+
createAdminSession,
|
|
54
|
+
deleteAdminSession,
|
|
55
|
+
listAdmins,
|
|
56
|
+
deleteAdmin,
|
|
57
|
+
updateAdminPassword,
|
|
58
|
+
listUserMappings,
|
|
59
|
+
searchUserMappingsByEmail,
|
|
60
|
+
countUsers,
|
|
61
|
+
deleteAnon,
|
|
62
|
+
} from '../lib/auth-d1.js';
|
|
63
|
+
import * as authService from '../lib/auth-d1-service.js';
|
|
64
|
+
import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
|
|
65
|
+
import { getPublicProfileWithCache } from './users.js';
|
|
66
|
+
import { createSignedToken, parseDuration } from './storage.js';
|
|
67
|
+
import { getDevicesForUser, getPushLogs } from '../lib/push-token.js';
|
|
68
|
+
import { RATE_LIMIT_DEFAULTS } from '../middleware/rate-limit.js';
|
|
69
|
+
import {
|
|
70
|
+
createManagedAdminUser,
|
|
71
|
+
deleteManagedAdminUser,
|
|
72
|
+
normalizeAdminUserUpdates,
|
|
73
|
+
updateManagedAdminUser,
|
|
74
|
+
} from '../lib/admin-user-management.js';
|
|
75
|
+
import { DATABASE_LIVE_HUB_DO_NAME } from '../lib/database-live-emitter.js';
|
|
76
|
+
import { fetchRoomMonitoringStatsFromKv } from '../lib/room-monitoring.js';
|
|
77
|
+
import {
|
|
78
|
+
executeAdminDbQuery,
|
|
79
|
+
resolveAdminInstanceOptions,
|
|
80
|
+
serializeAdminInstanceDiscovery,
|
|
81
|
+
} from '../lib/admin-db-target.js';
|
|
82
|
+
|
|
83
|
+
const BUILT_IN_RATE_LIMIT_GROUPS = [
|
|
84
|
+
'global',
|
|
85
|
+
'db',
|
|
86
|
+
'storage',
|
|
87
|
+
'functions',
|
|
88
|
+
'auth',
|
|
89
|
+
'authSignin',
|
|
90
|
+
'authSignup',
|
|
91
|
+
'events',
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
const AUTH_BACKUP_TABLES = [
|
|
95
|
+
'_email_index',
|
|
96
|
+
'_oauth_index',
|
|
97
|
+
'_anon_index',
|
|
98
|
+
'_phone_index',
|
|
99
|
+
'_passkey_index',
|
|
100
|
+
'_admins',
|
|
101
|
+
'_admin_sessions',
|
|
102
|
+
'_users_public',
|
|
103
|
+
'_meta',
|
|
104
|
+
'_users',
|
|
105
|
+
'_sessions',
|
|
106
|
+
'_oauth_accounts',
|
|
107
|
+
'_email_tokens',
|
|
108
|
+
'_mfa_factors',
|
|
109
|
+
'_mfa_recovery_codes',
|
|
110
|
+
'_webauthn_credentials',
|
|
111
|
+
] as const;
|
|
112
|
+
|
|
113
|
+
const AUTH_BACKUP_TABLE_SET = new Set<string>(AUTH_BACKUP_TABLES);
|
|
114
|
+
|
|
115
|
+
function quoteSqlIdentifier(identifier: string): string {
|
|
116
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
117
|
+
throw new EdgeBaseError(400, `Invalid SQL identifier: ${identifier}`, undefined, 'validation-failed');
|
|
118
|
+
}
|
|
119
|
+
return `"${identifier}"`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface MonitoringStats {
|
|
123
|
+
subsystem?: string;
|
|
124
|
+
activeConnections: number;
|
|
125
|
+
authenticatedConnections?: number;
|
|
126
|
+
channels: number;
|
|
127
|
+
channelDetails?: Array<{ channel: string; subscribers: number }>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function emptyMonitoringStats(): MonitoringStats {
|
|
131
|
+
return {
|
|
132
|
+
activeConnections: 0,
|
|
133
|
+
authenticatedConnections: 0,
|
|
134
|
+
channels: 0,
|
|
135
|
+
channelDetails: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fetchMonitoringStatsFromNamespace(
|
|
140
|
+
namespace: DurableObjectNamespace | undefined,
|
|
141
|
+
hubName: string,
|
|
142
|
+
): Promise<MonitoringStats> {
|
|
143
|
+
if (!namespace) return emptyMonitoringStats();
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const stub = namespace.get(namespace.idFromName(hubName));
|
|
147
|
+
const resp = await stub.fetch(new Request('http://internal/internal/stats', {
|
|
148
|
+
headers: { 'X-DO-Name': hubName },
|
|
149
|
+
}));
|
|
150
|
+
if (!resp.ok) return emptyMonitoringStats();
|
|
151
|
+
const stats = await resp.json() as MonitoringStats;
|
|
152
|
+
return {
|
|
153
|
+
...emptyMonitoringStats(),
|
|
154
|
+
...stats,
|
|
155
|
+
channelDetails: Array.isArray(stats.channelDetails) ? stats.channelDetails : [],
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
return emptyMonitoringStats();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function fetchUnifiedMonitoringStats(env: Env): Promise<MonitoringStats & {
|
|
163
|
+
databaseLive: MonitoringStats;
|
|
164
|
+
rooms: MonitoringStats;
|
|
165
|
+
}> {
|
|
166
|
+
const [databaseLive, rooms] = await Promise.all([
|
|
167
|
+
fetchMonitoringStatsFromNamespace(env.DATABASE_LIVE, DATABASE_LIVE_HUB_DO_NAME),
|
|
168
|
+
fetchRoomMonitoringStatsFromKv(env.KV),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const channelDetails = [
|
|
172
|
+
...(databaseLive.channelDetails ?? []),
|
|
173
|
+
...(rooms.channelDetails ?? []),
|
|
174
|
+
].sort((a, b) => b.subscribers - a.subscribers);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
activeConnections: databaseLive.activeConnections + rooms.activeConnections,
|
|
178
|
+
authenticatedConnections:
|
|
179
|
+
(databaseLive.authenticatedConnections ?? 0) + (rooms.authenticatedConnections ?? 0),
|
|
180
|
+
channels: new Set(channelDetails.map((detail) => detail.channel)).size,
|
|
181
|
+
channelDetails,
|
|
182
|
+
databaseLive,
|
|
183
|
+
rooms,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function fetchRecentLogsFromDo(
|
|
188
|
+
env: Env,
|
|
189
|
+
options: {
|
|
190
|
+
limit: number;
|
|
191
|
+
level?: string;
|
|
192
|
+
pathFilter?: string;
|
|
193
|
+
category?: string;
|
|
194
|
+
},
|
|
195
|
+
): Promise<Array<Record<string, unknown>> | null> {
|
|
196
|
+
if (!env.LOGS) return null;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const logsId = env.LOGS.idFromName('logs:main');
|
|
200
|
+
const logsDO = env.LOGS.get(logsId);
|
|
201
|
+
const params = new URLSearchParams({
|
|
202
|
+
limit: String(Math.max(1, Math.min(options.limit, 200))),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (options.level) params.set('level', options.level);
|
|
206
|
+
if (options.pathFilter) params.set('path', options.pathFilter);
|
|
207
|
+
if (options.category) params.set('category', options.category);
|
|
208
|
+
|
|
209
|
+
const resp = await logsDO.fetch(
|
|
210
|
+
new Request(`http://internal/internal/logs/recent?${params.toString()}`),
|
|
211
|
+
);
|
|
212
|
+
if (!resp.ok) return [];
|
|
213
|
+
|
|
214
|
+
const data = await resp.json<{ logs?: Array<Record<string, unknown>> }>();
|
|
215
|
+
return data.logs ?? [];
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getLogStatusCode(log: Record<string, unknown>): number {
|
|
222
|
+
const status = log.status;
|
|
223
|
+
if (typeof status === 'number') return status;
|
|
224
|
+
if (typeof status === 'string') {
|
|
225
|
+
const parsed = Number.parseInt(status, 10);
|
|
226
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
227
|
+
}
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function matchesLogLevel(status: number, level: string): boolean {
|
|
232
|
+
const normalized = level.toLowerCase();
|
|
233
|
+
if (normalized === 'error') return status >= 500;
|
|
234
|
+
if (normalized === 'warn') return status >= 300 && status < 500;
|
|
235
|
+
if (normalized === 'info') return status >= 200 && status < 300;
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const DEFAULT_RATE_LIMIT_BINDING = {
|
|
240
|
+
limit: 10_000_000,
|
|
241
|
+
period: 60 as const,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
function normalizeOptionalRole(role: unknown): string | undefined {
|
|
245
|
+
if (role === undefined) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
if (typeof role !== 'string') {
|
|
249
|
+
throw new EdgeBaseError(400, 'Role must be a non-empty string.', undefined, 'validation-failed');
|
|
250
|
+
}
|
|
251
|
+
const normalized = role.trim();
|
|
252
|
+
if (!normalized) {
|
|
253
|
+
throw new EdgeBaseError(400, 'Role must be a non-empty string.', undefined, 'validation-failed');
|
|
254
|
+
}
|
|
255
|
+
if (normalized.length > 100) {
|
|
256
|
+
throw new EdgeBaseError(400, 'Role must not exceed 100 characters.', undefined, 'validation-failed');
|
|
257
|
+
}
|
|
258
|
+
return normalized;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildRateLimitSummary(config: ReturnType<typeof parseConfig>) {
|
|
262
|
+
const configured = config.rateLimiting ?? {};
|
|
263
|
+
const entries: Array<{
|
|
264
|
+
group: string;
|
|
265
|
+
requests: number;
|
|
266
|
+
window: string;
|
|
267
|
+
binding: {
|
|
268
|
+
enabled: boolean;
|
|
269
|
+
limit?: number;
|
|
270
|
+
period?: number;
|
|
271
|
+
source: 'default' | 'override' | 'disabled' | 'custom';
|
|
272
|
+
} | null;
|
|
273
|
+
}> = [];
|
|
274
|
+
const seen = new Set<string>();
|
|
275
|
+
const formatWindow = (window: string | number | undefined, fallbackSec: number) => {
|
|
276
|
+
if (typeof window === 'number') return `${window}s`;
|
|
277
|
+
return window ?? `${fallbackSec}s`;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
for (const group of BUILT_IN_RATE_LIMIT_GROUPS) {
|
|
281
|
+
const groupConfig = configured[group];
|
|
282
|
+
const fallback = RATE_LIMIT_DEFAULTS[group] ?? RATE_LIMIT_DEFAULTS.global;
|
|
283
|
+
const bindingConfig = groupConfig?.binding;
|
|
284
|
+
|
|
285
|
+
entries.push({
|
|
286
|
+
group,
|
|
287
|
+
requests: groupConfig?.requests ?? fallback.requests,
|
|
288
|
+
window: formatWindow(groupConfig?.window, fallback.windowSec),
|
|
289
|
+
binding: bindingConfig?.enabled === false
|
|
290
|
+
? { enabled: false, source: 'disabled' }
|
|
291
|
+
: {
|
|
292
|
+
enabled: true,
|
|
293
|
+
limit: bindingConfig?.limit ?? DEFAULT_RATE_LIMIT_BINDING.limit,
|
|
294
|
+
period: bindingConfig?.period ?? DEFAULT_RATE_LIMIT_BINDING.period,
|
|
295
|
+
source: bindingConfig ? 'override' : 'default',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
seen.add(group);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const [group, groupConfig] of Object.entries(configured)) {
|
|
302
|
+
if (seen.has(group) || !groupConfig) continue;
|
|
303
|
+
const bindingConfig = groupConfig.binding;
|
|
304
|
+
|
|
305
|
+
entries.push({
|
|
306
|
+
group,
|
|
307
|
+
requests: groupConfig.requests,
|
|
308
|
+
window: formatWindow(groupConfig.window, RATE_LIMIT_DEFAULTS.global.windowSec),
|
|
309
|
+
binding: bindingConfig
|
|
310
|
+
? (
|
|
311
|
+
bindingConfig.enabled === false
|
|
312
|
+
? { enabled: false, source: 'disabled' as const }
|
|
313
|
+
: {
|
|
314
|
+
enabled: true,
|
|
315
|
+
limit: bindingConfig.limit,
|
|
316
|
+
period: bindingConfig.period ?? DEFAULT_RATE_LIMIT_BINDING.period,
|
|
317
|
+
source: 'custom' as const,
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
: null,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return entries;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
/** Resolve AuthDb from Hono context. Defaults to D1 (AUTH_DB binding). */
|
|
329
|
+
function getAuthDb(c: { env: Env }): AuthDb {
|
|
330
|
+
return resolveAuthDb(c.env as unknown as Record<string, unknown>);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export const adminRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
|
|
334
|
+
|
|
335
|
+
// Error handler
|
|
336
|
+
adminRoute.onError((err, c) => {
|
|
337
|
+
if (err instanceof EdgeBaseError) {
|
|
338
|
+
return c.json(err.toJSON(), err.code as 400);
|
|
339
|
+
}
|
|
340
|
+
console.error('Admin Dashboard unhandled error:', err);
|
|
341
|
+
return c.json({ code: 500, message: 'Internal server error.' }, 500);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ─────────────────────────────────────────────
|
|
345
|
+
// 1. Auth Routes — No JWT required
|
|
346
|
+
// Admin accounts stored in D1
|
|
347
|
+
// ─────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
// GET /admin/api/setup/status — check if admin setup is needed
|
|
350
|
+
const adminSetupStatus = createRoute({
|
|
351
|
+
operationId: 'adminSetupStatus',
|
|
352
|
+
method: 'get',
|
|
353
|
+
path: '/setup/status',
|
|
354
|
+
tags: ['admin'],
|
|
355
|
+
summary: 'Check if admin setup is needed',
|
|
356
|
+
responses: {
|
|
357
|
+
200: { description: 'Setup status', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
adminRoute.openapi(adminSetupStatus, async (c) => {
|
|
362
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
363
|
+
const exists = await adminExists(getAuthDb(c));
|
|
364
|
+
return c.json({ needsSetup: !exists });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// POST /admin/api/setup — create the first admin account
|
|
368
|
+
const adminSetup = createRoute({
|
|
369
|
+
operationId: 'adminSetup',
|
|
370
|
+
method: 'post',
|
|
371
|
+
path: '/setup',
|
|
372
|
+
tags: ['admin'],
|
|
373
|
+
summary: 'Create the first admin account',
|
|
374
|
+
request: {
|
|
375
|
+
body: {
|
|
376
|
+
content: {
|
|
377
|
+
'application/json': {
|
|
378
|
+
schema: z.object({
|
|
379
|
+
email: z.string(),
|
|
380
|
+
password: z.string(),
|
|
381
|
+
}).passthrough(),
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
required: true,
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
responses: {
|
|
388
|
+
201: { description: 'Admin created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
389
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
adminRoute.openapi(adminSetup, async (c) => {
|
|
394
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
395
|
+
const exists = await adminExists(getAuthDb(c));
|
|
396
|
+
if (exists) throw new EdgeBaseError(400, 'Admin account already exists. Use login instead.', undefined, 'already-exists');
|
|
397
|
+
|
|
398
|
+
const body = await c.req.json<{ email: string; password: string }>();
|
|
399
|
+
if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
|
|
400
|
+
body.email = body.email.trim().toLowerCase();
|
|
401
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
402
|
+
if (body.password.length < 8) throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
|
|
403
|
+
if (body.password.length > 256) throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
|
|
404
|
+
|
|
405
|
+
const adminSecret = c.env.JWT_ADMIN_SECRET;
|
|
406
|
+
if (!adminSecret) throw new EdgeBaseError(500, 'JWT_ADMIN_SECRET not configured.', undefined, 'internal-error');
|
|
407
|
+
|
|
408
|
+
const adminId = generateId();
|
|
409
|
+
const passwordHash = await hashPassword(body.password);
|
|
410
|
+
await createAdmin(getAuthDb(c), adminId, body.email, passwordHash);
|
|
411
|
+
|
|
412
|
+
const accessToken = await signAdminAccessToken({ sub: adminId }, adminSecret, '1h');
|
|
413
|
+
const refreshToken = await signAdminRefreshToken({ sub: adminId }, adminSecret, '28d');
|
|
414
|
+
|
|
415
|
+
const sessionId = generateId();
|
|
416
|
+
const expiresAt = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000).toISOString();
|
|
417
|
+
await createAdminSession(getAuthDb(c), sessionId, adminId, refreshToken, expiresAt);
|
|
418
|
+
|
|
419
|
+
return c.json({
|
|
420
|
+
accessToken,
|
|
421
|
+
refreshToken,
|
|
422
|
+
admin: { id: adminId, email: body.email },
|
|
423
|
+
}, 201);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// POST /admin/api/auth/login — admin login
|
|
427
|
+
const adminLogin = createRoute({
|
|
428
|
+
operationId: 'adminLogin',
|
|
429
|
+
method: 'post',
|
|
430
|
+
path: '/auth/login',
|
|
431
|
+
tags: ['admin'],
|
|
432
|
+
summary: 'Admin login',
|
|
433
|
+
request: {
|
|
434
|
+
body: {
|
|
435
|
+
content: {
|
|
436
|
+
'application/json': {
|
|
437
|
+
schema: z.object({
|
|
438
|
+
email: z.string(),
|
|
439
|
+
password: z.string(),
|
|
440
|
+
}).passthrough(),
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
required: true,
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
responses: {
|
|
447
|
+
200: { description: 'Login successful', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
448
|
+
401: { description: 'Invalid credentials', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
adminRoute.openapi(adminLogin, async (c) => {
|
|
453
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
454
|
+
const body = await c.req.json<{ email: string; password: string }>();
|
|
455
|
+
if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
|
|
456
|
+
body.email = body.email.trim().toLowerCase();
|
|
457
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
458
|
+
|
|
459
|
+
const admin = await getAdminByEmail(getAuthDb(c), body.email);
|
|
460
|
+
if (!admin) throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
|
|
461
|
+
|
|
462
|
+
const valid = await verifyPassword(body.password, admin.passwordHash);
|
|
463
|
+
if (!valid) throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
|
|
464
|
+
|
|
465
|
+
const adminSecret = c.env.JWT_ADMIN_SECRET;
|
|
466
|
+
if (!adminSecret) throw new EdgeBaseError(500, 'JWT_ADMIN_SECRET not configured.', undefined, 'internal-error');
|
|
467
|
+
|
|
468
|
+
const accessToken = await signAdminAccessToken({ sub: admin.id }, adminSecret, '1h');
|
|
469
|
+
const refreshToken = await signAdminRefreshToken({ sub: admin.id }, adminSecret, '28d');
|
|
470
|
+
|
|
471
|
+
const sessionId = generateId();
|
|
472
|
+
const expiresAt = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000).toISOString();
|
|
473
|
+
await createAdminSession(getAuthDb(c), sessionId, admin.id, refreshToken, expiresAt);
|
|
474
|
+
|
|
475
|
+
return c.json({
|
|
476
|
+
accessToken,
|
|
477
|
+
refreshToken,
|
|
478
|
+
admin: { id: admin.id, email: admin.email },
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// POST /admin/api/auth/refresh — rotate admin token
|
|
483
|
+
const adminRefresh = createRoute({
|
|
484
|
+
operationId: 'adminRefresh',
|
|
485
|
+
method: 'post',
|
|
486
|
+
path: '/auth/refresh',
|
|
487
|
+
tags: ['admin'],
|
|
488
|
+
summary: 'Rotate admin token',
|
|
489
|
+
request: {
|
|
490
|
+
body: {
|
|
491
|
+
content: {
|
|
492
|
+
'application/json': {
|
|
493
|
+
schema: z.object({
|
|
494
|
+
refreshToken: z.string(),
|
|
495
|
+
}).passthrough(),
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
required: true,
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
responses: {
|
|
502
|
+
200: { description: 'Token rotated', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
503
|
+
401: { description: 'Invalid token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
adminRoute.openapi(adminRefresh, async (c) => {
|
|
508
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
509
|
+
const body = await c.req.json<{ refreshToken: string }>();
|
|
510
|
+
if (!body.refreshToken) throw new EdgeBaseError(400, 'Refresh token is required.', undefined, 'validation-failed');
|
|
511
|
+
|
|
512
|
+
const adminSecret = c.env.JWT_ADMIN_SECRET;
|
|
513
|
+
if (!adminSecret) throw new EdgeBaseError(500, 'JWT_ADMIN_SECRET not configured.', undefined, 'internal-error');
|
|
514
|
+
|
|
515
|
+
let tokenPayload: { sub: string };
|
|
516
|
+
try {
|
|
517
|
+
tokenPayload = await verifyAdminRefreshTokenWithFallback(
|
|
518
|
+
body.refreshToken,
|
|
519
|
+
adminSecret,
|
|
520
|
+
c.env.JWT_ADMIN_SECRET_OLD,
|
|
521
|
+
c.env.JWT_ADMIN_SECRET_OLD_AT,
|
|
522
|
+
) as { sub: string };
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (err instanceof TokenExpiredError || err instanceof TokenInvalidError) {
|
|
525
|
+
throw new EdgeBaseError(401, 'Invalid or expired refresh token.', undefined, 'invalid-refresh-token');
|
|
526
|
+
}
|
|
527
|
+
throw err;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const session = await getAdminSession(getAuthDb(c), body.refreshToken);
|
|
531
|
+
if (!session) throw new EdgeBaseError(401, 'Invalid or expired refresh token.', undefined, 'invalid-refresh-token');
|
|
532
|
+
if (session.adminId !== tokenPayload.sub) {
|
|
533
|
+
throw new EdgeBaseError(401, 'Invalid or expired refresh token.', undefined, 'invalid-refresh-token');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const admin = await getAdminById(getAuthDb(c), session.adminId);
|
|
537
|
+
if (!admin) throw new EdgeBaseError(401, 'Admin not found.', undefined, 'user-not-found');
|
|
538
|
+
|
|
539
|
+
// Rotate: delete old session, create new
|
|
540
|
+
await deleteAdminSession(getAuthDb(c), session.id);
|
|
541
|
+
|
|
542
|
+
const newAccessToken = await signAdminAccessToken({ sub: admin.id }, adminSecret, '1h');
|
|
543
|
+
const newRefreshToken = await signAdminRefreshToken({ sub: admin.id }, adminSecret, '28d');
|
|
544
|
+
|
|
545
|
+
const newSessionId = generateId();
|
|
546
|
+
const expiresAt = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000).toISOString();
|
|
547
|
+
await createAdminSession(getAuthDb(c), newSessionId, admin.id, newRefreshToken, expiresAt);
|
|
548
|
+
|
|
549
|
+
return c.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ─────────────────────────────────────────────
|
|
553
|
+
// 2. Internal Route — Service Key required
|
|
554
|
+
// ─────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
// POST /admin/api/internal/reset-password — CLI reset-password endpoint
|
|
557
|
+
const adminResetPassword = createRoute({
|
|
558
|
+
operationId: 'adminResetPassword',
|
|
559
|
+
method: 'post',
|
|
560
|
+
path: '/internal/reset-password',
|
|
561
|
+
tags: ['admin'],
|
|
562
|
+
summary: 'Reset admin password (Service Key required)',
|
|
563
|
+
request: {
|
|
564
|
+
body: {
|
|
565
|
+
content: {
|
|
566
|
+
'application/json': {
|
|
567
|
+
schema: z.object({
|
|
568
|
+
email: z.string(),
|
|
569
|
+
newPassword: z.string(),
|
|
570
|
+
}).passthrough(),
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
required: true,
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
responses: {
|
|
577
|
+
200: { description: 'Password reset', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
578
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
579
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
580
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
adminRoute.openapi(adminResetPassword, async (c) => {
|
|
585
|
+
const config = parseConfig(c.env);
|
|
586
|
+
const provided = resolveServiceKeyCandidate(c.req);
|
|
587
|
+
const { result } = validateKey(provided, 'auth:admin:*:*', config, c.env, undefined, buildConstraintCtx(c.env, c.req));
|
|
588
|
+
if (result === 'missing') {
|
|
589
|
+
throw new EdgeBaseError(403, 'Service Key required for admin operations.', undefined, 'forbidden');
|
|
590
|
+
}
|
|
591
|
+
if (result === 'invalid') {
|
|
592
|
+
throw new EdgeBaseError(401, 'Invalid or missing Service Key.', undefined, 'unauthenticated');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
596
|
+
const body = await c.req.json<{ email: string; newPassword: string }>();
|
|
597
|
+
if (!body.email || !body.newPassword) throw new EdgeBaseError(400, 'Email and newPassword are required.', undefined, 'validation-failed');
|
|
598
|
+
body.email = body.email.trim().toLowerCase();
|
|
599
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
600
|
+
if (body.newPassword.length < 8) throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
|
|
601
|
+
if (body.newPassword.length > 256) throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
|
|
602
|
+
|
|
603
|
+
const admin = await getAdminByEmail(getAuthDb(c), body.email);
|
|
604
|
+
if (!admin) throw new EdgeBaseError(404, 'Admin not found.', undefined, 'user-not-found');
|
|
605
|
+
|
|
606
|
+
const newHash = await hashPassword(body.newPassword);
|
|
607
|
+
await updateAdminPassword(getAuthDb(c), admin.id, newHash);
|
|
608
|
+
|
|
609
|
+
return c.json({ ok: true, message: 'Admin password reset successfully.' });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// ─────────────────────────────────────────────
|
|
613
|
+
// 3. Admin API Routes — Admin JWT required
|
|
614
|
+
// ─────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
// Sub-app for JWT-protected routes
|
|
617
|
+
const api = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
|
|
618
|
+
|
|
619
|
+
// Admin JWT or Service Key middleware — verifies Admin JWT with separate signing key,
|
|
620
|
+
// OR accepts service key for programmatic/server admin access
|
|
621
|
+
api.use('*', async (c, next) => {
|
|
622
|
+
const config = parseConfig(c.env);
|
|
623
|
+
|
|
624
|
+
// 1. Try service key first (allows admin SDK to access /admin/api/data/*)
|
|
625
|
+
const explicitServiceKey =
|
|
626
|
+
c.req.header('X-EdgeBase-Service-Key') ??
|
|
627
|
+
c.req.header('x-edgebase-service-key') ??
|
|
628
|
+
c.req.raw.headers.get('X-EdgeBase-Service-Key') ??
|
|
629
|
+
c.req.raw.headers.get('x-edgebase-service-key');
|
|
630
|
+
const provided = explicitServiceKey ?? resolveServiceKeyCandidate(c.req, extractBearerToken(c.req));
|
|
631
|
+
|
|
632
|
+
if (provided !== undefined) {
|
|
633
|
+
const { result } = validateKey(provided, 'auth:admin:*:*', config, c.env, undefined, buildConstraintCtx(c.env, c.req));
|
|
634
|
+
if (result === 'valid') {
|
|
635
|
+
// Valid service key — allow access
|
|
636
|
+
await next();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 2. Try Admin JWT
|
|
641
|
+
const authHeader = c.req.header('authorization');
|
|
642
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
643
|
+
const token = authHeader.slice(7);
|
|
644
|
+
const secret = c.env.JWT_ADMIN_SECRET;
|
|
645
|
+
if (secret) {
|
|
646
|
+
try {
|
|
647
|
+
const payload = await verifyAdminTokenWithFallback(
|
|
648
|
+
token,
|
|
649
|
+
secret,
|
|
650
|
+
c.env.JWT_ADMIN_SECRET_OLD,
|
|
651
|
+
c.env.JWT_ADMIN_SECRET_OLD_AT,
|
|
652
|
+
);
|
|
653
|
+
c.set('adminId' as never, payload.sub);
|
|
654
|
+
await next();
|
|
655
|
+
return;
|
|
656
|
+
} catch {
|
|
657
|
+
// fall through to 401
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
throw new EdgeBaseError(401, 'Admin authentication required. Provide Admin JWT or Service Key.', undefined, 'unauthenticated');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// ─── Tables API ───
|
|
667
|
+
|
|
668
|
+
/** Parse config to get tables from databases block (§1). */
|
|
669
|
+
function getTables(env: Env): Array<{ name: string; namespace: string; fields: Record<string, unknown> }> {
|
|
670
|
+
try {
|
|
671
|
+
const config = parseConfig(env);
|
|
672
|
+
const result: Array<{ name: string; namespace: string; fields: Record<string, unknown> }> = [];
|
|
673
|
+
for (const [namespace, dbBlock] of Object.entries(config.databases ?? {})) {
|
|
674
|
+
for (const [tableName, tableConfig] of Object.entries(dbBlock.tables ?? {})) {
|
|
675
|
+
result.push({
|
|
676
|
+
name: tableName,
|
|
677
|
+
namespace,
|
|
678
|
+
fields: tableConfig.schema ?? {},
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return result;
|
|
683
|
+
} catch {
|
|
684
|
+
return [];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Get the Database DO stub for a table (§1/§2). */
|
|
689
|
+
function findNamespaceForTable(tableName: string, config: ReturnType<typeof parseConfig>): string {
|
|
690
|
+
for (const [ns, dbBlock] of Object.entries(config.databases ?? {})) {
|
|
691
|
+
if (dbBlock.tables?.[tableName]) {
|
|
692
|
+
return ns;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return 'shared';
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function getTableDO(env: Env, tableName: string, config: ReturnType<typeof parseConfig>, instanceId?: string) {
|
|
699
|
+
const namespace = findNamespaceForTable(tableName, config);
|
|
700
|
+
const doName = getDbDoName(namespace, instanceId);
|
|
701
|
+
return { stub: env.DATABASE.get(env.DATABASE.idFromName(doName)), doName };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function isDynamicDbBlock(
|
|
705
|
+
dbBlock: {
|
|
706
|
+
instance?: boolean;
|
|
707
|
+
access?: {
|
|
708
|
+
canCreate?: unknown;
|
|
709
|
+
access?: unknown;
|
|
710
|
+
};
|
|
711
|
+
} | undefined,
|
|
712
|
+
): boolean {
|
|
713
|
+
if (!dbBlock) return false;
|
|
714
|
+
return !!(dbBlock.instance || dbBlock.access?.canCreate || dbBlock.access?.access);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function getEffectiveDbProvider(namespace: string, config: ReturnType<typeof parseConfig>): 'do' | 'd1' | 'postgres' | 'neon' {
|
|
718
|
+
const dbBlock = config.databases?.[namespace];
|
|
719
|
+
if (!dbBlock) return 'do';
|
|
720
|
+
if (dbBlock.provider === 'neon') {
|
|
721
|
+
return 'neon';
|
|
722
|
+
}
|
|
723
|
+
if (dbBlock.provider === 'postgres') {
|
|
724
|
+
return 'postgres';
|
|
725
|
+
}
|
|
726
|
+
if (dbBlock.provider === 'do' || dbBlock.provider === 'd1') {
|
|
727
|
+
return dbBlock.provider;
|
|
728
|
+
}
|
|
729
|
+
return shouldRouteToD1(namespace, config) ? 'd1' : 'do';
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function getRequestedInstanceId(c: { req: { query: (name: string) => string | undefined } }): string | undefined {
|
|
733
|
+
const raw = c.req.query('instanceId');
|
|
734
|
+
if (!raw) return undefined;
|
|
735
|
+
const trimmed = raw.trim();
|
|
736
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function validateAdminTableInstanceId(
|
|
740
|
+
namespace: string,
|
|
741
|
+
config: ReturnType<typeof parseConfig>,
|
|
742
|
+
instanceId: string | undefined,
|
|
743
|
+
): Response | null {
|
|
744
|
+
const dynamic = isDynamicDbBlock(config.databases?.[namespace]);
|
|
745
|
+
if (!instanceId) {
|
|
746
|
+
if (dynamic) {
|
|
747
|
+
return new Response(
|
|
748
|
+
JSON.stringify({
|
|
749
|
+
code: 400,
|
|
750
|
+
message: `instanceId is required for dynamic namespace '${namespace}'`,
|
|
751
|
+
}),
|
|
752
|
+
{
|
|
753
|
+
status: 400,
|
|
754
|
+
headers: { 'Content-Type': 'application/json' },
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (instanceId.includes(':')) {
|
|
762
|
+
return new Response(
|
|
763
|
+
JSON.stringify({
|
|
764
|
+
code: 400,
|
|
765
|
+
message: 'instanceId must not contain \':\'',
|
|
766
|
+
}),
|
|
767
|
+
{
|
|
768
|
+
status: 400,
|
|
769
|
+
headers: { 'Content-Type': 'application/json' },
|
|
770
|
+
},
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function restoreAdminNamespaceTables(
|
|
778
|
+
env: Env,
|
|
779
|
+
config: ReturnType<typeof parseConfig>,
|
|
780
|
+
body: {
|
|
781
|
+
namespace: string;
|
|
782
|
+
tables: Record<string, Array<Record<string, unknown>>>;
|
|
783
|
+
skipWipe?: boolean;
|
|
784
|
+
},
|
|
785
|
+
): Promise<void> {
|
|
786
|
+
const dbBlock = config.databases?.[body.namespace];
|
|
787
|
+
if (!dbBlock) throw new EdgeBaseError(404, `Namespace '${body.namespace}' not found in config.`, undefined, 'not-found');
|
|
788
|
+
|
|
789
|
+
const userTableNames = Object.keys(dbBlock.tables ?? {});
|
|
790
|
+
const provider = dbBlock.provider;
|
|
791
|
+
const batchSize = 100;
|
|
792
|
+
|
|
793
|
+
if (provider === 'neon' || provider === 'postgres') {
|
|
794
|
+
const bindingName = getProviderBindingName(body.namespace);
|
|
795
|
+
const envRecord = env as unknown as Record<string, unknown>;
|
|
796
|
+
const hyperdrive = envRecord[bindingName] as { connectionString: string } | undefined;
|
|
797
|
+
const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
|
|
798
|
+
const connStr = hyperdrive?.connectionString ?? (envRecord[envKey] as string | undefined);
|
|
799
|
+
if (!connStr) {
|
|
800
|
+
throw new EdgeBaseError(500, `PostgreSQL connection not available for '${body.namespace}'.`, undefined, 'internal-error');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const localDevOptions = getLocalDevPostgresExecOptions(env as unknown as Record<string, unknown>, body.namespace);
|
|
804
|
+
if (localDevOptions) {
|
|
805
|
+
await ensureLocalDevPostgresSchema(localDevOptions);
|
|
806
|
+
}
|
|
807
|
+
await withPostgresConnection(connStr, async (query) => {
|
|
808
|
+
if (!localDevOptions) {
|
|
809
|
+
await ensurePgSchema(connStr, body.namespace, dbBlock.tables ?? {}, query);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (!body.skipWipe) {
|
|
813
|
+
for (const tableName of [...userTableNames, '_meta']) {
|
|
814
|
+
try {
|
|
815
|
+
await query(`DELETE FROM "${tableName}"`, []);
|
|
816
|
+
} catch {
|
|
817
|
+
// Table may not exist yet.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
for (const tableName of [...userTableNames, '_meta']) {
|
|
823
|
+
const rows = body.tables[tableName];
|
|
824
|
+
if (!rows || rows.length === 0) continue;
|
|
825
|
+
|
|
826
|
+
const escId = (name: string) => `"${name.replace(/"/g, '""')}"`;
|
|
827
|
+
for (const row of rows) {
|
|
828
|
+
const columns = Object.keys(row);
|
|
829
|
+
const columnList = columns.map((col) => escId(col)).join(', ');
|
|
830
|
+
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
|
831
|
+
const values = columns.map((col) => row[col]);
|
|
832
|
+
await query(
|
|
833
|
+
`INSERT INTO ${escId(tableName)} (${columnList}) VALUES (${placeholders})`,
|
|
834
|
+
values,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
for (const tableName of userTableNames) {
|
|
840
|
+
try {
|
|
841
|
+
await query(
|
|
842
|
+
`SELECT setval(pg_get_serial_sequence('"${tableName}"', 'id'), COALESCE((SELECT MAX(CAST(id AS BIGINT)) FROM "${tableName}"), 0) + 1, false)`,
|
|
843
|
+
[],
|
|
844
|
+
);
|
|
845
|
+
} catch {
|
|
846
|
+
// Sequence may not exist for this table.
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}, localDevOptions);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (!shouldRouteToD1(body.namespace, config)) {
|
|
854
|
+
throw new EdgeBaseError(400, `Namespace '${body.namespace}' is not restorable via the admin data backup API.`, undefined, 'validation-failed');
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const bindingName = getD1BindingName(body.namespace);
|
|
858
|
+
const db = (env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
|
|
859
|
+
if (!db) {
|
|
860
|
+
throw new EdgeBaseError(500, `D1 binding '${bindingName}' not available for '${body.namespace}'.`, undefined, 'internal-error');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
await ensureD1Schema(db, body.namespace, dbBlock.tables ?? {});
|
|
864
|
+
|
|
865
|
+
if (!body.skipWipe) {
|
|
866
|
+
const wipeStmts = [...userTableNames, '_meta'].map((tableName) => db.prepare(`DELETE FROM "${tableName}"`));
|
|
867
|
+
if (wipeStmts.length > 0) {
|
|
868
|
+
await db.batch(wipeStmts);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
for (const tableName of [...userTableNames, '_meta']) {
|
|
873
|
+
const rows = body.tables[tableName];
|
|
874
|
+
if (!rows || rows.length === 0) continue;
|
|
875
|
+
|
|
876
|
+
const escId = (name: string) => `"${name.replace(/"/g, '""')}"`;
|
|
877
|
+
const insertStmts: D1PreparedStatement[] = [];
|
|
878
|
+
for (const row of rows) {
|
|
879
|
+
const columns = Object.keys(row);
|
|
880
|
+
const columnList = columns.map((col) => escId(col)).join(', ');
|
|
881
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
882
|
+
const values = columns.map((col) => row[col]);
|
|
883
|
+
insertStmts.push(
|
|
884
|
+
db.prepare(
|
|
885
|
+
`INSERT OR REPLACE INTO ${escId(tableName)} (${columnList}) VALUES (${placeholders})`,
|
|
886
|
+
).bind(...values),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
for (let index = 0; index < insertStmts.length; index += batchSize) {
|
|
891
|
+
await db.batch(insertStmts.slice(index, index + batchSize));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// GET /admin/api/data/tables — list all tables from config
|
|
897
|
+
const adminListTables = createRoute({
|
|
898
|
+
operationId: 'adminListTables',
|
|
899
|
+
method: 'get',
|
|
900
|
+
path: '/tables',
|
|
901
|
+
tags: ['admin'],
|
|
902
|
+
summary: 'List all tables from config',
|
|
903
|
+
responses: {
|
|
904
|
+
200: { description: 'Tables list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
api.openapi(adminListTables, (c) => {
|
|
909
|
+
const tables = getTables(c.env);
|
|
910
|
+
return c.json({ tables: tables.map((col) => ({ name: col.name, namespace: col.namespace, fieldCount: Object.keys(col.fields).length })) });
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
/** Build DO URL with whitelisted query params passthrough.
|
|
914
|
+
* Uses QUERY_PARAM_KEYS — adding a key there auto-forwards it here. */
|
|
915
|
+
function buildDoUrl(basePath: string, incomingUrl: string): URL {
|
|
916
|
+
const incoming = new URL(incomingUrl).searchParams;
|
|
917
|
+
const url = new URL(`http://internal${basePath}`);
|
|
918
|
+
for (const key of QUERY_PARAM_KEYS) {
|
|
919
|
+
const val = incoming.get(key);
|
|
920
|
+
if (val) url.searchParams.set(key, val);
|
|
921
|
+
}
|
|
922
|
+
return url;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// GET /admin/api/data/tables/:name/records — list records with pagination (#133 §32)
|
|
926
|
+
const adminGetTableRecords = createRoute({
|
|
927
|
+
operationId: 'adminGetTableRecords',
|
|
928
|
+
method: 'get',
|
|
929
|
+
path: '/tables/{name}/records',
|
|
930
|
+
tags: ['admin'],
|
|
931
|
+
summary: 'List table records with pagination',
|
|
932
|
+
request: {
|
|
933
|
+
params: z.object({ name: z.string() }),
|
|
934
|
+
},
|
|
935
|
+
responses: {
|
|
936
|
+
200: { description: 'Records list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
api.openapi(adminGetTableRecords, async (c) => {
|
|
941
|
+
const name = c.req.param('name')!;
|
|
942
|
+
const config = parseConfig(c.env);
|
|
943
|
+
const namespace = findNamespaceForTable(name, config);
|
|
944
|
+
const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
|
|
945
|
+
const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
|
|
946
|
+
if (instanceError) return instanceError;
|
|
947
|
+
|
|
948
|
+
// D1 route: handle directly in Worker context
|
|
949
|
+
if (!instanceId && shouldRouteToD1(namespace, config)) {
|
|
950
|
+
// Inject service-key header for admin bypass
|
|
951
|
+
c.set('isServiceKey' as never, true);
|
|
952
|
+
return handleD1Request(c, namespace, name, `/tables/${name}`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const provider = config.databases?.[namespace]?.provider;
|
|
956
|
+
if (provider === 'neon' || provider === 'postgres') {
|
|
957
|
+
c.set('isServiceKey' as never, true);
|
|
958
|
+
return handlePgRequest(c, namespace, name, `/tables/${name}`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const { stub, doName } = getTableDO(c.env, name, config, instanceId);
|
|
962
|
+
const url = buildDoUrl(`/tables/${name}`, c.req.url);
|
|
963
|
+
|
|
964
|
+
const resp = await fetchDOWithRetry(stub, url.toString(), {
|
|
965
|
+
method: 'GET',
|
|
966
|
+
headers: { 'X-DO-Name': doName, 'x-internal': 'true' },
|
|
967
|
+
}, { safeToRetry: true });
|
|
968
|
+
const data = await resp.json();
|
|
969
|
+
return c.json(data, resp.status as 200);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
// POST /admin/api/data/tables/:name/records — create record (#133 §32)
|
|
974
|
+
// Admin requests are already authenticated — bypass row-level rules via X-Is-Service-Key.
|
|
975
|
+
const adminCreateTableRecord = createRoute({
|
|
976
|
+
operationId: 'adminCreateTableRecord',
|
|
977
|
+
method: 'post',
|
|
978
|
+
path: '/tables/{name}/records',
|
|
979
|
+
tags: ['admin'],
|
|
980
|
+
summary: 'Create a table record',
|
|
981
|
+
request: {
|
|
982
|
+
params: z.object({ name: z.string() }),
|
|
983
|
+
body: {
|
|
984
|
+
content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } },
|
|
985
|
+
required: true,
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
responses: {
|
|
989
|
+
200: { description: 'Record created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
api.openapi(adminCreateTableRecord, async (c) => {
|
|
994
|
+
const name = c.req.param('name')!;
|
|
995
|
+
const config = parseConfig(c.env);
|
|
996
|
+
const namespace = findNamespaceForTable(name, config);
|
|
997
|
+
const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
|
|
998
|
+
const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
|
|
999
|
+
if (instanceError) return instanceError;
|
|
1000
|
+
|
|
1001
|
+
if (!instanceId && shouldRouteToD1(namespace, config)) {
|
|
1002
|
+
c.set('isServiceKey' as never, true);
|
|
1003
|
+
return handleD1Request(c, namespace, name, `/tables/${name}`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const provider = config.databases?.[namespace]?.provider;
|
|
1007
|
+
if (provider === 'neon' || provider === 'postgres') {
|
|
1008
|
+
c.set('isServiceKey' as never, true);
|
|
1009
|
+
return handlePgRequest(c, namespace, name, `/tables/${name}`);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const body = await c.req.json();
|
|
1013
|
+
const { stub, doName } = getTableDO(c.env, name, config, instanceId);
|
|
1014
|
+
|
|
1015
|
+
const resp = await fetchDOWithRetry(stub, `http://internal/tables/${name}`, {
|
|
1016
|
+
method: 'POST',
|
|
1017
|
+
headers: { 'Content-Type': 'application/json', 'X-DO-Name': doName, 'x-internal': 'true', 'X-Is-Service-Key': 'true' },
|
|
1018
|
+
body: JSON.stringify(body),
|
|
1019
|
+
}, { safeToRetry: false });
|
|
1020
|
+
const data = await resp.json();
|
|
1021
|
+
return c.json(data, resp.status as 200);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
// PUT /admin/api/data/tables/:name/records/:id — update record (#133 §32)
|
|
1026
|
+
// Admin dashboard sends PUT, but DO only has PATCH handler (database-do.ts:683).
|
|
1027
|
+
// We accept PUT from the client and forward as PATCH to the DO.
|
|
1028
|
+
const adminUpdateTableRecord = createRoute({
|
|
1029
|
+
operationId: 'adminUpdateTableRecord',
|
|
1030
|
+
method: 'put',
|
|
1031
|
+
path: '/tables/{name}/records/{id}',
|
|
1032
|
+
tags: ['admin'],
|
|
1033
|
+
summary: 'Update a table record',
|
|
1034
|
+
request: {
|
|
1035
|
+
params: z.object({ name: z.string(), id: z.string() }),
|
|
1036
|
+
body: {
|
|
1037
|
+
content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } },
|
|
1038
|
+
required: true,
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
responses: {
|
|
1042
|
+
200: { description: 'Record updated', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
api.openapi(adminUpdateTableRecord, async (c) => {
|
|
1047
|
+
const name = c.req.param('name')!;
|
|
1048
|
+
const id = c.req.param('id')!;
|
|
1049
|
+
const config = parseConfig(c.env);
|
|
1050
|
+
const namespace = findNamespaceForTable(name, config);
|
|
1051
|
+
const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
|
|
1052
|
+
const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
|
|
1053
|
+
if (instanceError) return instanceError;
|
|
1054
|
+
|
|
1055
|
+
if (!instanceId && shouldRouteToD1(namespace, config)) {
|
|
1056
|
+
c.set('isServiceKey' as never, true);
|
|
1057
|
+
return handleD1Request(c, namespace, name, `/tables/${name}/${id}`);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const provider = config.databases?.[namespace]?.provider;
|
|
1061
|
+
if (provider === 'neon' || provider === 'postgres') {
|
|
1062
|
+
c.set('isServiceKey' as never, true);
|
|
1063
|
+
return handlePgRequest(c, namespace, name, `/tables/${name}/${id}`);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const body = await c.req.json();
|
|
1067
|
+
const { stub, doName } = getTableDO(c.env, name, config, instanceId);
|
|
1068
|
+
|
|
1069
|
+
const resp = await fetchDOWithRetry(stub, `http://internal/tables/${name}/${id}`, {
|
|
1070
|
+
method: 'PATCH',
|
|
1071
|
+
headers: { 'Content-Type': 'application/json', 'X-DO-Name': doName, 'x-internal': 'true', 'X-Is-Service-Key': 'true' },
|
|
1072
|
+
body: JSON.stringify(body),
|
|
1073
|
+
}, { safeToRetry: false });
|
|
1074
|
+
const data = await resp.json();
|
|
1075
|
+
return c.json(data, resp.status as 200);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
// DELETE /admin/api/data/tables/:name/records/:id — delete record (#133 §32)
|
|
1080
|
+
const adminDeleteTableRecord = createRoute({
|
|
1081
|
+
operationId: 'adminDeleteTableRecord',
|
|
1082
|
+
method: 'delete',
|
|
1083
|
+
path: '/tables/{name}/records/{id}',
|
|
1084
|
+
tags: ['admin'],
|
|
1085
|
+
summary: 'Delete a table record',
|
|
1086
|
+
request: {
|
|
1087
|
+
params: z.object({ name: z.string(), id: z.string() }),
|
|
1088
|
+
},
|
|
1089
|
+
responses: {
|
|
1090
|
+
200: { description: 'Record deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
api.openapi(adminDeleteTableRecord, async (c) => {
|
|
1095
|
+
const name = c.req.param('name')!;
|
|
1096
|
+
const id = c.req.param('id')!;
|
|
1097
|
+
const config = parseConfig(c.env);
|
|
1098
|
+
const namespace = findNamespaceForTable(name, config);
|
|
1099
|
+
const instanceId = isDynamicDbBlock(config.databases?.[namespace]) ? getRequestedInstanceId(c) : undefined;
|
|
1100
|
+
const instanceError = validateAdminTableInstanceId(namespace, config, instanceId);
|
|
1101
|
+
if (instanceError) return instanceError;
|
|
1102
|
+
|
|
1103
|
+
if (!instanceId && shouldRouteToD1(namespace, config)) {
|
|
1104
|
+
c.set('isServiceKey' as never, true);
|
|
1105
|
+
return handleD1Request(c, namespace, name, `/tables/${name}/${id}`);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const provider = config.databases?.[namespace]?.provider;
|
|
1109
|
+
if (provider === 'neon' || provider === 'postgres') {
|
|
1110
|
+
c.set('isServiceKey' as never, true);
|
|
1111
|
+
return handlePgRequest(c, namespace, name, `/tables/${name}/${id}`);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const { stub, doName } = getTableDO(c.env, name, config, instanceId);
|
|
1115
|
+
|
|
1116
|
+
const resp = await fetchDOWithRetry(stub, `http://internal/tables/${name}/${id}`, {
|
|
1117
|
+
method: 'DELETE',
|
|
1118
|
+
headers: { 'X-DO-Name': doName, 'x-internal': 'true', 'X-Is-Service-Key': 'true' },
|
|
1119
|
+
}, { safeToRetry: false });
|
|
1120
|
+
const data = await resp.json();
|
|
1121
|
+
return c.json(data, resp.status as 200);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
// ─── Users API ───
|
|
1126
|
+
|
|
1127
|
+
// GET /admin/api/data/users — list users via D1 index
|
|
1128
|
+
const adminListUsers = createRoute({
|
|
1129
|
+
operationId: 'adminListUsers',
|
|
1130
|
+
method: 'get',
|
|
1131
|
+
path: '/users',
|
|
1132
|
+
tags: ['admin'],
|
|
1133
|
+
summary: 'List users via D1 index',
|
|
1134
|
+
responses: {
|
|
1135
|
+
200: { description: 'Users list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
api.openapi(adminListUsers, async (c) => {
|
|
1140
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
1141
|
+
const { limit, offset } = parsePagination(c.req.query('limit'), c.req.query('cursor'));
|
|
1142
|
+
const emailQuery = c.req.query('email') || '';
|
|
1143
|
+
|
|
1144
|
+
let result;
|
|
1145
|
+
if (emailQuery) {
|
|
1146
|
+
result = await searchUserMappingsByEmail(getAuthDb(c), emailQuery, limit, offset);
|
|
1147
|
+
} else {
|
|
1148
|
+
result = await listUserMappings(getAuthDb(c), limit, offset);
|
|
1149
|
+
}
|
|
1150
|
+
const { mappings, total } = result;
|
|
1151
|
+
|
|
1152
|
+
if (mappings.length === 0) return c.json({ users: [], cursor: null, total: 0 });
|
|
1153
|
+
|
|
1154
|
+
// Fetch full user details from D1 directly
|
|
1155
|
+
const userIds = mappings.map(m => m.userId);
|
|
1156
|
+
const users = await authService.batchGetUsers(getAuthDb(c), userIds);
|
|
1157
|
+
const usersById = new Map(users.map((user) => [String(user.id ?? ''), user]));
|
|
1158
|
+
const sanitized = userIds
|
|
1159
|
+
.map((userId) => usersById.get(userId))
|
|
1160
|
+
.filter((user): user is Record<string, unknown> => !!user)
|
|
1161
|
+
.map((user) => authService.sanitizeUser(user, { includeAppMetadata: true }));
|
|
1162
|
+
|
|
1163
|
+
const hasMore = total > offset + limit;
|
|
1164
|
+
return c.json({ users: sanitized, cursor: hasMore ? String(offset + limit) : null, total });
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// GET /admin/api/data/users/:id — fetch a single user's auth info
|
|
1168
|
+
const adminGetUser = createRoute({
|
|
1169
|
+
operationId: 'adminGetUser',
|
|
1170
|
+
method: 'get',
|
|
1171
|
+
path: '/users/{id}',
|
|
1172
|
+
tags: ['admin'],
|
|
1173
|
+
summary: 'Fetch a single user by ID',
|
|
1174
|
+
request: {
|
|
1175
|
+
params: z.object({ id: z.string() }),
|
|
1176
|
+
},
|
|
1177
|
+
responses: {
|
|
1178
|
+
200: { description: 'User data', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1179
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
api.openapi(adminGetUser, async (c) => {
|
|
1184
|
+
const userId = c.req.param('id')!;
|
|
1185
|
+
const user = await authService.getUserById(getAuthDb(c), userId);
|
|
1186
|
+
if (!user) return c.json({ code: 404, message: 'User not found.' }, 404);
|
|
1187
|
+
return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) });
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// PUT /admin/api/data/users/:id — update user status/role
|
|
1191
|
+
const adminUpdateUser = createRoute({
|
|
1192
|
+
operationId: 'adminUpdateUser',
|
|
1193
|
+
method: 'put',
|
|
1194
|
+
path: '/users/{id}',
|
|
1195
|
+
tags: ['admin'],
|
|
1196
|
+
summary: 'Update user status or role',
|
|
1197
|
+
request: {
|
|
1198
|
+
params: z.object({ id: z.string() }),
|
|
1199
|
+
body: {
|
|
1200
|
+
content: { 'application/json': { schema: z.record(z.string(), z.unknown()) } },
|
|
1201
|
+
required: true,
|
|
1202
|
+
},
|
|
1203
|
+
},
|
|
1204
|
+
responses: {
|
|
1205
|
+
200: { description: 'User updated', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
api.openapi(adminUpdateUser, async (c) => {
|
|
1210
|
+
const userId = c.req.param('id')!;
|
|
1211
|
+
const body = await c.req.json() as Record<string, unknown>;
|
|
1212
|
+
const normalized = await normalizeAdminUserUpdates(body);
|
|
1213
|
+
const user = await updateManagedAdminUser(getAuthDb(c), userId, normalized as Record<string, unknown>, {
|
|
1214
|
+
executionCtx: c.executionCtx,
|
|
1215
|
+
kv: c.env.KV,
|
|
1216
|
+
});
|
|
1217
|
+
if (!user) return c.json({ code: 404, message: 'User not found.' }, 404);
|
|
1218
|
+
return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) });
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// GET /admin/api/data/users/:id/profile — fetch any user profile with 3-tier cache
|
|
1222
|
+
const adminGetUserProfile = createRoute({
|
|
1223
|
+
operationId: 'adminGetUserProfile',
|
|
1224
|
+
method: 'get',
|
|
1225
|
+
path: '/users/{id}/profile',
|
|
1226
|
+
tags: ['admin'],
|
|
1227
|
+
summary: 'Fetch user profile with cache',
|
|
1228
|
+
request: {
|
|
1229
|
+
params: z.object({ id: z.string() }),
|
|
1230
|
+
},
|
|
1231
|
+
responses: {
|
|
1232
|
+
200: { description: 'User profile', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1233
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
api.openapi(adminGetUserProfile, async (c) => {
|
|
1238
|
+
const userId = c.req.param('id');
|
|
1239
|
+
if (!userId) {
|
|
1240
|
+
throw new EdgeBaseError(400, 'User ID is required.', undefined, 'validation-failed');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const profile = await getPublicProfileWithCache(userId, c.env, c.executionCtx);
|
|
1244
|
+
|
|
1245
|
+
if (!profile) {
|
|
1246
|
+
throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return c.json(profile);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// DELETE /admin/api/data/users/:id/sessions — revoke all user sessions
|
|
1253
|
+
const adminDeleteUserSessions = createRoute({
|
|
1254
|
+
operationId: 'adminDeleteUserSessions',
|
|
1255
|
+
method: 'delete',
|
|
1256
|
+
path: '/users/{id}/sessions',
|
|
1257
|
+
tags: ['admin'],
|
|
1258
|
+
summary: 'Revoke all user sessions',
|
|
1259
|
+
request: {
|
|
1260
|
+
params: z.object({ id: z.string() }),
|
|
1261
|
+
},
|
|
1262
|
+
responses: {
|
|
1263
|
+
200: { description: 'Sessions revoked', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1264
|
+
},
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
api.openapi(adminDeleteUserSessions, async (c) => {
|
|
1268
|
+
const userId = c.req.param('id')!;
|
|
1269
|
+
await authService.deleteAllUserSessions(getAuthDb(c), userId);
|
|
1270
|
+
return c.json({ ok: true, message: 'All sessions revoked.' });
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
// ─── Anon Index D1 Cleanup ───
|
|
1274
|
+
|
|
1275
|
+
// POST /admin/api/data/cleanup-anon — process KV-signaled _anon_index D1 cleanup
|
|
1276
|
+
const adminCleanupAnon = createRoute({
|
|
1277
|
+
operationId: 'adminCleanupAnon',
|
|
1278
|
+
method: 'post',
|
|
1279
|
+
path: '/cleanup-anon',
|
|
1280
|
+
tags: ['admin'],
|
|
1281
|
+
summary: 'Cleanup anonymous user index',
|
|
1282
|
+
responses: {
|
|
1283
|
+
200: { description: 'Cleanup result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1284
|
+
},
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
api.openapi(adminCleanupAnon, async (c) => {
|
|
1288
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
1289
|
+
const config = parseConfig(c.env);
|
|
1290
|
+
const retentionDays = config?.auth?.anonymousRetentionDays ?? 30;
|
|
1291
|
+
|
|
1292
|
+
const deletedIds = await authService.cleanStaleAnonymousAccounts(getAuthDb(c), retentionDays);
|
|
1293
|
+
|
|
1294
|
+
// Clean D1 indexes
|
|
1295
|
+
for (const id of deletedIds) {
|
|
1296
|
+
await deleteAnon(getAuthDb(c), id).catch(() => {});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return c.json({ ok: true, cleaned: deletedIds.length });
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// ─── Storage API ───
|
|
1303
|
+
|
|
1304
|
+
// GET /admin/api/data/storage/buckets — list configured buckets
|
|
1305
|
+
const adminListBuckets = createRoute({
|
|
1306
|
+
operationId: 'adminListBuckets',
|
|
1307
|
+
method: 'get',
|
|
1308
|
+
path: '/storage/buckets',
|
|
1309
|
+
tags: ['admin'],
|
|
1310
|
+
summary: 'List configured storage buckets',
|
|
1311
|
+
responses: {
|
|
1312
|
+
200: { description: 'Buckets list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1313
|
+
},
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
api.openapi(adminListBuckets, (c) => {
|
|
1317
|
+
try {
|
|
1318
|
+
const config = parseConfig(c.env);
|
|
1319
|
+
const buckets = Object.keys(config?.storage?.buckets || {});
|
|
1320
|
+
return c.json({ buckets });
|
|
1321
|
+
} catch {
|
|
1322
|
+
return c.json({ buckets: [] });
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
// GET /admin/api/data/storage/buckets/:name/objects — list objects in a bucket
|
|
1327
|
+
const adminListBucketObjects = createRoute({
|
|
1328
|
+
operationId: 'adminListBucketObjects',
|
|
1329
|
+
method: 'get',
|
|
1330
|
+
path: '/storage/buckets/{name}/objects',
|
|
1331
|
+
tags: ['admin'],
|
|
1332
|
+
summary: 'List objects in a storage bucket',
|
|
1333
|
+
request: {
|
|
1334
|
+
params: z.object({ name: z.string() }),
|
|
1335
|
+
},
|
|
1336
|
+
responses: {
|
|
1337
|
+
200: { description: 'Objects list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
api.openapi(adminListBucketObjects, async (c) => {
|
|
1342
|
+
const bucketName = c.req.param('name');
|
|
1343
|
+
const userPrefix = c.req.query('prefix') || '';
|
|
1344
|
+
const delimiter = c.req.query('delimiter') || '';
|
|
1345
|
+
const prefix = `${bucketName}/${userPrefix}`;
|
|
1346
|
+
const cursor = c.req.query('cursor');
|
|
1347
|
+
const limit = parseInt(c.req.query('limit') || '50', 10);
|
|
1348
|
+
|
|
1349
|
+
const listOptions: R2ListOptions = { prefix, limit, include: ['httpMetadata'] };
|
|
1350
|
+
if (cursor) listOptions.cursor = cursor;
|
|
1351
|
+
if (delimiter) listOptions.delimiter = delimiter;
|
|
1352
|
+
|
|
1353
|
+
const result = await c.env.STORAGE.list(listOptions);
|
|
1354
|
+
const objects = result.objects.map((obj) => ({
|
|
1355
|
+
key: obj.key.replace(`${bucketName}/`, ''),
|
|
1356
|
+
size: obj.size,
|
|
1357
|
+
uploaded: obj.uploaded.toISOString(),
|
|
1358
|
+
httpMetadata: obj.httpMetadata,
|
|
1359
|
+
}));
|
|
1360
|
+
|
|
1361
|
+
// Extract folder names from delimited prefixes
|
|
1362
|
+
const folders = (result.delimitedPrefixes || []).map((p: string) => p.replace(`${bucketName}/`, ''));
|
|
1363
|
+
|
|
1364
|
+
return c.json({
|
|
1365
|
+
objects,
|
|
1366
|
+
folders,
|
|
1367
|
+
cursor: result.truncated ? result.cursor : null,
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// GET /admin/api/data/storage/buckets/:name/objects/:key — get object content (for preview)
|
|
1372
|
+
const adminGetBucketObject = createRoute({
|
|
1373
|
+
operationId: 'adminGetBucketObject',
|
|
1374
|
+
method: 'get',
|
|
1375
|
+
path: '/storage/buckets/{name}/objects/{key}',
|
|
1376
|
+
tags: ['admin'],
|
|
1377
|
+
summary: 'Get a storage object content',
|
|
1378
|
+
request: {
|
|
1379
|
+
params: z.object({ name: z.string(), key: z.string() }),
|
|
1380
|
+
},
|
|
1381
|
+
responses: {
|
|
1382
|
+
200: { description: 'Object content' },
|
|
1383
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1384
|
+
},
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
api.openapi(adminGetBucketObject, async (c) => {
|
|
1388
|
+
const bucketName = c.req.param('name')!;
|
|
1389
|
+
const key = decodeURIComponent(c.req.param('key')!);
|
|
1390
|
+
const fullKey = `${bucketName}/${key}`;
|
|
1391
|
+
const obj = await c.env.STORAGE.get(fullKey);
|
|
1392
|
+
if (!obj) throw new EdgeBaseError(404, 'Object not found.', undefined, 'not-found');
|
|
1393
|
+
const headers = new Headers();
|
|
1394
|
+
if (obj.httpMetadata?.contentType) headers.set('Content-Type', obj.httpMetadata.contentType);
|
|
1395
|
+
headers.set('Cache-Control', 'private, max-age=3600');
|
|
1396
|
+
return new Response(obj.body, { headers });
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
// DELETE /admin/api/data/storage/buckets/:name/objects/:key+ — delete an object
|
|
1400
|
+
const adminDeleteBucketObject = createRoute({
|
|
1401
|
+
operationId: 'adminDeleteBucketObject',
|
|
1402
|
+
method: 'delete',
|
|
1403
|
+
path: '/storage/buckets/{name}/objects/{key}',
|
|
1404
|
+
tags: ['admin'],
|
|
1405
|
+
summary: 'Delete a storage object',
|
|
1406
|
+
request: {
|
|
1407
|
+
params: z.object({ name: z.string(), key: z.string() }),
|
|
1408
|
+
},
|
|
1409
|
+
responses: {
|
|
1410
|
+
200: { description: 'Object deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1411
|
+
},
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
api.openapi(adminDeleteBucketObject, async (c) => {
|
|
1415
|
+
const bucketName = c.req.param('name');
|
|
1416
|
+
const key = c.req.param('key');
|
|
1417
|
+
const fullKey = `${bucketName}/${key}`;
|
|
1418
|
+
|
|
1419
|
+
await c.env.STORAGE.delete(fullKey);
|
|
1420
|
+
return c.json({ ok: true, deleted: fullKey });
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
// GET /admin/api/data/storage/buckets/:name/stats — bucket statistics
|
|
1424
|
+
const adminGetBucketStats = createRoute({
|
|
1425
|
+
operationId: 'adminGetBucketStats',
|
|
1426
|
+
method: 'get',
|
|
1427
|
+
path: '/storage/buckets/{name}/stats',
|
|
1428
|
+
tags: ['admin'],
|
|
1429
|
+
summary: 'Get bucket statistics (total objects and size)',
|
|
1430
|
+
request: {
|
|
1431
|
+
params: z.object({ name: z.string() }),
|
|
1432
|
+
},
|
|
1433
|
+
responses: {
|
|
1434
|
+
200: { description: 'Bucket stats', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
api.openapi(adminGetBucketStats, async (c) => {
|
|
1439
|
+
const bucketName = c.req.param('name');
|
|
1440
|
+
let totalObjects = 0;
|
|
1441
|
+
let totalSize = 0;
|
|
1442
|
+
let listCursor: string | undefined;
|
|
1443
|
+
|
|
1444
|
+
do {
|
|
1445
|
+
const listOptions: R2ListOptions = { prefix: `${bucketName}/`, limit: 1000 };
|
|
1446
|
+
if (listCursor) listOptions.cursor = listCursor;
|
|
1447
|
+
const result = await c.env.STORAGE.list(listOptions);
|
|
1448
|
+
for (const obj of result.objects) {
|
|
1449
|
+
totalObjects++;
|
|
1450
|
+
totalSize += obj.size;
|
|
1451
|
+
}
|
|
1452
|
+
listCursor = result.truncated ? result.cursor : undefined;
|
|
1453
|
+
} while (listCursor);
|
|
1454
|
+
|
|
1455
|
+
return c.json({ totalObjects, totalSize });
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// POST /admin/api/data/storage/buckets/:name/signed-url — create signed download URL
|
|
1459
|
+
const adminCreateSignedUrl = createRoute({
|
|
1460
|
+
operationId: 'adminCreateSignedUrl',
|
|
1461
|
+
method: 'post',
|
|
1462
|
+
path: '/storage/buckets/{name}/signed-url',
|
|
1463
|
+
tags: ['admin'],
|
|
1464
|
+
summary: 'Create a signed download URL for a storage object',
|
|
1465
|
+
request: {
|
|
1466
|
+
params: z.object({ name: z.string() }),
|
|
1467
|
+
body: {
|
|
1468
|
+
content: {
|
|
1469
|
+
'application/json': {
|
|
1470
|
+
schema: z.object({
|
|
1471
|
+
key: z.string(),
|
|
1472
|
+
expiresIn: z.string().optional(),
|
|
1473
|
+
}),
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
required: true,
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
responses: {
|
|
1480
|
+
200: { description: 'Signed URL created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1481
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1482
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1483
|
+
500: { description: 'Server error', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
api.openapi(adminCreateSignedUrl, async (c) => {
|
|
1488
|
+
const bucketName = c.req.param('name')!;
|
|
1489
|
+
const body = await c.req.json<{ key: string; expiresIn?: string }>();
|
|
1490
|
+
|
|
1491
|
+
if (!body.key) {
|
|
1492
|
+
throw new EdgeBaseError(400, 'Missing required field: key.', undefined, 'validation-failed');
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Check file exists
|
|
1496
|
+
const fullKey = `${bucketName}/${body.key}`;
|
|
1497
|
+
const obj = await c.env.STORAGE.head(fullKey);
|
|
1498
|
+
if (!obj) {
|
|
1499
|
+
throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const secret = c.env.JWT_USER_SECRET;
|
|
1503
|
+
if (!secret) {
|
|
1504
|
+
throw new EdgeBaseError(500, 'Signed URLs require JWT_USER_SECRET to be configured.', undefined, 'internal-error');
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const expiresInMs = parseDuration(body.expiresIn || '1h');
|
|
1508
|
+
const expiresAt = Date.now() + expiresInMs;
|
|
1509
|
+
const token = await createSignedToken(body.key, bucketName, expiresAt, secret);
|
|
1510
|
+
|
|
1511
|
+
// Build signed URL using the public storage endpoint
|
|
1512
|
+
const url = new URL(c.req.url);
|
|
1513
|
+
const signedUrl = `${url.protocol}//${url.host}/api/storage/${bucketName}/${body.key}?token=${token}`;
|
|
1514
|
+
|
|
1515
|
+
return c.json({
|
|
1516
|
+
url: signedUrl,
|
|
1517
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// ─── Schema API ───
|
|
1522
|
+
|
|
1523
|
+
// GET /admin/api/data/schema — full schema structure from config
|
|
1524
|
+
const adminGetSchema = createRoute({
|
|
1525
|
+
operationId: 'adminGetSchema',
|
|
1526
|
+
method: 'get',
|
|
1527
|
+
path: '/schema',
|
|
1528
|
+
tags: ['admin'],
|
|
1529
|
+
summary: 'Get full schema structure from config',
|
|
1530
|
+
responses: {
|
|
1531
|
+
200: { description: 'Schema', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
api.openapi(adminGetSchema, (c) => {
|
|
1536
|
+
try {
|
|
1537
|
+
const config = parseConfig(c.env);
|
|
1538
|
+
const schema: Record<string, unknown> = {};
|
|
1539
|
+
const namespaces: Record<string, unknown> = {};
|
|
1540
|
+
|
|
1541
|
+
for (const [namespace, dbBlock] of Object.entries(config.databases ?? {})) {
|
|
1542
|
+
const provider = getEffectiveDbProvider(namespace, config);
|
|
1543
|
+
const dynamic = isDynamicDbBlock(dbBlock);
|
|
1544
|
+
const instanceDiscovery = serializeAdminInstanceDiscovery(dbBlock.admin?.instances, {
|
|
1545
|
+
fallbackManual: dynamic,
|
|
1546
|
+
});
|
|
1547
|
+
namespaces[namespace] = {
|
|
1548
|
+
provider,
|
|
1549
|
+
dynamic,
|
|
1550
|
+
instanceDiscovery,
|
|
1551
|
+
};
|
|
1552
|
+
for (const [tableName, tableConfig] of Object.entries(dbBlock.tables ?? {})) {
|
|
1553
|
+
schema[tableName] = {
|
|
1554
|
+
namespace,
|
|
1555
|
+
provider,
|
|
1556
|
+
dynamic,
|
|
1557
|
+
instanceDiscovery,
|
|
1558
|
+
fields: tableConfig.schema || {},
|
|
1559
|
+
indexes: tableConfig.indexes || [],
|
|
1560
|
+
fts: tableConfig.fts || [],
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
return c.json({ schema, namespaces });
|
|
1566
|
+
} catch {
|
|
1567
|
+
return c.json({ schema: {}, namespaces: {} });
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
const adminListNamespaceInstances = createRoute({
|
|
1572
|
+
operationId: 'adminListNamespaceInstances',
|
|
1573
|
+
method: 'get',
|
|
1574
|
+
path: '/namespaces/{namespace}/instances',
|
|
1575
|
+
tags: ['admin'],
|
|
1576
|
+
summary: 'List instance suggestions for a dynamic namespace',
|
|
1577
|
+
request: {
|
|
1578
|
+
params: z.object({ namespace: z.string() }),
|
|
1579
|
+
query: z.object({
|
|
1580
|
+
q: z.string().optional(),
|
|
1581
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
1582
|
+
}),
|
|
1583
|
+
},
|
|
1584
|
+
responses: {
|
|
1585
|
+
200: { description: 'Instance suggestions', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1586
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1587
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1588
|
+
},
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
api.openapi(adminListNamespaceInstances, async (c) => {
|
|
1592
|
+
const namespace = c.req.param('namespace')!;
|
|
1593
|
+
const config = parseConfig(c.env);
|
|
1594
|
+
if (!config.databases?.[namespace]) {
|
|
1595
|
+
throw new EdgeBaseError(404, `Namespace not found: ${namespace}`, undefined, 'not-found');
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
try {
|
|
1599
|
+
const resolved = await resolveAdminInstanceOptions({
|
|
1600
|
+
env: c.env,
|
|
1601
|
+
config,
|
|
1602
|
+
namespace,
|
|
1603
|
+
query: c.req.query('q') ?? '',
|
|
1604
|
+
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
|
1605
|
+
});
|
|
1606
|
+
return c.json(resolved);
|
|
1607
|
+
} catch (err) {
|
|
1608
|
+
const message = err instanceof Error ? err.message : 'Failed to resolve instance suggestions';
|
|
1609
|
+
if (message.includes('not dynamic')) {
|
|
1610
|
+
throw new EdgeBaseError(400, message, undefined, 'validation-failed');
|
|
1611
|
+
}
|
|
1612
|
+
throw new EdgeBaseError(400, message, undefined, 'validation-failed');
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
// GET /admin/api/data/tables/:name/export?format=json (#133 §32)
|
|
1617
|
+
// Exports table data as JSON array.
|
|
1618
|
+
// In DB-block architecture (#133 §1), all tables share namespace-level DO isolation.
|
|
1619
|
+
const adminExportTable = createRoute({
|
|
1620
|
+
operationId: 'adminExportTable',
|
|
1621
|
+
method: 'get',
|
|
1622
|
+
path: '/tables/{name}/export',
|
|
1623
|
+
tags: ['admin'],
|
|
1624
|
+
summary: 'Export table data as JSON',
|
|
1625
|
+
request: {
|
|
1626
|
+
params: z.object({ name: z.string() }),
|
|
1627
|
+
},
|
|
1628
|
+
responses: {
|
|
1629
|
+
200: { description: 'Exported data', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1630
|
+
404: { description: 'Table not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
api.openapi(adminExportTable, async (c) => {
|
|
1635
|
+
const name = c.req.param('name')!;
|
|
1636
|
+
const format = c.req.query('format') || 'json';
|
|
1637
|
+
|
|
1638
|
+
if (format !== 'json') {
|
|
1639
|
+
throw new EdgeBaseError(400, `Unsupported export format: ${format}. Only "json" is supported.`, undefined, 'validation-failed');
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Validate table exists in config
|
|
1643
|
+
const allTables = getTables(c.env);
|
|
1644
|
+
const tableInfo = allTables.find((col) => col.name === name);
|
|
1645
|
+
if (!tableInfo) {
|
|
1646
|
+
throw new EdgeBaseError(404, `Table not found: ${name}`, undefined, 'not-found');
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const config = parseConfig(c.env);
|
|
1650
|
+
|
|
1651
|
+
const responseHeaders: Record<string, string> = {
|
|
1652
|
+
'Content-Type': 'application/json',
|
|
1653
|
+
'Content-Disposition': `attachment; filename="${name}-export.json"`,
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
const records = (await dumpNamespaceTables(c.env, config, tableInfo.namespace, {
|
|
1657
|
+
includeMeta: false,
|
|
1658
|
+
tableNames: [name],
|
|
1659
|
+
}))[name] || [];
|
|
1660
|
+
|
|
1661
|
+
return new Response(JSON.stringify(records, null, 2), { headers: responseHeaders });
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
// ─── Logs API ───
|
|
1666
|
+
|
|
1667
|
+
// GET /admin/api/data/logs — request logs (from KV, M10 logger)
|
|
1668
|
+
const adminGetLogs = createRoute({
|
|
1669
|
+
operationId: 'adminGetLogs',
|
|
1670
|
+
method: 'get',
|
|
1671
|
+
path: '/logs',
|
|
1672
|
+
tags: ['admin'],
|
|
1673
|
+
summary: 'Get request logs',
|
|
1674
|
+
responses: {
|
|
1675
|
+
200: { description: 'Logs', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1676
|
+
},
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
api.openapi(adminGetLogs, async (c) => {
|
|
1680
|
+
const limit = parseInt(c.req.query('limit') || '50', 10);
|
|
1681
|
+
const prefix = c.req.query('prefix') || 'log:';
|
|
1682
|
+
const level = c.req.query('level') || '';
|
|
1683
|
+
const pathFilter = c.req.query('path') || '';
|
|
1684
|
+
const category = c.req.query('category') || '';
|
|
1685
|
+
|
|
1686
|
+
try {
|
|
1687
|
+
const doLogs = await fetchRecentLogsFromDo(c.env, { limit, level, pathFilter, category });
|
|
1688
|
+
if (doLogs !== null) {
|
|
1689
|
+
return c.json({ logs: doLogs, cursor: null });
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const list = await c.env.KV.list({ prefix, limit });
|
|
1693
|
+
let logs: Array<Record<string, unknown>> = [];
|
|
1694
|
+
|
|
1695
|
+
for (const key of list.keys) {
|
|
1696
|
+
const value = await c.env.KV.get(key.name, 'json');
|
|
1697
|
+
if (value) logs.push(value as Record<string, unknown>);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (level) {
|
|
1701
|
+
logs = logs.filter((log) => matchesLogLevel(getLogStatusCode(log), level));
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (pathFilter) {
|
|
1705
|
+
logs = logs.filter((log) => String(log.path ?? '').includes(pathFilter));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (category) {
|
|
1709
|
+
logs = logs.filter((log) => String(log.category ?? '').toLowerCase() === category.toLowerCase());
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
return c.json({ logs, cursor: list.list_complete ? null : list.cursor });
|
|
1713
|
+
} catch {
|
|
1714
|
+
return c.json({ logs: [], cursor: null });
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
// ─── Monitoring API ───
|
|
1719
|
+
|
|
1720
|
+
// GET /admin/api/data/monitoring — live monitoring stats
|
|
1721
|
+
const adminGetMonitoring = createRoute({
|
|
1722
|
+
operationId: 'adminGetMonitoring',
|
|
1723
|
+
method: 'get',
|
|
1724
|
+
path: '/monitoring',
|
|
1725
|
+
tags: ['admin'],
|
|
1726
|
+
summary: 'Get live monitoring stats',
|
|
1727
|
+
responses: {
|
|
1728
|
+
200: { description: 'Monitoring stats', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1729
|
+
},
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
api.openapi(adminGetMonitoring, async (c) => {
|
|
1733
|
+
return c.json(await fetchUnifiedMonitoringStats(c.env));
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
// ─── Analytics API ───
|
|
1737
|
+
|
|
1738
|
+
// GET /admin/api/data/analytics — analytics dashboard data
|
|
1739
|
+
const adminGetAnalytics = createRoute({
|
|
1740
|
+
operationId: 'adminGetAnalytics',
|
|
1741
|
+
method: 'get',
|
|
1742
|
+
path: '/analytics',
|
|
1743
|
+
tags: ['admin'],
|
|
1744
|
+
summary: 'Get analytics dashboard data',
|
|
1745
|
+
request: {
|
|
1746
|
+
query: z.object({
|
|
1747
|
+
range: z.string().optional().openapi({ description: 'Time range (e.g. 1h, 6h, 24h, 7d, 30d)', example: '24h' }),
|
|
1748
|
+
category: z.string().optional().openapi({ description: 'Filter by category', example: 'db' }),
|
|
1749
|
+
metric: z.string().optional().openapi({ description: 'Metric type (overview, timeSeries, breakdown, topEndpoints)', example: 'overview' }),
|
|
1750
|
+
groupBy: z.string().optional().openapi({ description: 'Optional group-by override (minute, tenMinute, hour, day)', example: 'hour' }),
|
|
1751
|
+
start: z.string().optional().openapi({ description: 'Custom ISO start time' }),
|
|
1752
|
+
end: z.string().optional().openapi({ description: 'Custom ISO end time' }),
|
|
1753
|
+
excludeCategory: z.string().optional().openapi({ description: 'Exclude a category from the result set', example: 'admin' }),
|
|
1754
|
+
}),
|
|
1755
|
+
},
|
|
1756
|
+
responses: {
|
|
1757
|
+
200: { description: 'Analytics data', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1758
|
+
},
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
api.openapi(adminGetAnalytics, async (c) => {
|
|
1762
|
+
const range = c.req.query('range') || '24h';
|
|
1763
|
+
const category = c.req.query('category') || '';
|
|
1764
|
+
const metric = c.req.query('metric') || 'overview';
|
|
1765
|
+
const start = c.req.query('start') || undefined;
|
|
1766
|
+
const end = c.req.query('end') || undefined;
|
|
1767
|
+
const excludeCategory = c.req.query('excludeCategory') || undefined;
|
|
1768
|
+
|
|
1769
|
+
const { executeAnalyticsQuery, resolveAnalyticsGroupBy } = await import('../lib/analytics-query.js');
|
|
1770
|
+
const groupBy = resolveAnalyticsGroupBy(range, start, end, c.req.query('groupBy') || undefined);
|
|
1771
|
+
|
|
1772
|
+
const result = await executeAnalyticsQuery(c.env, { range, category, metric, groupBy, start, end, excludeCategory });
|
|
1773
|
+
return c.json(result);
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
// GET /admin/api/data/analytics/events — proxy custom events query for admin dashboard
|
|
1777
|
+
const adminGetAnalyticsEvents = createRoute({
|
|
1778
|
+
operationId: 'adminGetAnalyticsEvents',
|
|
1779
|
+
method: 'get',
|
|
1780
|
+
path: '/analytics/events',
|
|
1781
|
+
tags: ['admin'],
|
|
1782
|
+
summary: 'Query analytics events for admin dashboard',
|
|
1783
|
+
responses: {
|
|
1784
|
+
200: { description: 'Events data', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
api.openapi(adminGetAnalyticsEvents, async (c) => {
|
|
1789
|
+
const range = c.req.query('range') || '24h';
|
|
1790
|
+
const type = c.req.query('type') || '';
|
|
1791
|
+
const userId = c.req.query('userId') || '';
|
|
1792
|
+
const metric = c.req.query('metric') || 'list';
|
|
1793
|
+
const groupBy = c.req.query('groupBy') || 'hour';
|
|
1794
|
+
const limit = c.req.query('limit') || '100';
|
|
1795
|
+
const cursor = c.req.query('cursor') || '';
|
|
1796
|
+
|
|
1797
|
+
if (!c.env.LOGS) {
|
|
1798
|
+
if (metric === 'list') return c.json({ events: [], cursor: undefined, hasMore: false });
|
|
1799
|
+
if (metric === 'count') return c.json({ totalEvents: 0, uniqueUsers: 0 });
|
|
1800
|
+
if (metric === 'timeSeries') return c.json({ timeSeries: [] });
|
|
1801
|
+
if (metric === 'topEvents') return c.json({ topEvents: [] });
|
|
1802
|
+
return c.json({ events: [] });
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
const logsDO = c.env.LOGS.get(c.env.LOGS.idFromName('logs:main'));
|
|
1806
|
+
const params = new URLSearchParams({ range, metric, groupBy, limit });
|
|
1807
|
+
if (type && type !== 'all') params.set('event', type);
|
|
1808
|
+
if (userId) params.set('userId', userId);
|
|
1809
|
+
if (cursor) params.set('cursor', cursor);
|
|
1810
|
+
|
|
1811
|
+
const resp = await logsDO.fetch(
|
|
1812
|
+
new Request(`http://internal/internal/events/query?${params}`),
|
|
1813
|
+
);
|
|
1814
|
+
const data = await resp.json();
|
|
1815
|
+
return c.json(data);
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
// ─── Overview API ───
|
|
1819
|
+
|
|
1820
|
+
// GET /admin/api/data/overview — project overview for dashboard home
|
|
1821
|
+
const adminGetOverview = createRoute({
|
|
1822
|
+
operationId: 'adminGetOverview',
|
|
1823
|
+
method: 'get',
|
|
1824
|
+
path: '/overview',
|
|
1825
|
+
tags: ['admin'],
|
|
1826
|
+
summary: 'Get project overview for dashboard home',
|
|
1827
|
+
request: {
|
|
1828
|
+
query: z.object({
|
|
1829
|
+
range: z.string().optional().openapi({ description: 'Time range (e.g. 1h, 6h, 24h, 7d, 30d)', example: '24h' }),
|
|
1830
|
+
groupBy: z.string().optional().openapi({ description: 'Optional group-by override (minute, tenMinute, hour, day)', example: 'hour' }),
|
|
1831
|
+
}),
|
|
1832
|
+
},
|
|
1833
|
+
responses: {
|
|
1834
|
+
200: { description: 'Overview data', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1835
|
+
},
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
api.openapi(adminGetOverview, async (c) => {
|
|
1839
|
+
const { executeAnalyticsQuery, resolveAnalyticsGroupBy, resolveOverviewAutoRange } = await import('../lib/analytics-query.js');
|
|
1840
|
+
const requestedRange = c.req.query('range');
|
|
1841
|
+
const effectiveRange =
|
|
1842
|
+
requestedRange === '1h' || requestedRange === '6h' || requestedRange === '24h'
|
|
1843
|
+
? requestedRange
|
|
1844
|
+
: await resolveOverviewAutoRange(c.env);
|
|
1845
|
+
const groupBy = resolveAnalyticsGroupBy(effectiveRange, undefined, undefined, c.req.query('groupBy') || undefined);
|
|
1846
|
+
|
|
1847
|
+
const [userCountResult, configResult, analyticsResult, liveStatsResult] = await Promise.allSettled([
|
|
1848
|
+
// User count from D1
|
|
1849
|
+
(async () => {
|
|
1850
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
1851
|
+
return countUsers(getAuthDb(c));
|
|
1852
|
+
})(),
|
|
1853
|
+
// Config info (tables, buckets, auth providers)
|
|
1854
|
+
(async () => {
|
|
1855
|
+
const config = parseConfig(c.env);
|
|
1856
|
+
const databases = Object.entries(config.databases ?? {}).map(([name, db]) => ({
|
|
1857
|
+
name,
|
|
1858
|
+
tableCount: Object.keys(db.tables ?? {}).length,
|
|
1859
|
+
}));
|
|
1860
|
+
const totalTables = databases.reduce((sum, db) => sum + db.tableCount, 0);
|
|
1861
|
+
const storageConfig = config.storage ?? {} as Record<string, unknown>;
|
|
1862
|
+
const buckets = Object.keys((storageConfig as Record<string, unknown>).buckets ?? {});
|
|
1863
|
+
const serviceKeys = config.serviceKeys ?? [];
|
|
1864
|
+
const serviceKeyCount = Array.isArray(serviceKeys) ? serviceKeys.length : 0;
|
|
1865
|
+
const authProviders = config.auth?.allowedOAuthProviders ?? [];
|
|
1866
|
+
const sidecarPort = c.env.EDGEBASE_DEV_SIDECAR_PORT;
|
|
1867
|
+
return {
|
|
1868
|
+
databases,
|
|
1869
|
+
totalTables,
|
|
1870
|
+
storageBuckets: buckets,
|
|
1871
|
+
serviceKeyCount,
|
|
1872
|
+
authProviders,
|
|
1873
|
+
devMode: !!sidecarPort,
|
|
1874
|
+
};
|
|
1875
|
+
})(),
|
|
1876
|
+
// Analytics summary
|
|
1877
|
+
executeAnalyticsQuery(c.env, { range: effectiveRange, category: '', metric: 'overview', groupBy }),
|
|
1878
|
+
fetchUnifiedMonitoringStats(c.env),
|
|
1879
|
+
]);
|
|
1880
|
+
|
|
1881
|
+
const totalUsers = userCountResult.status === 'fulfilled' ? userCountResult.value : 0;
|
|
1882
|
+
const config = configResult.status === 'fulfilled' ? configResult.value : {
|
|
1883
|
+
databases: [], totalTables: 0, storageBuckets: [], serviceKeyCount: 0, authProviders: [], devMode: false,
|
|
1884
|
+
};
|
|
1885
|
+
const analytics = analyticsResult.status === 'fulfilled' ? analyticsResult.value : {
|
|
1886
|
+
summary: { totalRequests: 0, totalErrors: 0, avgLatency: 0, uniqueUsers: 0 },
|
|
1887
|
+
timeSeries: [],
|
|
1888
|
+
breakdown: [],
|
|
1889
|
+
topItems: [],
|
|
1890
|
+
};
|
|
1891
|
+
const live = liveStatsResult.status === 'fulfilled'
|
|
1892
|
+
? liveStatsResult.value as { activeConnections: number; channels: number }
|
|
1893
|
+
: { activeConnections: 0, channels: 0 };
|
|
1894
|
+
|
|
1895
|
+
return c.json({
|
|
1896
|
+
project: {
|
|
1897
|
+
totalUsers,
|
|
1898
|
+
totalTables: config.totalTables,
|
|
1899
|
+
databases: config.databases,
|
|
1900
|
+
storageBuckets: config.storageBuckets,
|
|
1901
|
+
serviceKeyCount: config.serviceKeyCount,
|
|
1902
|
+
authProviders: config.authProviders,
|
|
1903
|
+
liveConnections: live.activeConnections,
|
|
1904
|
+
liveChannels: live.channels,
|
|
1905
|
+
devMode: config.devMode,
|
|
1906
|
+
},
|
|
1907
|
+
traffic: {
|
|
1908
|
+
appliedRange: effectiveRange,
|
|
1909
|
+
summary: analytics.summary,
|
|
1910
|
+
timeSeries: analytics.timeSeries,
|
|
1911
|
+
breakdown: analytics.breakdown,
|
|
1912
|
+
topItems: analytics.topItems,
|
|
1913
|
+
},
|
|
1914
|
+
});
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
// ─── Dev Info API ───
|
|
1918
|
+
|
|
1919
|
+
// GET /admin/api/data/dev-info — returns dev mode status and sidecar port
|
|
1920
|
+
const adminGetDevInfo = createRoute({
|
|
1921
|
+
operationId: 'adminGetDevInfo',
|
|
1922
|
+
method: 'get',
|
|
1923
|
+
path: '/dev-info',
|
|
1924
|
+
tags: ['admin'],
|
|
1925
|
+
summary: 'Get dev mode status and sidecar port',
|
|
1926
|
+
responses: {
|
|
1927
|
+
200: { description: 'Dev info', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1928
|
+
},
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
api.openapi(adminGetDevInfo, (c) => {
|
|
1932
|
+
const sidecarPort = c.env.EDGEBASE_DEV_SIDECAR_PORT;
|
|
1933
|
+
return c.json({
|
|
1934
|
+
devMode: !!sidecarPort,
|
|
1935
|
+
sidecarPort: sidecarPort ? parseInt(sidecarPort, 10) : null,
|
|
1936
|
+
});
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
// ─── SQL Console API ───
|
|
1940
|
+
|
|
1941
|
+
// POST /admin/api/data/sql — execute raw SQL via admin JWT (proxies to DatabaseDO)
|
|
1942
|
+
const adminExecuteSql = createRoute({
|
|
1943
|
+
operationId: 'adminExecuteSql',
|
|
1944
|
+
method: 'post',
|
|
1945
|
+
path: '/sql',
|
|
1946
|
+
tags: ['admin'],
|
|
1947
|
+
summary: 'Execute raw SQL query',
|
|
1948
|
+
request: {
|
|
1949
|
+
body: {
|
|
1950
|
+
content: {
|
|
1951
|
+
'application/json': {
|
|
1952
|
+
schema: z.object({
|
|
1953
|
+
namespace: z.string(),
|
|
1954
|
+
id: z.string().optional(),
|
|
1955
|
+
sql: z.string(),
|
|
1956
|
+
params: z.array(z.unknown()).optional(),
|
|
1957
|
+
}).passthrough(),
|
|
1958
|
+
},
|
|
1959
|
+
},
|
|
1960
|
+
required: true,
|
|
1961
|
+
},
|
|
1962
|
+
},
|
|
1963
|
+
responses: {
|
|
1964
|
+
200: { description: 'SQL result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1965
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1966
|
+
404: { description: 'Namespace not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1967
|
+
},
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
api.openapi(adminExecuteSql, async (c) => {
|
|
1971
|
+
const body = await c.req.json<{ namespace: string; id?: string; sql: string; params?: unknown[] }>();
|
|
1972
|
+
if (!body.namespace || !body.sql) {
|
|
1973
|
+
throw new EdgeBaseError(400, 'namespace and sql are required.', undefined, 'validation-failed');
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// ── Block destructive DDL statements in admin SQL Console ──
|
|
1977
|
+
const sqlUpper = body.sql.trim().replace(/\s+/g, ' ').toUpperCase();
|
|
1978
|
+
const destructivePatterns = [
|
|
1979
|
+
/^DROP\s+TABLE/,
|
|
1980
|
+
/^DROP\s+INDEX/,
|
|
1981
|
+
/^DROP\s+TRIGGER/,
|
|
1982
|
+
/^DROP\s+VIEW/,
|
|
1983
|
+
/^ALTER\s+TABLE\s+\S+\s+DROP/,
|
|
1984
|
+
/^TRUNCATE/,
|
|
1985
|
+
/^DELETE\s+FROM\s+\S+\s*$/, // DELETE without WHERE clause
|
|
1986
|
+
/^DELETE\s+FROM\s+\S+\s*;?\s*$/, // DELETE without WHERE (with optional semicolon)
|
|
1987
|
+
];
|
|
1988
|
+
for (const pat of destructivePatterns) {
|
|
1989
|
+
if (pat.test(sqlUpper)) {
|
|
1990
|
+
throw new EdgeBaseError(400, `Destructive SQL blocked: "${body.sql.trim().split(/\s+/).slice(0, 3).join(' ')}..." is not allowed in the admin SQL Console. Use the Schema editor or CLI for DDL operations.`, undefined, 'forbidden');
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
const config = parseConfig(c.env);
|
|
1995
|
+
const databases = config.databases ?? {};
|
|
1996
|
+
if (!databases[body.namespace]) {
|
|
1997
|
+
throw new EdgeBaseError(404, `Namespace not found: ${body.namespace}`, undefined, 'not-found');
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Validate id (no ':' allowed per)
|
|
2001
|
+
if (body.id && body.id.includes(':')) {
|
|
2002
|
+
throw new EdgeBaseError(400, 'Instance ID must not contain ":".', undefined, 'validation-failed');
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const start = Date.now();
|
|
2006
|
+
try {
|
|
2007
|
+
const result = await executeAdminDbQuery({
|
|
2008
|
+
env: c.env,
|
|
2009
|
+
config,
|
|
2010
|
+
namespace: body.namespace,
|
|
2011
|
+
id: body.id,
|
|
2012
|
+
sql: body.sql,
|
|
2013
|
+
params: body.params ?? [],
|
|
2014
|
+
});
|
|
2015
|
+
const elapsed = Date.now() - start;
|
|
2016
|
+
return c.json({ ...result, time: elapsed });
|
|
2017
|
+
} catch (err) {
|
|
2018
|
+
const elapsed = Date.now() - start;
|
|
2019
|
+
return c.json({ code: 400, message: err instanceof Error ? err.message : 'SQL execution failed', time: elapsed }, 400);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
// ─── Batch Import API ───
|
|
2024
|
+
|
|
2025
|
+
// POST /admin/api/data/tables/:name/import — batch import records
|
|
2026
|
+
const adminImportTable = createRoute({
|
|
2027
|
+
operationId: 'adminImportTable',
|
|
2028
|
+
method: 'post',
|
|
2029
|
+
path: '/tables/{name}/import',
|
|
2030
|
+
tags: ['admin'],
|
|
2031
|
+
summary: 'Batch import records into a table',
|
|
2032
|
+
request: {
|
|
2033
|
+
params: z.object({ name: z.string() }),
|
|
2034
|
+
body: {
|
|
2035
|
+
content: {
|
|
2036
|
+
'application/json': {
|
|
2037
|
+
schema: z.object({
|
|
2038
|
+
records: z.array(z.record(z.string(), z.unknown())),
|
|
2039
|
+
mode: z.enum(['create', 'upsert']).optional(),
|
|
2040
|
+
}).passthrough(),
|
|
2041
|
+
},
|
|
2042
|
+
},
|
|
2043
|
+
required: true,
|
|
2044
|
+
},
|
|
2045
|
+
},
|
|
2046
|
+
responses: {
|
|
2047
|
+
200: { description: 'Import result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2048
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2049
|
+
},
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
api.openapi(adminImportTable, async (c) => {
|
|
2053
|
+
const name = c.req.param('name')!;
|
|
2054
|
+
const body = await c.req.json<{ records: Record<string, unknown>[]; mode?: 'create' | 'upsert' }>();
|
|
2055
|
+
|
|
2056
|
+
if (!Array.isArray(body.records) || body.records.length === 0) {
|
|
2057
|
+
throw new EdgeBaseError(400, 'records array is required and must not be empty.', undefined, 'validation-failed');
|
|
2058
|
+
}
|
|
2059
|
+
if (body.records.length > 1000) {
|
|
2060
|
+
throw new EdgeBaseError(400, 'Maximum 1000 records per import.', undefined, 'validation-failed');
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const config = parseConfig(c.env);
|
|
2064
|
+
const namespace = findNamespaceForTable(name, config);
|
|
2065
|
+
const mode = body.mode ?? 'create';
|
|
2066
|
+
const upsert = mode === 'upsert' ? '?upsert=true' : '';
|
|
2067
|
+
|
|
2068
|
+
// D1 route: insert directly via D1 batch API
|
|
2069
|
+
if (shouldRouteToD1(namespace, config)) {
|
|
2070
|
+
const result = await d1BatchImport(c.env, namespace, name, body.records, {
|
|
2071
|
+
upsert: mode === 'upsert',
|
|
2072
|
+
});
|
|
2073
|
+
return c.json({ imported: result.imported, errors: result.errors, total: body.records.length });
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const { stub, doName } = getTableDO(c.env, name, config);
|
|
2077
|
+
|
|
2078
|
+
let imported = 0;
|
|
2079
|
+
const errors: Array<{ row: number; message: string }> = [];
|
|
2080
|
+
|
|
2081
|
+
// Batch create via DO (process in chunks of 50)
|
|
2082
|
+
const chunkSize = 50;
|
|
2083
|
+
for (let i = 0; i < body.records.length; i += chunkSize) {
|
|
2084
|
+
const chunk = body.records.slice(i, i + chunkSize);
|
|
2085
|
+
|
|
2086
|
+
try {
|
|
2087
|
+
const resp = await stub.fetch(new Request(`http://internal/tables/${name}/batch${upsert}`, {
|
|
2088
|
+
method: 'POST',
|
|
2089
|
+
headers: { 'Content-Type': 'application/json', 'X-DO-Name': doName, 'x-internal': 'true' },
|
|
2090
|
+
body: JSON.stringify({ inserts: chunk }),
|
|
2091
|
+
}));
|
|
2092
|
+
|
|
2093
|
+
if (resp.ok) {
|
|
2094
|
+
imported += chunk.length;
|
|
2095
|
+
} else {
|
|
2096
|
+
const errData = await resp.json() as { message?: string };
|
|
2097
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
2098
|
+
errors.push({ row: i + j, message: errData.message ?? 'Batch insert failed' });
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
} catch (err) {
|
|
2102
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
2103
|
+
errors.push({ row: i + j, message: err instanceof Error ? err.message : 'Unknown error' });
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
return c.json({ imported, errors, total: body.records.length });
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
// ─── Rules Test API ───
|
|
2112
|
+
|
|
2113
|
+
// POST /admin/api/data/rules-test — evaluate access rules with simulated auth context
|
|
2114
|
+
const adminRulesTest = createRoute({
|
|
2115
|
+
operationId: 'adminRulesTest',
|
|
2116
|
+
method: 'post',
|
|
2117
|
+
path: '/rules-test',
|
|
2118
|
+
tags: ['admin'],
|
|
2119
|
+
summary: 'Evaluate access rules with simulated auth context',
|
|
2120
|
+
request: {
|
|
2121
|
+
body: {
|
|
2122
|
+
content: {
|
|
2123
|
+
'application/json': {
|
|
2124
|
+
schema: z.object({
|
|
2125
|
+
namespace: z.string(),
|
|
2126
|
+
table: z.string(),
|
|
2127
|
+
auth: z.record(z.string(), z.unknown()).nullable(),
|
|
2128
|
+
record: z.record(z.string(), z.unknown()).optional(),
|
|
2129
|
+
operations: z.array(z.string()),
|
|
2130
|
+
}).passthrough(),
|
|
2131
|
+
},
|
|
2132
|
+
},
|
|
2133
|
+
required: true,
|
|
2134
|
+
},
|
|
2135
|
+
},
|
|
2136
|
+
responses: {
|
|
2137
|
+
200: { description: 'Rules test results', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2138
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2139
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2140
|
+
},
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
api.openapi(adminRulesTest, async (c) => {
|
|
2144
|
+
const body = await c.req.json<{
|
|
2145
|
+
namespace: string;
|
|
2146
|
+
table: string;
|
|
2147
|
+
auth: Record<string, unknown> | null;
|
|
2148
|
+
record?: Record<string, unknown>;
|
|
2149
|
+
operations: string[];
|
|
2150
|
+
}>();
|
|
2151
|
+
|
|
2152
|
+
if (!body.namespace || !body.table || !body.operations?.length) {
|
|
2153
|
+
throw new EdgeBaseError(400, 'namespace, table, and operations are required.', undefined, 'validation-failed');
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
const config = parseConfig(c.env);
|
|
2157
|
+
const dbBlock = (config.databases ?? {})[body.namespace];
|
|
2158
|
+
if (!dbBlock) {
|
|
2159
|
+
throw new EdgeBaseError(404, `Namespace not found: ${body.namespace}`, undefined, 'not-found');
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
const tableConfig = dbBlock.tables?.[body.table];
|
|
2163
|
+
if (!tableConfig) {
|
|
2164
|
+
throw new EdgeBaseError(404, `Table not found: ${body.table}`, undefined, 'not-found');
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
const rules = getTableAccess(tableConfig) ?? {};
|
|
2168
|
+
const dbAccess = getDbAccess(dbBlock)?.access;
|
|
2169
|
+
const results: Array<{ operation: string; allowed: boolean; rule: string; error?: string }> = [];
|
|
2170
|
+
|
|
2171
|
+
for (const op of body.operations) {
|
|
2172
|
+
try {
|
|
2173
|
+
if (op === 'access' && typeof dbAccess === 'function') {
|
|
2174
|
+
const stubCtx = { db: { get: async () => null, exists: async () => false } };
|
|
2175
|
+
const allowed = await dbAccess(body.auth as AuthContext | null, 'test', stubCtx);
|
|
2176
|
+
results.push({ operation: op, allowed: !!allowed, rule: 'databases.' + body.namespace + '.access.access()' });
|
|
2177
|
+
} else if (op === 'access') {
|
|
2178
|
+
results.push({ operation: op, allowed: true, rule: '(no access rule defined — allowed by default)' });
|
|
2179
|
+
} else {
|
|
2180
|
+
const ruleFn = (rules as Record<string, unknown>)[op];
|
|
2181
|
+
if (typeof ruleFn === 'function') {
|
|
2182
|
+
const allowed = await ruleFn(body.auth, body.record ?? {});
|
|
2183
|
+
results.push({ operation: op, allowed: !!allowed, rule: `rules.${op}()` });
|
|
2184
|
+
} else if (ruleFn === undefined) {
|
|
2185
|
+
// No rule defined — check release mode
|
|
2186
|
+
const release = config.release ?? false;
|
|
2187
|
+
results.push({
|
|
2188
|
+
operation: op,
|
|
2189
|
+
allowed: !release,
|
|
2190
|
+
rule: release ? '(no access rule defined — denied in release mode)' : '(no access rule defined — allowed in dev mode)',
|
|
2191
|
+
});
|
|
2192
|
+
} else {
|
|
2193
|
+
results.push({ operation: op, allowed: !!ruleFn, rule: `rules.${op} = ${String(ruleFn)}` });
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
} catch (err) {
|
|
2197
|
+
results.push({
|
|
2198
|
+
operation: op,
|
|
2199
|
+
allowed: false,
|
|
2200
|
+
rule: `access.${op}()`,
|
|
2201
|
+
error: err instanceof Error ? err.message : 'Evaluation error',
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
return c.json({ results });
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
// ─── Functions List API ───
|
|
2210
|
+
|
|
2211
|
+
// GET /admin/api/data/functions — list registered functions from config
|
|
2212
|
+
const adminListFunctions = createRoute({
|
|
2213
|
+
operationId: 'adminListFunctions',
|
|
2214
|
+
method: 'get',
|
|
2215
|
+
path: '/functions',
|
|
2216
|
+
tags: ['admin'],
|
|
2217
|
+
summary: 'List registered functions from config',
|
|
2218
|
+
responses: {
|
|
2219
|
+
200: { description: 'Functions list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2220
|
+
},
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
api.openapi(adminListFunctions, (c) => {
|
|
2224
|
+
try {
|
|
2225
|
+
const config = parseConfig(c.env) as Record<string, unknown>;
|
|
2226
|
+
const functionsConfig = config.functions as Record<string, unknown> | undefined;
|
|
2227
|
+
const functions: Array<{ path: string; methods: string[]; type: string }> = [];
|
|
2228
|
+
|
|
2229
|
+
if (functionsConfig && typeof functionsConfig === 'object') {
|
|
2230
|
+
// Extract function routes from config
|
|
2231
|
+
for (const [path, fn] of Object.entries(functionsConfig)) {
|
|
2232
|
+
if (typeof fn === 'object' && fn !== null) {
|
|
2233
|
+
const fnObj = fn as Record<string, unknown>;
|
|
2234
|
+
const methods = Array.isArray(fnObj.methods) ? fnObj.methods as string[] : ['POST'];
|
|
2235
|
+
const type = (fnObj.type as string) ?? 'endpoint';
|
|
2236
|
+
functions.push({ path, methods, type });
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
return c.json({ functions });
|
|
2242
|
+
} catch {
|
|
2243
|
+
return c.json({ functions: [] });
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
// ─── Config Info API ───
|
|
2248
|
+
|
|
2249
|
+
// GET /admin/api/data/config-info — environment and config overview
|
|
2250
|
+
const adminGetConfigInfo = createRoute({
|
|
2251
|
+
operationId: 'adminGetConfigInfo',
|
|
2252
|
+
method: 'get',
|
|
2253
|
+
path: '/config-info',
|
|
2254
|
+
tags: ['admin'],
|
|
2255
|
+
summary: 'Get environment and config overview',
|
|
2256
|
+
responses: {
|
|
2257
|
+
200: { description: 'Config info', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2258
|
+
},
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
api.openapi(adminGetConfigInfo, (c) => {
|
|
2262
|
+
try {
|
|
2263
|
+
const config = parseConfig(c.env);
|
|
2264
|
+
const sidecarPort = c.env.EDGEBASE_DEV_SIDECAR_PORT;
|
|
2265
|
+
const devMode = !!sidecarPort;
|
|
2266
|
+
|
|
2267
|
+
const databases = Object.entries(config.databases ?? {}).map(([name, db]) => ({
|
|
2268
|
+
name,
|
|
2269
|
+
tableCount: Object.keys(db.tables ?? {}).length,
|
|
2270
|
+
hasAccess: !!db.access?.access,
|
|
2271
|
+
}));
|
|
2272
|
+
|
|
2273
|
+
const storageConfig = config.storage ?? {} as Record<string, unknown>;
|
|
2274
|
+
const buckets = Object.keys((storageConfig as Record<string, unknown>).buckets ?? {});
|
|
2275
|
+
|
|
2276
|
+
// Service keys — show masked preview for admin display
|
|
2277
|
+
const rawServiceKeys = config.serviceKeys ?? [];
|
|
2278
|
+
const serviceKeyList: string[] = Array.isArray(rawServiceKeys)
|
|
2279
|
+
? rawServiceKeys.map((k: string) => {
|
|
2280
|
+
if (typeof k !== 'string' || k.length < 8) return '****';
|
|
2281
|
+
return k.slice(0, 8) + '•'.repeat(Math.min(k.length - 8, 24));
|
|
2282
|
+
})
|
|
2283
|
+
: [];
|
|
2284
|
+
const serviceKeyCount = serviceKeyList.length;
|
|
2285
|
+
|
|
2286
|
+
// Native resources
|
|
2287
|
+
const kvNamespaces = Object.keys(config.kv ?? {});
|
|
2288
|
+
const d1Databases = Object.keys(config.d1 ?? {});
|
|
2289
|
+
const vectorizeIndexes = Object.keys(config.vectorize ?? {});
|
|
2290
|
+
const rateLimiting = buildRateLimitSummary(config);
|
|
2291
|
+
|
|
2292
|
+
return c.json({
|
|
2293
|
+
devMode,
|
|
2294
|
+
release: config.release ?? false,
|
|
2295
|
+
databases,
|
|
2296
|
+
storageBuckets: buckets,
|
|
2297
|
+
serviceKeyCount,
|
|
2298
|
+
serviceKeys: serviceKeyList,
|
|
2299
|
+
bindings: {
|
|
2300
|
+
kv: kvNamespaces,
|
|
2301
|
+
d1: d1Databases,
|
|
2302
|
+
vectorize: vectorizeIndexes,
|
|
2303
|
+
},
|
|
2304
|
+
auth: {
|
|
2305
|
+
providers: config.auth?.allowedOAuthProviders ?? [],
|
|
2306
|
+
anonymousAuth: config.auth?.anonymousAuth ?? false,
|
|
2307
|
+
},
|
|
2308
|
+
rateLimiting,
|
|
2309
|
+
});
|
|
2310
|
+
} catch {
|
|
2311
|
+
return c.json({
|
|
2312
|
+
devMode: false,
|
|
2313
|
+
release: false,
|
|
2314
|
+
databases: [],
|
|
2315
|
+
storageBuckets: [],
|
|
2316
|
+
serviceKeyCount: 0,
|
|
2317
|
+
bindings: { kv: [], d1: [], vectorize: [] },
|
|
2318
|
+
auth: { providers: [], anonymousAuth: false },
|
|
2319
|
+
rateLimiting: [],
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
// ─── Recent Logs API (enhanced polling for real-time) ───
|
|
2325
|
+
|
|
2326
|
+
// GET /admin/api/data/logs/recent — recent request logs with filtering
|
|
2327
|
+
const adminGetRecentLogs = createRoute({
|
|
2328
|
+
operationId: 'adminGetRecentLogs',
|
|
2329
|
+
method: 'get',
|
|
2330
|
+
path: '/logs/recent',
|
|
2331
|
+
tags: ['admin'],
|
|
2332
|
+
summary: 'Get recent request logs with filtering',
|
|
2333
|
+
responses: {
|
|
2334
|
+
200: { description: 'Recent logs', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2335
|
+
},
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
api.openapi(adminGetRecentLogs, async (c) => {
|
|
2339
|
+
const limit = parseInt(c.req.query('limit') || '100', 10);
|
|
2340
|
+
const level = c.req.query('level') || '';
|
|
2341
|
+
const pathFilter = c.req.query('path') || '';
|
|
2342
|
+
const category = c.req.query('category') || '';
|
|
2343
|
+
|
|
2344
|
+
try {
|
|
2345
|
+
const doLogs = await fetchRecentLogsFromDo(c.env, { limit, level, pathFilter, category });
|
|
2346
|
+
if (doLogs !== null) {
|
|
2347
|
+
return c.json({ logs: doLogs, total: doLogs.length });
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const list = await c.env.KV.list({ prefix: 'log:', limit: Math.min(limit, 200) });
|
|
2351
|
+
let logs: Array<Record<string, unknown>> = [];
|
|
2352
|
+
|
|
2353
|
+
for (const key of list.keys) {
|
|
2354
|
+
const value = await c.env.KV.get(key.name, 'json');
|
|
2355
|
+
if (value) logs.push(value as Record<string, unknown>);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// Apply filters
|
|
2359
|
+
if (level) {
|
|
2360
|
+
logs = logs.filter((log) => {
|
|
2361
|
+
return matchesLogLevel(getLogStatusCode(log), level);
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
if (pathFilter) {
|
|
2366
|
+
logs = logs.filter((log) => String(log.path ?? '').includes(pathFilter));
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
if (category) {
|
|
2370
|
+
logs = logs.filter((log) => String(log.category ?? '').toLowerCase() === category.toLowerCase());
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
return c.json({ logs, total: logs.length });
|
|
2374
|
+
} catch {
|
|
2375
|
+
return c.json({ logs: [], total: 0 });
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
// ─── Auth Settings API ───
|
|
2380
|
+
|
|
2381
|
+
// GET /admin/api/data/auth/settings — OAuth provider config
|
|
2382
|
+
const adminGetAuthSettings = createRoute({
|
|
2383
|
+
operationId: 'adminGetAuthSettings',
|
|
2384
|
+
method: 'get',
|
|
2385
|
+
path: '/auth/settings',
|
|
2386
|
+
tags: ['admin'],
|
|
2387
|
+
summary: 'Get OAuth provider config',
|
|
2388
|
+
responses: {
|
|
2389
|
+
200: { description: 'Auth settings', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2390
|
+
},
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
api.openapi(adminGetAuthSettings, (c) => {
|
|
2394
|
+
try {
|
|
2395
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2396
|
+
const config = parseConfig(c.env) as any;
|
|
2397
|
+
const authConfig = config?.auth || {};
|
|
2398
|
+
return c.json({
|
|
2399
|
+
providers: Array.isArray(authConfig.allowedOAuthProviders) ? authConfig.allowedOAuthProviders : [],
|
|
2400
|
+
emailAuth: authConfig.emailAuth !== false,
|
|
2401
|
+
anonymousAuth: !!authConfig.anonymousAuth,
|
|
2402
|
+
allowedRedirectUrls: Array.isArray(authConfig.allowedRedirectUrls) ? authConfig.allowedRedirectUrls : [],
|
|
2403
|
+
session: {
|
|
2404
|
+
accessTokenTTL: authConfig.session?.accessTokenTTL ?? null,
|
|
2405
|
+
refreshTokenTTL: authConfig.session?.refreshTokenTTL ?? null,
|
|
2406
|
+
maxActiveSessions: typeof authConfig.session?.maxActiveSessions === 'number'
|
|
2407
|
+
? authConfig.session.maxActiveSessions
|
|
2408
|
+
: null,
|
|
2409
|
+
},
|
|
2410
|
+
magicLink: {
|
|
2411
|
+
enabled: !!authConfig.magicLink?.enabled,
|
|
2412
|
+
autoCreate: authConfig.magicLink?.autoCreate !== false,
|
|
2413
|
+
tokenTTL: authConfig.magicLink?.tokenTTL ?? null,
|
|
2414
|
+
},
|
|
2415
|
+
emailOtp: {
|
|
2416
|
+
enabled: !!authConfig.emailOtp?.enabled,
|
|
2417
|
+
autoCreate: authConfig.emailOtp?.autoCreate !== false,
|
|
2418
|
+
},
|
|
2419
|
+
passkeys: {
|
|
2420
|
+
enabled: !!authConfig.passkeys?.enabled,
|
|
2421
|
+
rpName: authConfig.passkeys?.rpName ?? null,
|
|
2422
|
+
rpID: authConfig.passkeys?.rpID ?? null,
|
|
2423
|
+
origin: Array.isArray(authConfig.passkeys?.origin)
|
|
2424
|
+
? authConfig.passkeys.origin
|
|
2425
|
+
: authConfig.passkeys?.origin
|
|
2426
|
+
? [authConfig.passkeys.origin]
|
|
2427
|
+
: [],
|
|
2428
|
+
},
|
|
2429
|
+
});
|
|
2430
|
+
} catch {
|
|
2431
|
+
return c.json({
|
|
2432
|
+
providers: [],
|
|
2433
|
+
emailAuth: true,
|
|
2434
|
+
anonymousAuth: false,
|
|
2435
|
+
allowedRedirectUrls: [],
|
|
2436
|
+
session: {
|
|
2437
|
+
accessTokenTTL: null,
|
|
2438
|
+
refreshTokenTTL: null,
|
|
2439
|
+
maxActiveSessions: null,
|
|
2440
|
+
},
|
|
2441
|
+
magicLink: {
|
|
2442
|
+
enabled: false,
|
|
2443
|
+
autoCreate: true,
|
|
2444
|
+
tokenTTL: null,
|
|
2445
|
+
},
|
|
2446
|
+
emailOtp: {
|
|
2447
|
+
enabled: false,
|
|
2448
|
+
autoCreate: true,
|
|
2449
|
+
},
|
|
2450
|
+
passkeys: {
|
|
2451
|
+
enabled: false,
|
|
2452
|
+
rpName: null,
|
|
2453
|
+
rpID: null,
|
|
2454
|
+
origin: [],
|
|
2455
|
+
},
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
// GET /admin/api/data/email/templates — read email template/subject config
|
|
2461
|
+
const adminGetEmailTemplates = createRoute({
|
|
2462
|
+
operationId: 'adminGetEmailTemplates',
|
|
2463
|
+
method: 'get',
|
|
2464
|
+
path: '/email/templates',
|
|
2465
|
+
tags: ['admin'],
|
|
2466
|
+
summary: 'Get email template and subject config',
|
|
2467
|
+
responses: {
|
|
2468
|
+
200: { description: 'Email template config', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2469
|
+
},
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
api.openapi(adminGetEmailTemplates, (c) => {
|
|
2473
|
+
try {
|
|
2474
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2475
|
+
const config = parseConfig(c.env) as any;
|
|
2476
|
+
const emailConfig = config?.email || {};
|
|
2477
|
+
return c.json({
|
|
2478
|
+
appName: emailConfig.appName || 'EdgeBase',
|
|
2479
|
+
configured: !!emailConfig.provider,
|
|
2480
|
+
subjects: {
|
|
2481
|
+
verification: emailConfig.subjects?.verification || null,
|
|
2482
|
+
passwordReset: emailConfig.subjects?.passwordReset || null,
|
|
2483
|
+
magicLink: emailConfig.subjects?.magicLink || null,
|
|
2484
|
+
emailOtp: emailConfig.subjects?.emailOtp || null,
|
|
2485
|
+
emailChange: emailConfig.subjects?.emailChange || null,
|
|
2486
|
+
},
|
|
2487
|
+
templates: {
|
|
2488
|
+
verification: emailConfig.templates?.verification || null,
|
|
2489
|
+
passwordReset: emailConfig.templates?.passwordReset || null,
|
|
2490
|
+
magicLink: emailConfig.templates?.magicLink || null,
|
|
2491
|
+
emailOtp: emailConfig.templates?.emailOtp || null,
|
|
2492
|
+
emailChange: emailConfig.templates?.emailChange || null,
|
|
2493
|
+
},
|
|
2494
|
+
});
|
|
2495
|
+
} catch {
|
|
2496
|
+
return c.json({
|
|
2497
|
+
appName: 'EdgeBase',
|
|
2498
|
+
configured: false,
|
|
2499
|
+
subjects: { verification: null, passwordReset: null, magicLink: null, emailOtp: null, emailChange: null },
|
|
2500
|
+
templates: { verification: null, passwordReset: null, magicLink: null, emailOtp: null, emailChange: null },
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
// ─── User Create / Delete / MFA (Admin JWT proxied from admin-auth) ───
|
|
2506
|
+
|
|
2507
|
+
// POST /admin/api/data/users — create a new user
|
|
2508
|
+
const adminCreateUser = createRoute({
|
|
2509
|
+
operationId: 'adminCreateUser',
|
|
2510
|
+
method: 'post',
|
|
2511
|
+
path: '/users',
|
|
2512
|
+
tags: ['admin'],
|
|
2513
|
+
summary: 'Create a new user',
|
|
2514
|
+
request: {
|
|
2515
|
+
body: {
|
|
2516
|
+
content: {
|
|
2517
|
+
'application/json': {
|
|
2518
|
+
schema: z.object({
|
|
2519
|
+
email: z.string(),
|
|
2520
|
+
password: z.string(),
|
|
2521
|
+
displayName: z.string().optional(),
|
|
2522
|
+
role: z.string().optional(),
|
|
2523
|
+
}).passthrough(),
|
|
2524
|
+
},
|
|
2525
|
+
},
|
|
2526
|
+
required: true,
|
|
2527
|
+
},
|
|
2528
|
+
},
|
|
2529
|
+
responses: {
|
|
2530
|
+
201: { description: 'User created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2531
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2532
|
+
409: { description: 'Conflict', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2533
|
+
},
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
api.openapi(adminCreateUser, async (c) => {
|
|
2537
|
+
const body = await c.req.json<{ email: string; password: string; displayName?: string; role?: string }>();
|
|
2538
|
+
if (!body.email || !body.password) throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'validation-failed');
|
|
2539
|
+
body.email = body.email.trim().toLowerCase();
|
|
2540
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
2541
|
+
if (body.password.length < 8) throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
|
|
2542
|
+
if (body.password.length > 256) throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
|
|
2543
|
+
body.role = normalizeOptionalRole(body.role);
|
|
2544
|
+
if (body.displayName && body.displayName.length > 200) throw new EdgeBaseError(400, 'Display name must not exceed 200 characters.', undefined, 'display-name-too-long');
|
|
2545
|
+
|
|
2546
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
2547
|
+
const user = await createManagedAdminUser(
|
|
2548
|
+
getAuthDb(c),
|
|
2549
|
+
{
|
|
2550
|
+
userId: generateId(),
|
|
2551
|
+
email: body.email,
|
|
2552
|
+
passwordHash: await hashPassword(body.password),
|
|
2553
|
+
displayName: body.displayName,
|
|
2554
|
+
role: body.role || 'user',
|
|
2555
|
+
verified: true,
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
executionCtx: c.executionCtx,
|
|
2559
|
+
kv: c.env.KV,
|
|
2560
|
+
},
|
|
2561
|
+
);
|
|
2562
|
+
|
|
2563
|
+
return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) }, 201);
|
|
2564
|
+
});
|
|
2565
|
+
|
|
2566
|
+
// DELETE /admin/api/data/users/:id — delete a user completely
|
|
2567
|
+
const adminDeleteUser = createRoute({
|
|
2568
|
+
operationId: 'adminDeleteUser',
|
|
2569
|
+
method: 'delete',
|
|
2570
|
+
path: '/users/{id}',
|
|
2571
|
+
tags: ['admin'],
|
|
2572
|
+
summary: 'Delete a user completely',
|
|
2573
|
+
request: {
|
|
2574
|
+
params: z.object({ id: z.string() }),
|
|
2575
|
+
},
|
|
2576
|
+
responses: {
|
|
2577
|
+
200: { description: 'User deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2578
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2579
|
+
},
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
api.openapi(adminDeleteUser, async (c) => {
|
|
2583
|
+
const userId = c.req.param('id')!;
|
|
2584
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
2585
|
+
const deleted = await deleteManagedAdminUser(getAuthDb(c), userId, {
|
|
2586
|
+
executionCtx: c.executionCtx,
|
|
2587
|
+
kv: c.env.KV,
|
|
2588
|
+
});
|
|
2589
|
+
if (!deleted) {
|
|
2590
|
+
return c.json({ code: 404, message: 'User not found.' }, 404);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
return c.json({ ok: true });
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
// DELETE /admin/api/data/users/:id/mfa — disable MFA for a user
|
|
2597
|
+
const adminDeleteUserMfa = createRoute({
|
|
2598
|
+
operationId: 'adminDeleteUserMfa',
|
|
2599
|
+
method: 'delete',
|
|
2600
|
+
path: '/users/{id}/mfa',
|
|
2601
|
+
tags: ['admin'],
|
|
2602
|
+
summary: 'Disable MFA for a user',
|
|
2603
|
+
request: {
|
|
2604
|
+
params: z.object({ id: z.string() }),
|
|
2605
|
+
},
|
|
2606
|
+
responses: {
|
|
2607
|
+
200: { description: 'MFA disabled', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2608
|
+
},
|
|
2609
|
+
});
|
|
2610
|
+
|
|
2611
|
+
api.openapi(adminDeleteUserMfa, async (c) => {
|
|
2612
|
+
const userId = c.req.param('id')!;
|
|
2613
|
+
await authService.disableMfa(getAuthDb(c), userId);
|
|
2614
|
+
return c.json({ ok: true, message: 'MFA disabled.' });
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
// POST /admin/api/data/users/:id/send-password-reset — send password reset email for a user
|
|
2618
|
+
const adminSendPasswordReset = createRoute({
|
|
2619
|
+
operationId: 'adminSendPasswordReset',
|
|
2620
|
+
method: 'post',
|
|
2621
|
+
path: '/users/{id}/send-password-reset',
|
|
2622
|
+
tags: ['admin'],
|
|
2623
|
+
summary: 'Send password reset email for a user',
|
|
2624
|
+
request: {
|
|
2625
|
+
params: z.object({ id: z.string() }),
|
|
2626
|
+
},
|
|
2627
|
+
responses: {
|
|
2628
|
+
200: { description: 'Reset email sent', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2629
|
+
404: { description: 'User not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2630
|
+
},
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
api.openapi(adminSendPasswordReset, async (c) => {
|
|
2634
|
+
const userId = c.req.param('id')!;
|
|
2635
|
+
const user = await authService.getUserById(getAuthDb(c), userId);
|
|
2636
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2637
|
+
if (!user.email) throw new EdgeBaseError(400, 'User has no email address.', undefined, 'validation-failed');
|
|
2638
|
+
|
|
2639
|
+
// Create email token in D1
|
|
2640
|
+
const token = generateId();
|
|
2641
|
+
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); // 1 hour
|
|
2642
|
+
await authService.createEmailToken(getAuthDb(c), {
|
|
2643
|
+
token,
|
|
2644
|
+
userId,
|
|
2645
|
+
type: 'password_reset',
|
|
2646
|
+
expiresAt,
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
return c.json({ ok: true, token, message: 'Password reset token created.' });
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
// ─── Storage Upload (Admin JWT) ───
|
|
2653
|
+
|
|
2654
|
+
// POST /admin/api/data/storage/buckets/:name/upload — upload file to R2
|
|
2655
|
+
const adminUploadFile = createRoute({
|
|
2656
|
+
operationId: 'adminUploadFile',
|
|
2657
|
+
method: 'post',
|
|
2658
|
+
path: '/storage/buckets/{name}/upload',
|
|
2659
|
+
tags: ['admin'],
|
|
2660
|
+
summary: 'Upload file to R2 storage',
|
|
2661
|
+
request: {
|
|
2662
|
+
params: z.object({ name: z.string() }),
|
|
2663
|
+
body: {
|
|
2664
|
+
content: { 'multipart/form-data': { schema: z.object({}).passthrough() } },
|
|
2665
|
+
required: true,
|
|
2666
|
+
},
|
|
2667
|
+
},
|
|
2668
|
+
responses: {
|
|
2669
|
+
201: { description: 'File uploaded', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2670
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2671
|
+
404: { description: 'Bucket not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2672
|
+
},
|
|
2673
|
+
});
|
|
2674
|
+
|
|
2675
|
+
api.openapi(adminUploadFile, async (c) => {
|
|
2676
|
+
const bucketName = c.req.param('name')!;
|
|
2677
|
+
|
|
2678
|
+
// Validate bucket exists
|
|
2679
|
+
const config = parseConfig(c.env);
|
|
2680
|
+
const buckets = Object.keys(config?.storage?.buckets || {});
|
|
2681
|
+
if (!buckets.includes(bucketName)) {
|
|
2682
|
+
throw new EdgeBaseError(404, `Bucket "${bucketName}" not found.`, undefined, 'not-found');
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
let formData: FormData;
|
|
2686
|
+
try {
|
|
2687
|
+
formData = await c.req.formData();
|
|
2688
|
+
} catch {
|
|
2689
|
+
throw new EdgeBaseError(400, 'Expected multipart/form-data request body.', undefined, 'validation-failed');
|
|
2690
|
+
}
|
|
2691
|
+
const file = formData.get('file') as File | null;
|
|
2692
|
+
if (!file) throw new EdgeBaseError(400, 'No file provided. Use "file" form field.', undefined, 'validation-failed');
|
|
2693
|
+
|
|
2694
|
+
const key = (formData.get('key') as string) || file.name;
|
|
2695
|
+
if (!key) throw new EdgeBaseError(400, 'File key is required.', undefined, 'validation-failed');
|
|
2696
|
+
|
|
2697
|
+
const fullKey = `${bucketName}/${key}`;
|
|
2698
|
+
const result = await c.env.STORAGE.put(fullKey, file.stream(), {
|
|
2699
|
+
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
|
2700
|
+
customMetadata: { uploadedBy: 'admin', originalName: file.name },
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
if (!result) throw new EdgeBaseError(500, 'Failed to upload file to R2.', undefined, 'internal-error');
|
|
2704
|
+
|
|
2705
|
+
return c.json({
|
|
2706
|
+
ok: true,
|
|
2707
|
+
key,
|
|
2708
|
+
size: file.size,
|
|
2709
|
+
contentType: file.type || 'application/octet-stream',
|
|
2710
|
+
}, 201);
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
// ─── Push Management (Admin JWT) ───
|
|
2714
|
+
|
|
2715
|
+
// GET /admin/api/data/push/tokens — list push tokens for a user
|
|
2716
|
+
const adminGetPushTokens = createRoute({
|
|
2717
|
+
operationId: 'adminGetPushTokens',
|
|
2718
|
+
method: 'get',
|
|
2719
|
+
path: '/push/tokens',
|
|
2720
|
+
tags: ['admin'],
|
|
2721
|
+
summary: 'List push tokens for a user',
|
|
2722
|
+
responses: {
|
|
2723
|
+
200: { description: 'Push tokens', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2724
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2725
|
+
},
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
api.openapi(adminGetPushTokens, async (c) => {
|
|
2729
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
2730
|
+
const userId = c.req.query('userId');
|
|
2731
|
+
if (!userId) throw new EdgeBaseError(400, 'userId query parameter is required.', undefined, 'validation-failed');
|
|
2732
|
+
const devices = await getDevicesForUser({ kv: c.env.KV, authDb: getAuthDb(c) }, userId);
|
|
2733
|
+
return c.json({ items: devices });
|
|
2734
|
+
});
|
|
2735
|
+
|
|
2736
|
+
// GET /admin/api/data/push/logs — get push notification logs
|
|
2737
|
+
const adminGetPushLogs = createRoute({
|
|
2738
|
+
operationId: 'adminGetPushLogs',
|
|
2739
|
+
method: 'get',
|
|
2740
|
+
path: '/push/logs',
|
|
2741
|
+
tags: ['admin'],
|
|
2742
|
+
summary: 'Get push notification logs',
|
|
2743
|
+
responses: {
|
|
2744
|
+
200: { description: 'Push logs', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2745
|
+
},
|
|
2746
|
+
});
|
|
2747
|
+
|
|
2748
|
+
api.openapi(adminGetPushLogs, async (c) => {
|
|
2749
|
+
const userId = c.req.query('userId');
|
|
2750
|
+
const limit = parseInt(c.req.query('limit') || '50', 10);
|
|
2751
|
+
|
|
2752
|
+
if (userId) {
|
|
2753
|
+
const logs = await getPushLogs(c.env.KV, userId, limit);
|
|
2754
|
+
return c.json({ items: logs });
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// List all recent push logs across all users
|
|
2758
|
+
const result = await c.env.KV.list({ prefix: 'push:log:', limit: Math.min(limit, 200) });
|
|
2759
|
+
const items: Array<Record<string, unknown>> = [];
|
|
2760
|
+
for (const key of result.keys) {
|
|
2761
|
+
const raw = await c.env.KV.get(key.name, 'json');
|
|
2762
|
+
if (raw) items.push(raw as Record<string, unknown>);
|
|
2763
|
+
}
|
|
2764
|
+
return c.json({ items });
|
|
2765
|
+
});
|
|
2766
|
+
|
|
2767
|
+
// POST /admin/api/data/push/test-send — test send push notification
|
|
2768
|
+
const adminTestPushSend = createRoute({
|
|
2769
|
+
operationId: 'adminTestPushSend',
|
|
2770
|
+
method: 'post',
|
|
2771
|
+
path: '/push/test-send',
|
|
2772
|
+
tags: ['admin'],
|
|
2773
|
+
summary: 'Test send push notification',
|
|
2774
|
+
request: {
|
|
2775
|
+
body: {
|
|
2776
|
+
content: {
|
|
2777
|
+
'application/json': {
|
|
2778
|
+
schema: z.object({
|
|
2779
|
+
userId: z.string(),
|
|
2780
|
+
title: z.string(),
|
|
2781
|
+
body: z.string(),
|
|
2782
|
+
data: z.record(z.string(), z.string()).optional(),
|
|
2783
|
+
}).passthrough(),
|
|
2784
|
+
},
|
|
2785
|
+
},
|
|
2786
|
+
required: true,
|
|
2787
|
+
},
|
|
2788
|
+
},
|
|
2789
|
+
responses: {
|
|
2790
|
+
200: { description: 'Push sent', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2791
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2792
|
+
404: { description: 'No tokens', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2793
|
+
},
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
api.openapi(adminTestPushSend, async (c) => {
|
|
2797
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
2798
|
+
const body = await c.req.json<{ userId: string; title: string; body: string; data?: Record<string, string> }>();
|
|
2799
|
+
if (!body.userId || !body.title) throw new EdgeBaseError(400, 'userId and title are required.', undefined, 'validation-failed');
|
|
2800
|
+
|
|
2801
|
+
const devices = await getDevicesForUser({ kv: c.env.KV, authDb: getAuthDb(c) }, body.userId);
|
|
2802
|
+
if (devices.length === 0) throw new EdgeBaseError(404, 'No push tokens registered for this user.', undefined, 'not-found');
|
|
2803
|
+
|
|
2804
|
+
// Forward to internal push send logic via the push route
|
|
2805
|
+
const config = parseConfig(c.env);
|
|
2806
|
+
if (!config.push?.fcm) throw new EdgeBaseError(400, 'Push notifications not configured. Set push.fcm in config.', undefined, 'feature-not-enabled');
|
|
2807
|
+
|
|
2808
|
+
// Use dynamic import to avoid circular dependency
|
|
2809
|
+
const { createPushProvider } = await import('../lib/push-provider.js');
|
|
2810
|
+
const { storePushLog } = await import('../lib/push-token.js');
|
|
2811
|
+
const provider = createPushProvider(config.push, c.env);
|
|
2812
|
+
if (!provider) throw new EdgeBaseError(400, 'Push provider could not be initialized. Check push.fcm config and PUSH_FCM_SERVICE_ACCOUNT env.', undefined, 'internal-error');
|
|
2813
|
+
|
|
2814
|
+
let sent = 0;
|
|
2815
|
+
let failed = 0;
|
|
2816
|
+
|
|
2817
|
+
for (const device of devices) {
|
|
2818
|
+
try {
|
|
2819
|
+
await provider.send({
|
|
2820
|
+
token: device.token,
|
|
2821
|
+
platform: device.platform,
|
|
2822
|
+
payload: {
|
|
2823
|
+
title: body.title,
|
|
2824
|
+
body: body.body,
|
|
2825
|
+
data: body.data,
|
|
2826
|
+
},
|
|
2827
|
+
});
|
|
2828
|
+
sent++;
|
|
2829
|
+
} catch {
|
|
2830
|
+
failed++;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
await storePushLog(c.env.KV, body.userId, {
|
|
2835
|
+
sentAt: new Date().toISOString(),
|
|
2836
|
+
userId: body.userId,
|
|
2837
|
+
platform: 'admin-test',
|
|
2838
|
+
status: failed === 0 ? 'sent' : 'failed',
|
|
2839
|
+
});
|
|
2840
|
+
|
|
2841
|
+
return c.json({ ok: true, sent, failed, total: devices.length });
|
|
2842
|
+
});
|
|
2843
|
+
|
|
2844
|
+
// ─── Backup Proxy (Admin JWT) ───
|
|
2845
|
+
|
|
2846
|
+
// POST /admin/api/data/backup/list-dos
|
|
2847
|
+
const adminBackupListDOs = createRoute({
|
|
2848
|
+
operationId: 'adminBackupListDOs',
|
|
2849
|
+
method: 'post',
|
|
2850
|
+
path: '/backup/list-dos',
|
|
2851
|
+
tags: ['admin'],
|
|
2852
|
+
summary: 'List Durable Objects for backup',
|
|
2853
|
+
responses: {
|
|
2854
|
+
200: { description: 'DO list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2855
|
+
},
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
api.openapi(adminBackupListDOs, async (c) => {
|
|
2859
|
+
const config = parseConfig(c.env);
|
|
2860
|
+
const dos: Array<{ doName: string; type: string; namespace: string }> = [];
|
|
2861
|
+
|
|
2862
|
+
// Database DOs
|
|
2863
|
+
for (const [namespace, _dbBlock] of Object.entries(config.databases ?? {})) {
|
|
2864
|
+
const doName = getDbDoName(namespace);
|
|
2865
|
+
dos.push({ doName, type: 'database', namespace });
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
return c.json({ dos, total: dos.length });
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
// POST /admin/api/data/backup/dump-do
|
|
2872
|
+
const adminBackupDumpDO = createRoute({
|
|
2873
|
+
operationId: 'adminBackupDumpDO',
|
|
2874
|
+
method: 'post',
|
|
2875
|
+
path: '/backup/dump-do',
|
|
2876
|
+
tags: ['admin'],
|
|
2877
|
+
summary: 'Dump a Durable Object for backup',
|
|
2878
|
+
request: {
|
|
2879
|
+
body: {
|
|
2880
|
+
content: {
|
|
2881
|
+
'application/json': {
|
|
2882
|
+
schema: z.object({
|
|
2883
|
+
doName: z.string(),
|
|
2884
|
+
type: z.string(),
|
|
2885
|
+
}).passthrough(),
|
|
2886
|
+
},
|
|
2887
|
+
},
|
|
2888
|
+
required: true,
|
|
2889
|
+
},
|
|
2890
|
+
},
|
|
2891
|
+
responses: {
|
|
2892
|
+
200: { description: 'DO dump', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2893
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2894
|
+
},
|
|
2895
|
+
});
|
|
2896
|
+
|
|
2897
|
+
api.openapi(adminBackupDumpDO, async (c) => {
|
|
2898
|
+
const body = await c.req.json<{ doName: string; type: string }>();
|
|
2899
|
+
if (!body.doName || !body.type) throw new EdgeBaseError(400, 'doName and type are required.', undefined, 'validation-failed');
|
|
2900
|
+
|
|
2901
|
+
const binding = body.type === 'auth' ? c.env.AUTH : c.env.DATABASE;
|
|
2902
|
+
const stub = binding.get(binding.idFromName(body.doName));
|
|
2903
|
+
|
|
2904
|
+
const resp = await stub.fetch(new Request('http://internal/internal/backup/dump', {
|
|
2905
|
+
method: 'GET',
|
|
2906
|
+
headers: { 'X-DO-Name': body.doName },
|
|
2907
|
+
}));
|
|
2908
|
+
if (!resp.ok) throw new EdgeBaseError(resp.status, `Failed to dump DO: ${body.doName}`, undefined, 'internal-error');
|
|
2909
|
+
|
|
2910
|
+
const data = await resp.json();
|
|
2911
|
+
return c.json({ ...data as Record<string, unknown>, doName: body.doName, type: body.type });
|
|
2912
|
+
});
|
|
2913
|
+
|
|
2914
|
+
// POST /admin/api/data/backup/restore-do
|
|
2915
|
+
const adminBackupRestoreDO = createRoute({
|
|
2916
|
+
operationId: 'adminBackupRestoreDO',
|
|
2917
|
+
method: 'post',
|
|
2918
|
+
path: '/backup/restore-do',
|
|
2919
|
+
tags: ['admin'],
|
|
2920
|
+
summary: 'Restore a Durable Object from backup',
|
|
2921
|
+
request: {
|
|
2922
|
+
body: {
|
|
2923
|
+
content: {
|
|
2924
|
+
'application/json': {
|
|
2925
|
+
schema: z.object({
|
|
2926
|
+
doName: z.string(),
|
|
2927
|
+
type: z.string(),
|
|
2928
|
+
tables: z.record(z.string(), z.unknown()),
|
|
2929
|
+
}).passthrough(),
|
|
2930
|
+
},
|
|
2931
|
+
},
|
|
2932
|
+
required: true,
|
|
2933
|
+
},
|
|
2934
|
+
},
|
|
2935
|
+
responses: {
|
|
2936
|
+
200: { description: 'DO restored', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2937
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2938
|
+
},
|
|
2939
|
+
});
|
|
2940
|
+
|
|
2941
|
+
api.openapi(adminBackupRestoreDO, async (c) => {
|
|
2942
|
+
const body = await c.req.json<{ doName: string; type: string; tables: Record<string, unknown> }>();
|
|
2943
|
+
if (!body.doName || !body.type || !body.tables) throw new EdgeBaseError(400, 'doName, type and tables are required.', undefined, 'validation-failed');
|
|
2944
|
+
|
|
2945
|
+
const binding = body.type === 'auth' ? c.env.AUTH : c.env.DATABASE;
|
|
2946
|
+
const stub = binding.get(binding.idFromName(body.doName));
|
|
2947
|
+
|
|
2948
|
+
const resp = await stub.fetch(new Request('http://internal/internal/backup/restore', {
|
|
2949
|
+
method: 'POST',
|
|
2950
|
+
headers: { 'Content-Type': 'application/json', 'X-DO-Name': body.doName },
|
|
2951
|
+
body: JSON.stringify({ tables: body.tables }),
|
|
2952
|
+
}));
|
|
2953
|
+
if (!resp.ok) throw new EdgeBaseError(resp.status, `Failed to restore DO: ${body.doName}`, undefined, 'internal-error');
|
|
2954
|
+
|
|
2955
|
+
const data = await resp.json();
|
|
2956
|
+
return c.json(data);
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
// POST /admin/api/data/backup/dump-d1
|
|
2960
|
+
const adminBackupDumpD1 = createRoute({
|
|
2961
|
+
operationId: 'adminBackupDumpD1',
|
|
2962
|
+
method: 'post',
|
|
2963
|
+
path: '/backup/dump-d1',
|
|
2964
|
+
tags: ['admin'],
|
|
2965
|
+
summary: 'Dump D1 database for backup',
|
|
2966
|
+
responses: {
|
|
2967
|
+
200: { description: 'D1 dump', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2968
|
+
},
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
api.openapi(adminBackupDumpD1, async (c) => {
|
|
2972
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
2973
|
+
const tables: Record<string, unknown[]> = {};
|
|
2974
|
+
|
|
2975
|
+
for (const tbl of AUTH_BACKUP_TABLES) {
|
|
2976
|
+
try {
|
|
2977
|
+
tables[tbl] = await getAuthDb(c).query(`SELECT * FROM ${quoteSqlIdentifier(tbl)}`);
|
|
2978
|
+
} catch {
|
|
2979
|
+
tables[tbl] = [];
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
return c.json({ type: 'd1', tables, timestamp: new Date().toISOString() });
|
|
2984
|
+
});
|
|
2985
|
+
|
|
2986
|
+
// POST /admin/api/data/backup/restore-d1
|
|
2987
|
+
const adminBackupRestoreD1 = createRoute({
|
|
2988
|
+
operationId: 'adminBackupRestoreD1',
|
|
2989
|
+
method: 'post',
|
|
2990
|
+
path: '/backup/restore-d1',
|
|
2991
|
+
tags: ['admin'],
|
|
2992
|
+
summary: 'Restore D1 database from backup',
|
|
2993
|
+
request: {
|
|
2994
|
+
body: {
|
|
2995
|
+
content: {
|
|
2996
|
+
'application/json': {
|
|
2997
|
+
schema: z.object({
|
|
2998
|
+
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
|
|
2999
|
+
}).passthrough(),
|
|
3000
|
+
},
|
|
3001
|
+
},
|
|
3002
|
+
required: true,
|
|
3003
|
+
},
|
|
3004
|
+
},
|
|
3005
|
+
responses: {
|
|
3006
|
+
200: { description: 'D1 restored', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3007
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3008
|
+
},
|
|
3009
|
+
});
|
|
3010
|
+
|
|
3011
|
+
api.openapi(adminBackupRestoreD1, async (c) => {
|
|
3012
|
+
const body = await c.req.json<{ tables: Record<string, unknown[]> }>();
|
|
3013
|
+
if (!body.tables) throw new EdgeBaseError(400, 'tables object is required.', undefined, 'validation-failed');
|
|
3014
|
+
|
|
3015
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
3016
|
+
|
|
3017
|
+
const statements: Array<{ sql: string; params?: unknown[] }> = [];
|
|
3018
|
+
let restored = 0;
|
|
3019
|
+
for (const [tableName, rows] of Object.entries(body.tables)) {
|
|
3020
|
+
if (!AUTH_BACKUP_TABLE_SET.has(tableName)) {
|
|
3021
|
+
throw new EdgeBaseError(400, `Unsupported backup table: ${tableName}`, undefined, 'validation-failed');
|
|
3022
|
+
}
|
|
3023
|
+
if (!Array.isArray(rows)) {
|
|
3024
|
+
throw new EdgeBaseError(400, `Backup rows for ${tableName} must be an array.`, undefined, 'validation-failed');
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
statements.push({ sql: `DELETE FROM ${quoteSqlIdentifier(tableName)}` });
|
|
3028
|
+
|
|
3029
|
+
for (const row of rows) {
|
|
3030
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
3031
|
+
throw new EdgeBaseError(400, `Backup rows for ${tableName} must be objects.`, undefined, 'validation-failed');
|
|
3032
|
+
}
|
|
3033
|
+
const cols = Object.keys(row as Record<string, unknown>);
|
|
3034
|
+
if (cols.length === 0) continue;
|
|
3035
|
+
const vals = Object.values(row as Record<string, unknown>);
|
|
3036
|
+
const placeholders = cols.map(() => '?').join(',');
|
|
3037
|
+
const quotedCols = cols.map(quoteSqlIdentifier).join(', ');
|
|
3038
|
+
statements.push({
|
|
3039
|
+
sql: `INSERT INTO ${quoteSqlIdentifier(tableName)} (${quotedCols}) VALUES (${placeholders})`,
|
|
3040
|
+
params: vals,
|
|
3041
|
+
});
|
|
3042
|
+
restored++;
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
await getAuthDb(c).batch(statements);
|
|
3047
|
+
|
|
3048
|
+
return c.json({ ok: true, restored });
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
// POST /admin/api/data/backup/dump-data
|
|
3052
|
+
const adminBackupDumpData = createRoute({
|
|
3053
|
+
operationId: 'adminBackupDumpData',
|
|
3054
|
+
method: 'post',
|
|
3055
|
+
path: '/backup/dump-data',
|
|
3056
|
+
tags: ['admin'],
|
|
3057
|
+
summary: 'Dump data namespace tables for admin-side migrations',
|
|
3058
|
+
request: {
|
|
3059
|
+
body: {
|
|
3060
|
+
content: {
|
|
3061
|
+
'application/json': {
|
|
3062
|
+
schema: z.object({
|
|
3063
|
+
namespace: z.string(),
|
|
3064
|
+
}).passthrough(),
|
|
3065
|
+
},
|
|
3066
|
+
},
|
|
3067
|
+
required: true,
|
|
3068
|
+
},
|
|
3069
|
+
},
|
|
3070
|
+
responses: {
|
|
3071
|
+
200: { description: 'Namespace dump', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3072
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3073
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3074
|
+
},
|
|
3075
|
+
});
|
|
3076
|
+
|
|
3077
|
+
api.openapi(adminBackupDumpData, async (c) => {
|
|
3078
|
+
const { namespace } = await c.req.json<{ namespace: string }>();
|
|
3079
|
+
if (!namespace) throw new EdgeBaseError(400, 'namespace is required.', undefined, 'validation-failed');
|
|
3080
|
+
|
|
3081
|
+
const config = parseConfig(c.env);
|
|
3082
|
+
const dbBlock = config.databases?.[namespace];
|
|
3083
|
+
if (!dbBlock) throw new EdgeBaseError(404, `Namespace '${namespace}' not found in config.`, undefined, 'not-found');
|
|
3084
|
+
|
|
3085
|
+
const tableNames = Object.keys(dbBlock.tables ?? {});
|
|
3086
|
+
const tables = await dumpNamespaceTables(c.env, config, namespace, {
|
|
3087
|
+
includeMeta: true,
|
|
3088
|
+
tableNames,
|
|
3089
|
+
});
|
|
3090
|
+
|
|
3091
|
+
return c.json({
|
|
3092
|
+
type: 'data',
|
|
3093
|
+
namespace,
|
|
3094
|
+
tables,
|
|
3095
|
+
tableOrder: tableNames,
|
|
3096
|
+
timestamp: new Date().toISOString(),
|
|
3097
|
+
});
|
|
3098
|
+
});
|
|
3099
|
+
|
|
3100
|
+
// POST /admin/api/data/backup/restore-data
|
|
3101
|
+
const adminBackupRestoreData = createRoute({
|
|
3102
|
+
operationId: 'adminBackupRestoreData',
|
|
3103
|
+
method: 'post',
|
|
3104
|
+
path: '/backup/restore-data',
|
|
3105
|
+
tags: ['admin'],
|
|
3106
|
+
summary: 'Restore data namespace tables for admin-side migrations',
|
|
3107
|
+
request: {
|
|
3108
|
+
body: {
|
|
3109
|
+
content: {
|
|
3110
|
+
'application/json': {
|
|
3111
|
+
schema: z.object({
|
|
3112
|
+
namespace: z.string(),
|
|
3113
|
+
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
|
|
3114
|
+
skipWipe: z.boolean().optional(),
|
|
3115
|
+
}).passthrough(),
|
|
3116
|
+
},
|
|
3117
|
+
},
|
|
3118
|
+
required: true,
|
|
3119
|
+
},
|
|
3120
|
+
},
|
|
3121
|
+
responses: {
|
|
3122
|
+
200: { description: 'Namespace restored', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3123
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3124
|
+
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3125
|
+
},
|
|
3126
|
+
});
|
|
3127
|
+
|
|
3128
|
+
api.openapi(adminBackupRestoreData, async (c) => {
|
|
3129
|
+
const body = await c.req.json<{
|
|
3130
|
+
namespace: string;
|
|
3131
|
+
tables: Record<string, Array<Record<string, unknown>>>;
|
|
3132
|
+
skipWipe?: boolean;
|
|
3133
|
+
}>();
|
|
3134
|
+
if (!body.namespace) throw new EdgeBaseError(400, 'namespace is required.', undefined, 'validation-failed');
|
|
3135
|
+
if (!body.tables) throw new EdgeBaseError(400, 'tables data is required.', undefined, 'validation-failed');
|
|
3136
|
+
|
|
3137
|
+
const config = parseConfig(c.env);
|
|
3138
|
+
await restoreAdminNamespaceTables(c.env, config, body);
|
|
3139
|
+
|
|
3140
|
+
return c.json({
|
|
3141
|
+
ok: true,
|
|
3142
|
+
namespace: body.namespace,
|
|
3143
|
+
restored: Object.keys(body.tables).length,
|
|
3144
|
+
});
|
|
3145
|
+
});
|
|
3146
|
+
|
|
3147
|
+
// GET /admin/api/data/backup/config
|
|
3148
|
+
const adminBackupGetConfig = createRoute({
|
|
3149
|
+
operationId: 'adminBackupGetConfig',
|
|
3150
|
+
method: 'get',
|
|
3151
|
+
path: '/backup/config',
|
|
3152
|
+
tags: ['admin'],
|
|
3153
|
+
summary: 'Get backup config',
|
|
3154
|
+
responses: {
|
|
3155
|
+
200: { description: 'Backup config', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3156
|
+
},
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
api.openapi(adminBackupGetConfig, (c) => {
|
|
3160
|
+
try {
|
|
3161
|
+
const config = parseConfig(c.env);
|
|
3162
|
+
return c.json(config);
|
|
3163
|
+
} catch {
|
|
3164
|
+
return c.json({});
|
|
3165
|
+
}
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
// ─── Admin Account Management ───
|
|
3169
|
+
|
|
3170
|
+
// GET /admin/api/data/admins — list all admin accounts
|
|
3171
|
+
const adminListAdmins = createRoute({
|
|
3172
|
+
operationId: 'adminListAdmins',
|
|
3173
|
+
method: 'get',
|
|
3174
|
+
path: '/admins',
|
|
3175
|
+
tags: ['admin'],
|
|
3176
|
+
summary: 'List admin accounts',
|
|
3177
|
+
responses: {
|
|
3178
|
+
200: { description: 'Admin list', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3179
|
+
},
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
api.openapi(adminListAdmins, async (c) => {
|
|
3183
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
3184
|
+
const admins = await listAdmins(getAuthDb(c));
|
|
3185
|
+
return c.json({ admins });
|
|
3186
|
+
});
|
|
3187
|
+
|
|
3188
|
+
// POST /admin/api/data/admins — create a new admin account
|
|
3189
|
+
const adminCreateAdmin = createRoute({
|
|
3190
|
+
operationId: 'adminCreateAdmin',
|
|
3191
|
+
method: 'post',
|
|
3192
|
+
path: '/admins',
|
|
3193
|
+
tags: ['admin'],
|
|
3194
|
+
summary: 'Create an admin account',
|
|
3195
|
+
request: {
|
|
3196
|
+
body: {
|
|
3197
|
+
content: { 'application/json': { schema: z.object({ email: z.string().email(), password: z.string().min(8) }) } },
|
|
3198
|
+
required: true,
|
|
3199
|
+
},
|
|
3200
|
+
},
|
|
3201
|
+
responses: {
|
|
3202
|
+
200: { description: 'Admin created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3203
|
+
},
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
api.openapi(adminCreateAdmin, async (c) => {
|
|
3207
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
3208
|
+
const body = await c.req.json<{ email: string; password: string }>();
|
|
3209
|
+
|
|
3210
|
+
const existing = await getAdminByEmail(getAuthDb(c), body.email);
|
|
3211
|
+
if (existing) {
|
|
3212
|
+
return c.json({ code: 409, message: 'An admin with this email already exists' }, 409);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
const id = generateId();
|
|
3216
|
+
const hash = await hashPassword(body.password);
|
|
3217
|
+
await createAdmin(getAuthDb(c), id, body.email, hash);
|
|
3218
|
+
return c.json({ id, email: body.email });
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
// DELETE /admin/api/data/admins/:id — delete an admin account
|
|
3222
|
+
const adminDeleteAdmin = createRoute({
|
|
3223
|
+
operationId: 'adminDeleteAdmin',
|
|
3224
|
+
method: 'delete',
|
|
3225
|
+
path: '/admins/{id}',
|
|
3226
|
+
tags: ['admin'],
|
|
3227
|
+
summary: 'Delete an admin account',
|
|
3228
|
+
request: { params: z.object({ id: z.string() }) },
|
|
3229
|
+
responses: {
|
|
3230
|
+
200: { description: 'Admin deleted', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3231
|
+
},
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
api.openapi(adminDeleteAdmin, async (c) => {
|
|
3235
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
3236
|
+
const id = c.req.param('id')!;
|
|
3237
|
+
const currentAdminId = (c as unknown as { get(key: string): string }).get('adminId');
|
|
3238
|
+
|
|
3239
|
+
if (id === currentAdminId) {
|
|
3240
|
+
return c.json({ code: 403, message: 'Cannot delete your own admin account' }, 403);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
const admins = await listAdmins(getAuthDb(c));
|
|
3244
|
+
if (admins.length <= 1) {
|
|
3245
|
+
return c.json({ code: 403, message: 'Cannot delete the last admin account' }, 403);
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
await deleteAdmin(getAuthDb(c), id);
|
|
3249
|
+
return c.json({ success: true });
|
|
3250
|
+
});
|
|
3251
|
+
|
|
3252
|
+
// PUT /admin/api/data/admins/:id/password — change admin password
|
|
3253
|
+
const adminChangePassword = createRoute({
|
|
3254
|
+
operationId: 'adminChangePassword',
|
|
3255
|
+
method: 'put',
|
|
3256
|
+
path: '/admins/{id}/password',
|
|
3257
|
+
tags: ['admin'],
|
|
3258
|
+
summary: 'Change admin password',
|
|
3259
|
+
request: {
|
|
3260
|
+
params: z.object({ id: z.string() }),
|
|
3261
|
+
body: {
|
|
3262
|
+
content: { 'application/json': { schema: z.object({ password: z.string().min(8) }) } },
|
|
3263
|
+
required: true,
|
|
3264
|
+
},
|
|
3265
|
+
},
|
|
3266
|
+
responses: {
|
|
3267
|
+
200: { description: 'Password updated', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3268
|
+
},
|
|
3269
|
+
});
|
|
3270
|
+
|
|
3271
|
+
api.openapi(adminChangePassword, async (c) => {
|
|
3272
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
3273
|
+
const id = c.req.param('id')!;
|
|
3274
|
+
const body = await c.req.json<{ password: string }>();
|
|
3275
|
+
const hash = await hashPassword(body.password);
|
|
3276
|
+
await updateAdminPassword(getAuthDb(c), id, hash);
|
|
3277
|
+
return c.json({ success: true });
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
// ─── Destroy App (Self-Destruct) ───
|
|
3281
|
+
|
|
3282
|
+
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
|
3283
|
+
|
|
3284
|
+
interface DeployManifest {
|
|
3285
|
+
version: number;
|
|
3286
|
+
accountId: string;
|
|
3287
|
+
worker: { name: string; url: string };
|
|
3288
|
+
resources: Array<{
|
|
3289
|
+
type: string;
|
|
3290
|
+
name: string;
|
|
3291
|
+
binding?: string;
|
|
3292
|
+
id?: string;
|
|
3293
|
+
managed?: boolean;
|
|
3294
|
+
}>;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
async function cfApi(
|
|
3298
|
+
accountId: string,
|
|
3299
|
+
apiToken: string,
|
|
3300
|
+
method: string,
|
|
3301
|
+
path: string,
|
|
3302
|
+
): Promise<{ ok: boolean; status: number; error?: string }> {
|
|
3303
|
+
try {
|
|
3304
|
+
const res = await fetch(`${CF_API_BASE}/accounts/${accountId}${path}`, {
|
|
3305
|
+
method,
|
|
3306
|
+
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
|
3307
|
+
});
|
|
3308
|
+
// 2xx = success, 404 = not found (already gone), 410 = gone (Vectorize soft-delete)
|
|
3309
|
+
if (res.ok || res.status === 404 || res.status === 410) return { ok: true, status: res.status };
|
|
3310
|
+
const body = await res.json().catch(() => ({})) as { errors?: Array<{ message?: string }> };
|
|
3311
|
+
const msg = (body.errors ?? []).map((e: { message?: string }) => e.message).filter(Boolean).join(', ');
|
|
3312
|
+
return { ok: false, status: res.status, error: msg || `HTTP ${res.status}` };
|
|
3313
|
+
} catch (err) {
|
|
3314
|
+
return { ok: false, status: 0, error: err instanceof Error ? err.message : 'Network error' };
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
const adminDestroyApp = createRoute({
|
|
3319
|
+
operationId: 'adminDestroyApp',
|
|
3320
|
+
method: 'post',
|
|
3321
|
+
path: '/destroy-app',
|
|
3322
|
+
tags: ['admin'],
|
|
3323
|
+
summary: 'Delete all Cloudflare resources and the Worker itself (self-destruct)',
|
|
3324
|
+
request: {
|
|
3325
|
+
body: {
|
|
3326
|
+
content: {
|
|
3327
|
+
'application/json': {
|
|
3328
|
+
schema: z.object({
|
|
3329
|
+
confirm: z.literal('DELETE_ALL_RESOURCES'),
|
|
3330
|
+
}),
|
|
3331
|
+
},
|
|
3332
|
+
},
|
|
3333
|
+
required: true,
|
|
3334
|
+
},
|
|
3335
|
+
},
|
|
3336
|
+
responses: {
|
|
3337
|
+
200: {
|
|
3338
|
+
description: 'Destruction result',
|
|
3339
|
+
content: {
|
|
3340
|
+
'application/json': {
|
|
3341
|
+
schema: z.object({
|
|
3342
|
+
success: z.boolean(),
|
|
3343
|
+
deleted: z.array(z.string()),
|
|
3344
|
+
failed: z.array(z.object({ resource: z.string(), error: z.string() })),
|
|
3345
|
+
message: z.string(),
|
|
3346
|
+
}),
|
|
3347
|
+
},
|
|
3348
|
+
},
|
|
3349
|
+
},
|
|
3350
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3351
|
+
503: { description: 'Not available', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3352
|
+
},
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
api.openapi(adminDestroyApp, async (c) => {
|
|
3356
|
+
const env = c.env as Env;
|
|
3357
|
+
const body = await c.req.json<{ confirm: string }>();
|
|
3358
|
+
|
|
3359
|
+
if (body.confirm !== 'DELETE_ALL_RESOURCES') {
|
|
3360
|
+
throw new EdgeBaseError(400, 'Confirmation string must be "DELETE_ALL_RESOURCES"', undefined, 'bad_request');
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
const apiToken = env.CF_API_TOKEN;
|
|
3364
|
+
const accountId = env.CF_ACCOUNT_ID;
|
|
3365
|
+
|
|
3366
|
+
if (!apiToken || !accountId) {
|
|
3367
|
+
throw new EdgeBaseError(
|
|
3368
|
+
503,
|
|
3369
|
+
'Self-destruct is not available. CF_API_TOKEN and CF_ACCOUNT_ID must be set as Worker secrets during deploy.',
|
|
3370
|
+
undefined,
|
|
3371
|
+
'unavailable',
|
|
3372
|
+
);
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
// Read manifest from KV
|
|
3376
|
+
const manifestRaw = await env.KV.get('__edgebase_deploy_manifest', 'text');
|
|
3377
|
+
if (!manifestRaw) {
|
|
3378
|
+
throw new EdgeBaseError(
|
|
3379
|
+
503,
|
|
3380
|
+
'Deploy manifest not found in KV. Redeploy or use CLI `edgebase destroy` instead.',
|
|
3381
|
+
undefined,
|
|
3382
|
+
'unavailable',
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
let manifest: DeployManifest;
|
|
3387
|
+
try {
|
|
3388
|
+
manifest = JSON.parse(manifestRaw);
|
|
3389
|
+
} catch {
|
|
3390
|
+
throw new EdgeBaseError(503, 'Deploy manifest is corrupted.', undefined, 'unavailable');
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
const deleted: string[] = [];
|
|
3394
|
+
const failed: Array<{ resource: string; error: string }> = [];
|
|
3395
|
+
const resources = manifest.resources.filter((r) => r.managed !== false);
|
|
3396
|
+
|
|
3397
|
+
// Delete order: D1 → Vectorize → Hyperdrive → R2 (empty first) → Turnstile → Worker → KV (last)
|
|
3398
|
+
// KV is deleted last so the manifest remains available for retry on partial failure.
|
|
3399
|
+
// Worker is deleted second-to-last (it takes down DOs, secrets, crons automatically).
|
|
3400
|
+
|
|
3401
|
+
for (const r of resources) {
|
|
3402
|
+
if (r.type === 'd1_database' && r.id) {
|
|
3403
|
+
const label = `D1 ${r.name}`;
|
|
3404
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/d1/database/${r.id}`);
|
|
3405
|
+
if (result.ok) deleted.push(label);
|
|
3406
|
+
else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
for (const r of resources) {
|
|
3411
|
+
if (r.type === 'vectorize' && (r.id || r.name)) {
|
|
3412
|
+
const indexName = r.id ?? r.name;
|
|
3413
|
+
const label = `Vectorize ${indexName}`;
|
|
3414
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/vectorize/v2/indexes/${indexName}`);
|
|
3415
|
+
if (result.ok) deleted.push(label);
|
|
3416
|
+
else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
for (const r of resources) {
|
|
3421
|
+
if (r.type === 'hyperdrive' && r.id) {
|
|
3422
|
+
const label = `Hyperdrive ${r.name}`;
|
|
3423
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/hyperdrive/configs/${r.id}`);
|
|
3424
|
+
if (result.ok) deleted.push(label);
|
|
3425
|
+
else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
// R2: empty bucket contents via the Worker's R2 binding, then delete via CF API
|
|
3430
|
+
for (const r of resources) {
|
|
3431
|
+
if (r.type === 'r2_bucket' && r.name) {
|
|
3432
|
+
const label = `R2 ${r.name}`;
|
|
3433
|
+
|
|
3434
|
+
// Use the STORAGE binding to empty the bucket (works regardless of bucket name)
|
|
3435
|
+
try {
|
|
3436
|
+
let truncated = true;
|
|
3437
|
+
let cursor: string | undefined;
|
|
3438
|
+
while (truncated) {
|
|
3439
|
+
const list = await env.STORAGE.list({ limit: 1000, cursor });
|
|
3440
|
+
if (list.objects.length > 0) {
|
|
3441
|
+
await Promise.all(list.objects.map((obj) => env.STORAGE.delete(obj.key)));
|
|
3442
|
+
}
|
|
3443
|
+
truncated = list.truncated;
|
|
3444
|
+
cursor = truncated ? (list as unknown as { cursor: string }).cursor : undefined;
|
|
3445
|
+
}
|
|
3446
|
+
} catch {
|
|
3447
|
+
// Non-fatal: attempt deletion anyway — CF API will reject if not empty
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/r2/buckets/${r.name}`);
|
|
3451
|
+
if (result.ok) deleted.push(label);
|
|
3452
|
+
else failed.push({ resource: label, error: result.error ?? 'Bucket may not be empty' });
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
for (const r of resources) {
|
|
3457
|
+
if (r.type === 'turnstile_widget' && r.id) {
|
|
3458
|
+
const label = `Turnstile ${r.name}`;
|
|
3459
|
+
// Turnstile uses zone-level API, not account
|
|
3460
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/challenges/widgets/${r.id}`);
|
|
3461
|
+
if (result.ok) deleted.push(label);
|
|
3462
|
+
else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
// Worker is deleted before KV (takes down DOs, secrets, crons automatically)
|
|
3467
|
+
if (manifest.worker.name) {
|
|
3468
|
+
const label = `Worker ${manifest.worker.name}`;
|
|
3469
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/workers/scripts/${manifest.worker.name}`);
|
|
3470
|
+
if (result.ok) {
|
|
3471
|
+
deleted.push(label);
|
|
3472
|
+
} else {
|
|
3473
|
+
failed.push({ resource: label, error: result.error ?? 'Unknown error' });
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// KV deleted last — manifest stays available for retry on partial failure
|
|
3478
|
+
for (const r of resources) {
|
|
3479
|
+
if (r.type === 'kv_namespace' && r.id) {
|
|
3480
|
+
const label = `KV ${r.name}`;
|
|
3481
|
+
const result = await cfApi(accountId, apiToken, 'DELETE', `/storage/kv/namespaces/${r.id}`);
|
|
3482
|
+
if (result.ok) deleted.push(label);
|
|
3483
|
+
else failed.push({ resource: label, error: result.error ?? 'Unknown error' });
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
const allOk = failed.length === 0;
|
|
3488
|
+
const message = allOk
|
|
3489
|
+
? `All resources destroyed. ${deleted.length} resources deleted.`
|
|
3490
|
+
: `Partial destruction: ${deleted.length} deleted, ${failed.length} failed.`;
|
|
3491
|
+
|
|
3492
|
+
return c.json({
|
|
3493
|
+
success: allOk,
|
|
3494
|
+
deleted,
|
|
3495
|
+
failed,
|
|
3496
|
+
message,
|
|
3497
|
+
});
|
|
3498
|
+
});
|
|
3499
|
+
|
|
3500
|
+
// Mount JWT-protected sub-app under /data/
|
|
3501
|
+
adminRoute.route('/data', api);
|