@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,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Key utility — config-driven scope engine.
|
|
3
|
+
*
|
|
4
|
+
* Service Keys are defined in config.serviceKeys and resolved against Worker
|
|
5
|
+
* secrets via secretRef or inlineSecret. No separate env-only validation path.
|
|
6
|
+
*
|
|
7
|
+
* Memory keymap is built once per worker lifetime (lazy on first use).
|
|
8
|
+
* No D1/KV reads in the request hot path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EdgeBaseConfig, ServiceKeyEntry } from '@edge-base/shared';
|
|
12
|
+
import type { Env } from '../types.js';
|
|
13
|
+
import { isIpInCidr } from './cidr.js';
|
|
14
|
+
import { getTrustedClientIp } from './client-ip.js';
|
|
15
|
+
|
|
16
|
+
type HeaderReader = Request | { header: (name: string) => string | undefined; raw?: Request };
|
|
17
|
+
|
|
18
|
+
function readHeader(reader: HeaderReader, name: string): string | undefined {
|
|
19
|
+
if (reader instanceof Request) {
|
|
20
|
+
return reader.headers.get(name) ?? undefined;
|
|
21
|
+
}
|
|
22
|
+
const direct = reader.header(name);
|
|
23
|
+
if (direct !== undefined) {
|
|
24
|
+
return direct;
|
|
25
|
+
}
|
|
26
|
+
return reader.raw?.headers.get(name) ?? undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function firstDefinedValue(
|
|
30
|
+
...values: Array<string | null | undefined>
|
|
31
|
+
): string | undefined {
|
|
32
|
+
for (const value of values) {
|
|
33
|
+
if (value !== undefined && value !== null) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read the explicit Service Key header from a request-like object.
|
|
42
|
+
*
|
|
43
|
+
* Header names accepted:
|
|
44
|
+
* - X-EdgeBase-Service-Key
|
|
45
|
+
* - x-edgebase-service-key
|
|
46
|
+
*/
|
|
47
|
+
export function extractServiceKeyHeader(reader: HeaderReader): string | undefined {
|
|
48
|
+
return firstDefinedValue(
|
|
49
|
+
readHeader(reader, 'X-EdgeBase-Service-Key'),
|
|
50
|
+
readHeader(reader, 'x-edgebase-service-key'),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the first explicitly provided Service Key candidate.
|
|
56
|
+
*
|
|
57
|
+
* If a dedicated Service Key header is present, it wins even when the value is
|
|
58
|
+
* the empty string. This prevents explicit-but-empty headers from being treated
|
|
59
|
+
* as "missing" and silently falling back to public access or Bearer tokens.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveServiceKeyCandidate(
|
|
62
|
+
reader: HeaderReader,
|
|
63
|
+
...fallbacks: Array<string | null | undefined>
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const header = extractServiceKeyHeader(reader);
|
|
66
|
+
if (header !== undefined) {
|
|
67
|
+
return header;
|
|
68
|
+
}
|
|
69
|
+
return firstDefinedValue(...fallbacks);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the raw Bearer token payload from Authorization.
|
|
74
|
+
*
|
|
75
|
+
* Returns `null` when the header is missing or does not use the Bearer scheme.
|
|
76
|
+
* Returns the raw token payload as-is, including the empty string for `Bearer `.
|
|
77
|
+
*/
|
|
78
|
+
export function extractBearerToken(reader: HeaderReader): string | null {
|
|
79
|
+
const authHeader = readHeader(reader, 'authorization') || readHeader(reader, 'Authorization');
|
|
80
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return authHeader.slice(7);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Timing-safe comparison of two strings.
|
|
88
|
+
* Always runs in O(max(a,b)) time regardless of match.
|
|
89
|
+
*/
|
|
90
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
91
|
+
if (a.length !== b.length) return false;
|
|
92
|
+
|
|
93
|
+
const encodedA = new TextEncoder().encode(a);
|
|
94
|
+
const encodedB = new TextEncoder().encode(b);
|
|
95
|
+
|
|
96
|
+
let diff = 0;
|
|
97
|
+
for (let i = 0; i < encodedA.length; i++) {
|
|
98
|
+
diff |= encodedA[i] ^ encodedB[i];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return diff === 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Constraint Context ───
|
|
105
|
+
|
|
106
|
+
/** Request-level context needed for constraint evaluation */
|
|
107
|
+
export interface ConstraintContext {
|
|
108
|
+
/** Server environment name from ENVIRONMENT env var */
|
|
109
|
+
env?: string;
|
|
110
|
+
/** Client IP from cf-connecting-ip / x-forwarded-for */
|
|
111
|
+
ip?: string;
|
|
112
|
+
/** Tenant ID — auto-injected from DB URL instanceId (§15/136) */
|
|
113
|
+
tenantId?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a ConstraintContext from a minimal request-like object and env.
|
|
118
|
+
* Works with Hono's c.req or any object with a `.header()` method.
|
|
119
|
+
*
|
|
120
|
+
* @param env - The Worker Env (for ENVIRONMENT var)
|
|
121
|
+
* @param req - Request object with .header() method (optional)
|
|
122
|
+
*/
|
|
123
|
+
export function buildConstraintCtx(
|
|
124
|
+
env: { ENVIRONMENT?: string; EDGEBASE_CONFIG?: unknown; trustSelfHostedProxy?: boolean },
|
|
125
|
+
req?: { header: (name: string) => string | undefined },
|
|
126
|
+
): ConstraintContext {
|
|
127
|
+
const ctx: ConstraintContext = {
|
|
128
|
+
env: env.ENVIRONMENT,
|
|
129
|
+
};
|
|
130
|
+
if (req) {
|
|
131
|
+
ctx.ip = getTrustedClientIp(env, req);
|
|
132
|
+
// Note: tenantId is auto-injected from DB URL instanceId in rules.ts (§15)
|
|
133
|
+
// tenantId: from DB namespace+id in URL path (§2/#136)
|
|
134
|
+
}
|
|
135
|
+
return ctx;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Scoped Key Engine ───
|
|
139
|
+
|
|
140
|
+
/** In-memory resolved key entry: the entry + its resolved secret */
|
|
141
|
+
interface ResolvedKeyEntry {
|
|
142
|
+
entry: ServiceKeyEntry;
|
|
143
|
+
secret: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Scope string: "{domain}:{resourceType}:{resourceName}:{action}" */
|
|
147
|
+
export type ScopeString = string;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build an in-memory keymap from config.serviceKeys and env secrets.
|
|
151
|
+
* Returns null if config.serviceKeys is not defined.
|
|
152
|
+
*
|
|
153
|
+
* Each resolved entry stores the entry metadata and the resolved secret.
|
|
154
|
+
* 'dashboard' mode: reads env[secretRef] dynamically.
|
|
155
|
+
* 'inline' mode: uses inlineSecret directly.
|
|
156
|
+
*/
|
|
157
|
+
export function buildKeymap(
|
|
158
|
+
config: EdgeBaseConfig,
|
|
159
|
+
env: Env,
|
|
160
|
+
): Map<string, ResolvedKeyEntry> | null {
|
|
161
|
+
if (!config.serviceKeys?.keys?.length) return null;
|
|
162
|
+
|
|
163
|
+
const keymap = new Map<string, ResolvedKeyEntry>();
|
|
164
|
+
|
|
165
|
+
for (const entry of config.serviceKeys.keys) {
|
|
166
|
+
// Skip disabled entries
|
|
167
|
+
if (entry.enabled === false) continue;
|
|
168
|
+
|
|
169
|
+
// Resolve secret
|
|
170
|
+
let secret: string | undefined;
|
|
171
|
+
if (entry.secretSource === 'inline') {
|
|
172
|
+
secret = entry.inlineSecret;
|
|
173
|
+
} else {
|
|
174
|
+
// 'dashboard': read from env by secretRef name
|
|
175
|
+
if (entry.secretRef) {
|
|
176
|
+
secret = (env as unknown as Record<string, string | undefined>)[entry.secretRef];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Skip entries with no resolvable secret (misconfigured)
|
|
181
|
+
if (!secret) continue;
|
|
182
|
+
|
|
183
|
+
keymap.set(entry.kid, { entry, secret });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return keymap.size > 0 ? keymap : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns true when the provided value exactly matches any configured Service Key
|
|
191
|
+
* secret, ignoring scope/constraint checks. Used by auth middleware to classify
|
|
192
|
+
* Bearer tokens as Service Key candidates before route-specific validation runs.
|
|
193
|
+
*/
|
|
194
|
+
export function matchesConfiguredSecret(
|
|
195
|
+
provided: string | null | undefined,
|
|
196
|
+
keymap: Map<string, ResolvedKeyEntry> | null,
|
|
197
|
+
): boolean {
|
|
198
|
+
if (provided == null || provided === '') return false;
|
|
199
|
+
if (!keymap || keymap.size === 0) return false;
|
|
200
|
+
|
|
201
|
+
for (const [, resolved] of keymap) {
|
|
202
|
+
if (timingSafeEqual(provided, resolved.secret)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the internal-use root-tier Service Key secret for Worker self-calls.
|
|
212
|
+
*
|
|
213
|
+
* Selection rules:
|
|
214
|
+
* - only root-tier keys are eligible
|
|
215
|
+
* - keys must be usable without request-scoped context (tenant/IP constraints fail)
|
|
216
|
+
* - if present, the canonical `secretRef: 'SERVICE_KEY'` root key wins
|
|
217
|
+
* - otherwise, the first usable root-tier key in config order is used
|
|
218
|
+
*
|
|
219
|
+
* Returns undefined when no eligible root-tier key is configured or resolvable.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveRootServiceKey(
|
|
222
|
+
config: EdgeBaseConfig,
|
|
223
|
+
env: Env,
|
|
224
|
+
): string | undefined {
|
|
225
|
+
const keymap = buildKeymap(config, env);
|
|
226
|
+
if (!keymap) return undefined;
|
|
227
|
+
|
|
228
|
+
let fallback: string | undefined;
|
|
229
|
+
for (const { entry, secret } of keymap.values()) {
|
|
230
|
+
if (entry.tier !== 'root') continue;
|
|
231
|
+
if (!checkConstraints(entry, { env: env.ENVIRONMENT })) continue;
|
|
232
|
+
if (entry.secretSource === 'dashboard' && entry.secretRef === 'SERVICE_KEY') {
|
|
233
|
+
return secret;
|
|
234
|
+
}
|
|
235
|
+
fallback ??= secret;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return fallback;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check if an entry's scopes satisfy a required scope.
|
|
243
|
+
*
|
|
244
|
+
* Root tier entries always pass.
|
|
245
|
+
* Scoped entries: each scope segment is compared allowing '*' wildcard.
|
|
246
|
+
* Scope format: "domain:resourceType:resourceName:action"
|
|
247
|
+
*
|
|
248
|
+
* Examples:
|
|
249
|
+
* required = "storage:bucket:avatars:write"
|
|
250
|
+
* passes with: ["*"], ["storage:*:*:*"], ["storage:bucket:avatars:write"], ["storage:bucket:avatars:*"]
|
|
251
|
+
* fails with: ["storage:bucket:photos:write"], ["db:table:posts:read"]
|
|
252
|
+
*/
|
|
253
|
+
export function matchesScope(required: ScopeString, entry: ServiceKeyEntry): boolean {
|
|
254
|
+
// Root tier: always passes
|
|
255
|
+
if (entry.tier === 'root') return true;
|
|
256
|
+
|
|
257
|
+
const requiredParts = required.split(':');
|
|
258
|
+
|
|
259
|
+
for (const scope of entry.scopes) {
|
|
260
|
+
// Global wildcard
|
|
261
|
+
if (scope === '*') return true;
|
|
262
|
+
|
|
263
|
+
const scopeParts = scope.split(':');
|
|
264
|
+
|
|
265
|
+
// Must have same segment count
|
|
266
|
+
if (scopeParts.length !== requiredParts.length) continue;
|
|
267
|
+
|
|
268
|
+
let matches = true;
|
|
269
|
+
for (let i = 0; i < scopeParts.length; i++) {
|
|
270
|
+
if (scopeParts[i] !== '*' && scopeParts[i] !== requiredParts[i]) {
|
|
271
|
+
matches = false;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (matches) return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Check entry constraints: expiry, env, ipCidr, tenant.
|
|
283
|
+
* Returns true if constraints pass (or no constraints defined).
|
|
284
|
+
* Fail-closed: if a constraint is defined but the corresponding context value
|
|
285
|
+
* is missing, the constraint fails (deny). This prevents bypassing constraints
|
|
286
|
+
* by omitting context information.
|
|
287
|
+
*/
|
|
288
|
+
function checkConstraints(entry: ServiceKeyEntry, ctx?: ConstraintContext): boolean {
|
|
289
|
+
const c = entry.constraints;
|
|
290
|
+
if (!c) return true;
|
|
291
|
+
|
|
292
|
+
// 1. Expiry check
|
|
293
|
+
if (c.expiresAt) {
|
|
294
|
+
const expiresAt = new Date(c.expiresAt).getTime();
|
|
295
|
+
if (!isNaN(expiresAt) && Date.now() >= expiresAt) return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 2. Environment check — ENVIRONMENT env var must be in allowed list
|
|
299
|
+
if (c.env?.length) {
|
|
300
|
+
if (!ctx?.env || !c.env.includes(ctx.env)) return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 3. IP CIDR check — client IP must be in at least one allowed range
|
|
304
|
+
if (c.ipCidr?.length) {
|
|
305
|
+
if (!ctx?.ip || !c.ipCidr.some(cidr => isIpInCidr(ctx.ip!, cidr))) return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 4. Tenant check — request tenant ID must match
|
|
309
|
+
if (c.tenant) {
|
|
310
|
+
if (!ctx?.tenantId || c.tenant !== ctx.tenantId) return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Validate a scoped service key against a keymap.
|
|
318
|
+
*
|
|
319
|
+
* Key format: "jb_{kid}_{secret}" (recommended) or plain root-tier secret.
|
|
320
|
+
*
|
|
321
|
+
* Validation order:
|
|
322
|
+
* 1. Extract kid from "jb_{kid}_{...}" format
|
|
323
|
+
* 2. Look up entry in keymap by kid
|
|
324
|
+
* 3. timing-safe compare full provided key against stored secret
|
|
325
|
+
* 4. Check constraints (expiry, env)
|
|
326
|
+
* 5. Check scope match
|
|
327
|
+
*
|
|
328
|
+
* If key doesn't have "jb_" prefix, iterate root-tier entries and compare directly.
|
|
329
|
+
*
|
|
330
|
+
* Returns 'valid' | 'invalid' | 'missing'
|
|
331
|
+
*/
|
|
332
|
+
export function validateScopedKey(
|
|
333
|
+
provided: string | null | undefined,
|
|
334
|
+
requiredScope: ScopeString,
|
|
335
|
+
keymap: Map<string, ResolvedKeyEntry>,
|
|
336
|
+
ctx?: ConstraintContext,
|
|
337
|
+
): 'valid' | 'invalid' | 'missing' {
|
|
338
|
+
if (provided == null) return 'missing';
|
|
339
|
+
if (provided === '') return 'invalid';
|
|
340
|
+
if (keymap.size === 0) return 'missing';
|
|
341
|
+
|
|
342
|
+
// Try structured "jb_{kid}_{...}" format
|
|
343
|
+
if (provided.startsWith('jb_')) {
|
|
344
|
+
const secondUnderscore = provided.indexOf('_', 3);
|
|
345
|
+
if (secondUnderscore > 3) {
|
|
346
|
+
const kid = provided.substring(3, secondUnderscore);
|
|
347
|
+
const resolved = keymap.get(kid);
|
|
348
|
+
if (!resolved) return 'invalid';
|
|
349
|
+
|
|
350
|
+
if (!timingSafeEqual(provided, resolved.secret)) return 'invalid';
|
|
351
|
+
if (!checkConstraints(resolved.entry, ctx)) return 'invalid';
|
|
352
|
+
if (!matchesScope(requiredScope, resolved.entry)) return 'invalid';
|
|
353
|
+
return 'valid';
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Plain key format: iterate all root-tier entries directly
|
|
358
|
+
// (scoped entries are skipped because they require a kid)
|
|
359
|
+
let foundRootEntry = false;
|
|
360
|
+
for (const [, resolved] of keymap) {
|
|
361
|
+
if (resolved.entry.tier !== 'root') continue;
|
|
362
|
+
foundRootEntry = true;
|
|
363
|
+
if (timingSafeEqual(provided, resolved.secret)) {
|
|
364
|
+
if (!checkConstraints(resolved.entry, ctx)) return 'invalid';
|
|
365
|
+
// Root tier: scope always passes
|
|
366
|
+
return 'valid';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return foundRootEntry ? 'invalid' : 'missing';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Validate that a provided key matches any configured Service Key, without
|
|
375
|
+
* enforcing a route-specific scope check. Used by auth middleware to detect
|
|
376
|
+
* Service Key requests before endpoint-level scope validation runs.
|
|
377
|
+
*/
|
|
378
|
+
export function validateConfiguredKey(
|
|
379
|
+
provided: string | null | undefined,
|
|
380
|
+
keymap: Map<string, ResolvedKeyEntry> | null,
|
|
381
|
+
ctx?: ConstraintContext,
|
|
382
|
+
): 'valid' | 'invalid' | 'missing' {
|
|
383
|
+
if (provided == null) return 'missing';
|
|
384
|
+
if (provided === '') return 'invalid';
|
|
385
|
+
if (!keymap || keymap.size === 0) return 'missing';
|
|
386
|
+
|
|
387
|
+
for (const [, resolved] of keymap) {
|
|
388
|
+
if (!timingSafeEqual(provided, resolved.secret)) continue;
|
|
389
|
+
if (!checkConstraints(resolved.entry, ctx)) return 'invalid';
|
|
390
|
+
return 'valid';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return 'invalid';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ─── Unified Validator ───
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Primary entry point for Service Key validation.
|
|
400
|
+
*
|
|
401
|
+
* Strategy:
|
|
402
|
+
* 1. If config.serviceKeys is defined → use scoped engine (validateScopedKey)
|
|
403
|
+
* 2. Otherwise → return missing
|
|
404
|
+
*/
|
|
405
|
+
export function validateKey(
|
|
406
|
+
provided: string | null | undefined,
|
|
407
|
+
requiredScope: ScopeString,
|
|
408
|
+
config: EdgeBaseConfig,
|
|
409
|
+
env: Env,
|
|
410
|
+
keymapCache?: Map<string, ResolvedKeyEntry> | null,
|
|
411
|
+
ctx?: ConstraintContext,
|
|
412
|
+
): { result: 'valid' | 'invalid' | 'missing'; keymap: Map<string, ResolvedKeyEntry> | null } {
|
|
413
|
+
const keymap = keymapCache !== undefined ? keymapCache : buildKeymap(config, env);
|
|
414
|
+
if (keymap !== null) {
|
|
415
|
+
const result = validateScopedKey(provided, requiredScope, keymap, ctx);
|
|
416
|
+
return { result, keymap };
|
|
417
|
+
}
|
|
418
|
+
return { result: 'missing', keymap: null };
|
|
419
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SmsProvider — Adapter pattern for external SMS services.
|
|
3
|
+
*
|
|
4
|
+
* Workers cannot use direct telephony. Instead, we use HTTP REST API-based
|
|
5
|
+
* external SMS services via a common interface.
|
|
6
|
+
*
|
|
7
|
+
* Supported: Twilio (default), MessageBird, Vonage
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── Interface ───
|
|
11
|
+
|
|
12
|
+
export interface SmsSendOptions {
|
|
13
|
+
to: string;
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SmsSendResult {
|
|
18
|
+
success: boolean;
|
|
19
|
+
messageId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SmsProvider {
|
|
23
|
+
send(options: SmsSendOptions): Promise<SmsSendResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SmsProviderEnv {
|
|
27
|
+
EDGEBASE_SMS_API_URL?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Twilio Provider ───
|
|
31
|
+
|
|
32
|
+
export class TwilioProvider implements SmsProvider {
|
|
33
|
+
constructor(
|
|
34
|
+
private accountSid: string,
|
|
35
|
+
private authToken: string,
|
|
36
|
+
private from: string,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
async send(options: SmsSendOptions): Promise<SmsSendResult> {
|
|
40
|
+
const url = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}/Messages.json`;
|
|
41
|
+
|
|
42
|
+
const body = new URLSearchParams();
|
|
43
|
+
body.append('To', options.to);
|
|
44
|
+
body.append('From', this.from);
|
|
45
|
+
body.append('Body', options.body);
|
|
46
|
+
|
|
47
|
+
const resp = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Basic ${btoa(`${this.accountSid}:${this.authToken}`)}`,
|
|
51
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
52
|
+
},
|
|
53
|
+
body: body.toString(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!resp.ok) {
|
|
57
|
+
const text = await resp.text();
|
|
58
|
+
console.error('[SmsProvider:Twilio] Failed:', resp.status, text);
|
|
59
|
+
return { success: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const data = (await resp.json()) as { sid?: string };
|
|
63
|
+
return { success: true, messageId: data.sid };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class MockSmsProvider implements SmsProvider {
|
|
68
|
+
private endpoint: string;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
endpoint: string,
|
|
72
|
+
private from: string,
|
|
73
|
+
) {
|
|
74
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async send(options: SmsSendOptions): Promise<SmsSendResult> {
|
|
78
|
+
const resp = await fetch(`${this.endpoint}/send`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
},
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
from: this.from,
|
|
85
|
+
to: options.to,
|
|
86
|
+
body: options.body,
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!resp.ok) {
|
|
91
|
+
const text = await resp.text();
|
|
92
|
+
console.error('[SmsProvider:Mock] Failed:', resp.status, text);
|
|
93
|
+
return { success: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const data = await resp.json().catch(() => ({})) as { sid?: string; messageId?: string };
|
|
97
|
+
return { success: true, messageId: data.messageId ?? data.sid };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── MessageBird Provider ───
|
|
102
|
+
|
|
103
|
+
export class MessageBirdProvider implements SmsProvider {
|
|
104
|
+
constructor(
|
|
105
|
+
private apiKey: string,
|
|
106
|
+
private from: string,
|
|
107
|
+
) {}
|
|
108
|
+
|
|
109
|
+
async send(options: SmsSendOptions): Promise<SmsSendResult> {
|
|
110
|
+
const resp = await fetch('https://rest.messagebird.com/messages', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
Authorization: `AccessKey ${this.apiKey}`,
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
originator: this.from,
|
|
118
|
+
recipients: [options.to],
|
|
119
|
+
body: options.body,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!resp.ok) {
|
|
124
|
+
const text = await resp.text();
|
|
125
|
+
console.error('[SmsProvider:MessageBird] Failed:', resp.status, text);
|
|
126
|
+
return { success: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const data = (await resp.json()) as { id?: string };
|
|
130
|
+
return { success: true, messageId: data.id };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Vonage Provider ───
|
|
135
|
+
|
|
136
|
+
export class VonageProvider implements SmsProvider {
|
|
137
|
+
constructor(
|
|
138
|
+
private apiKey: string,
|
|
139
|
+
private apiSecret: string,
|
|
140
|
+
private from: string,
|
|
141
|
+
) {}
|
|
142
|
+
|
|
143
|
+
async send(options: SmsSendOptions): Promise<SmsSendResult> {
|
|
144
|
+
const resp = await fetch('https://rest.nexmo.com/sms/json', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Type': 'application/json',
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
api_key: this.apiKey,
|
|
151
|
+
api_secret: this.apiSecret,
|
|
152
|
+
from: this.from,
|
|
153
|
+
to: options.to,
|
|
154
|
+
text: options.body,
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!resp.ok) {
|
|
159
|
+
const text = await resp.text();
|
|
160
|
+
console.error('[SmsProvider:Vonage] Failed:', resp.status, text);
|
|
161
|
+
return { success: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = (await resp.json()) as {
|
|
165
|
+
messages?: Array<{ 'message-id'?: string; status?: string }>;
|
|
166
|
+
};
|
|
167
|
+
const msg = data.messages?.[0];
|
|
168
|
+
if (msg?.status !== '0') {
|
|
169
|
+
console.error('[SmsProvider:Vonage] Send failed:', msg);
|
|
170
|
+
return { success: false };
|
|
171
|
+
}
|
|
172
|
+
return { success: true, messageId: msg['message-id'] };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Factory ───
|
|
177
|
+
|
|
178
|
+
export interface SmsConfig {
|
|
179
|
+
provider: 'twilio' | 'messagebird' | 'vonage';
|
|
180
|
+
accountSid?: string; // Twilio
|
|
181
|
+
authToken?: string; // Twilio
|
|
182
|
+
apiKey?: string; // MessageBird, Vonage
|
|
183
|
+
apiSecret?: string; // Vonage
|
|
184
|
+
from: string; // Sender phone number (E.164)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create an SmsProvider instance from config.
|
|
189
|
+
* Returns null if config is missing (SMS features disabled).
|
|
190
|
+
*/
|
|
191
|
+
export function createSmsProvider(
|
|
192
|
+
config?: SmsConfig,
|
|
193
|
+
env?: SmsProviderEnv,
|
|
194
|
+
): SmsProvider | null {
|
|
195
|
+
const mockEndpoint = env?.EDGEBASE_SMS_API_URL?.trim()?.replace(/\/$/, '');
|
|
196
|
+
if (mockEndpoint) {
|
|
197
|
+
return new MockSmsProvider(mockEndpoint, config?.from ?? 'mock');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!config) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
switch (config.provider) {
|
|
205
|
+
case 'twilio': {
|
|
206
|
+
if (!config.accountSid || !config.authToken) {
|
|
207
|
+
console.warn('[SmsProvider] Twilio requires accountSid and authToken.');
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return new TwilioProvider(config.accountSid, config.authToken, config.from);
|
|
211
|
+
}
|
|
212
|
+
case 'messagebird': {
|
|
213
|
+
if (!config.apiKey) {
|
|
214
|
+
console.warn('[SmsProvider] MessageBird requires apiKey.');
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return new MessageBirdProvider(config.apiKey, config.from);
|
|
218
|
+
}
|
|
219
|
+
case 'vonage': {
|
|
220
|
+
if (!config.apiKey || !config.apiSecret) {
|
|
221
|
+
console.warn('[SmsProvider] Vonage requires apiKey and apiSecret.');
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return new VonageProvider(config.apiKey, config.apiSecret, config.from);
|
|
225
|
+
}
|
|
226
|
+
default:
|
|
227
|
+
console.warn(`[SmsProvider] Unknown provider: ${config.provider}. SMS features disabled.`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|