@cobinar/dalus 0.1.8 → 0.1.11

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.
Files changed (41) hide show
  1. package/README.md +10 -36
  2. package/bin/{dalus.js → dalus.mjs} +5 -14
  3. package/convert_to_mjs.py +59 -0
  4. package/fix_requires.py +57 -0
  5. package/package.json +4 -4
  6. package/publish-dalus.bat +1 -1
  7. package/src/{api-client.js → api-client.mjs} +67 -67
  8. package/src/commands/{forge.js → forge.mjs} +104 -104
  9. package/src/commands/{init.js → init.mjs} +53 -53
  10. package/src/{forgers/login.js → commands/login.mjs} +288 -288
  11. package/src/{config.js → config.mjs} +147 -147
  12. package/src/cors.mjs +19 -0
  13. package/src/forgers/{database.js → database.mjs} +34 -34
  14. package/src/forgers/{pages.js → pages.mjs} +35 -35
  15. package/src/forgers/storage.mjs +39 -0
  16. package/src/forgers/{vault.js → vault.mjs} +27 -27
  17. package/src/forgers/{workers.js → workers.mjs} +85 -85
  18. package/src/{fs-utils.js → fs-utils.mjs} +36 -36
  19. package/src/handlers/login-callback.mjs +143 -0
  20. package/src/handlers/services.mjs +45 -0
  21. package/src/index.mjs +36 -0
  22. package/src/auth.js +0 -78
  23. package/src/commands/auth.js +0 -78
  24. package/src/commands/cors.js +0 -34
  25. package/src/commands/index.js +0 -115
  26. package/src/commands/login.js +0 -288
  27. package/src/commands/storage.js +0 -72
  28. package/src/commands/utils.js +0 -7
  29. package/src/commands/validators.js +0 -101
  30. package/src/cors.js +0 -34
  31. package/src/forgers/auth.js +0 -78
  32. package/src/forgers/cors.js +0 -34
  33. package/src/forgers/index.js +0 -115
  34. package/src/forgers/storage.js +0 -72
  35. package/src/forgers/utils.js +0 -7
  36. package/src/forgers/validators.js +0 -101
  37. package/src/index.js +0 -115
  38. package/src/login.js +0 -288
  39. package/src/storage.js +0 -72
  40. package/src/utils.js +0 -7
  41. package/src/validators.js +0 -101
@@ -1,85 +1,85 @@
1
- 'use strict';
2
- const fs = require('fs');
3
- const readline = require('readline');
4
-
5
- function prompt(question) {
6
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
- return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer); }));
8
- }
9
-
10
- /** Resolves { name, type: 'storage'|'database', resource } bindings from
11
- * config into the { resourceId } shape the API wants, by name-matching
12
- * against the owner's existing buckets/collections — bindings reference
13
- * resources by NAME in dalus config (portable across accounts/environments),
14
- * but the API itself keys them by id. */
15
- async function resolveBindingResourceId(api, binding) {
16
- const listPath = binding.type === 'storage' ? '/storage/buckets' : '/database/collections';
17
- const kindLabel = binding.type === 'storage' ? 'storage bucket' : 'Document DB collection';
18
- const found = await api.findByName(listPath, binding.resource);
19
- if (!found) {
20
- throw new Error(`Binding "${binding.name}" references ${kindLabel} "${binding.resource}", which doesn't exist yet — forge that resource first (dalus forge --storage or --database).`);
21
- }
22
- return found.id;
23
- }
24
-
25
- async function syncBindings(api, log, serviceId, bindingsConfig) {
26
- if (!bindingsConfig || bindingsConfig.length === 0) return;
27
- const existing = await api.get(`/services/${serviceId}/bindings`);
28
- for (const binding of bindingsConfig) {
29
- if (existing.some((e) => e.bindingName === binding.name)) {
30
- log(` binding ${binding.name} already exists, skipping`);
31
- continue;
32
- }
33
- const resourceId = await resolveBindingResourceId(api, binding);
34
- await api.post(`/services/${serviceId}/bindings`, {
35
- bindingName: binding.name,
36
- resourceType: binding.type,
37
- resourceId,
38
- canWrite: !!binding.canWrite,
39
- });
40
- log(` bound env.${binding.name} -> ${binding.type}:${binding.resource}${binding.canWrite ? ' (read/write)' : ''}`);
41
- }
42
- }
43
-
44
- async function syncSecrets(api, log, serviceId, secretNames, { yes }) {
45
- if (!secretNames || secretNames.length === 0) return;
46
- for (const name of secretNames) {
47
- let value = process.env[name];
48
- if (!value) {
49
- if (yes) {
50
- log(` skipping secret ${name} (no ${name} env var set, and --yes disables prompting)`);
51
- continue;
52
- }
53
- value = await prompt(` Enter value for secret ${name} (or press Enter to skip): `);
54
- if (!value) { log(` skipped ${name}`); continue; }
55
- }
56
- await api.post(`/services/${serviceId}/secrets`, { name, value });
57
- log(` set secret env.${name}`);
58
- }
59
- }
60
-
61
- /** Deploys one Edge Compute service: find-or-create by name, upload its
62
- * code, then sync bindings and secrets. Bindings/secrets are synced
63
- * every run (idempotent — see syncBindings/syncSecrets above); the code
64
- * upload always overwrites, same as `wrangler deploy` always overwriting
65
- * a Worker's script. */
66
- async function forgeWorker(api, log, entry, opts) {
67
- log(`Edge Compute: ${entry.name}`);
68
- if (!fs.existsSync(entry.main)) throw new Error(`main file not found: ${entry.main}`);
69
- const code = fs.readFileSync(entry.main, 'utf8');
70
-
71
- let svc = await api.findByName('/services', entry.name);
72
- if (!svc) {
73
- log(` creating service...`);
74
- svc = await api.post('/services', { name: entry.name });
75
- }
76
- await api.put(`/services/${svc.id}/code`, { code });
77
- log(` code uploaded (${code.length} bytes)`);
78
-
79
- await syncBindings(api, log, svc.id, entry.bindings);
80
- await syncSecrets(api, log, svc.id, entry.secrets, opts);
81
-
82
- log(` done -> test at ${api.apiBase}/run/${entry.name}`);
83
- }
84
-
85
- module.exports = { forgeWorker };
1
+ 'use strict';
2
+ import fs from 'fs';
3
+ import readline from 'readline';
4
+
5
+ function prompt(question) {
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer); }));
8
+ }
9
+
10
+ /** Resolves { name, type: 'storage'|'database', resource } bindings from
11
+ * config into the { resourceId } shape the API wants, by name-matching
12
+ * against the owner's existing buckets/collections — bindings reference
13
+ * resources by NAME in dalus config (portable across accounts/environments),
14
+ * but the API itself keys them by id. */
15
+ async function resolveBindingResourceId(api, binding) {
16
+ const listPath = binding.type === 'storage' ? '/storage/buckets' : '/database/collections';
17
+ const kindLabel = binding.type === 'storage' ? 'storage bucket' : 'Document DB collection';
18
+ const found = await api.findByName(listPath, binding.resource);
19
+ if (!found) {
20
+ throw new Error(`Binding "${binding.name}" references ${kindLabel} "${binding.resource}", which doesn't exist yet — forge that resource first (dalus forge --storage or --database).`);
21
+ }
22
+ return found.id;
23
+ }
24
+
25
+ async function syncBindings(api, log, serviceId, bindingsConfig) {
26
+ if (!bindingsConfig || bindingsConfig.length === 0) return;
27
+ const existing = await api.get(`/services/${serviceId}/bindings`);
28
+ for (const binding of bindingsConfig) {
29
+ if (existing.some((e) => e.bindingName === binding.name)) {
30
+ log(` binding ${binding.name} already exists, skipping`);
31
+ continue;
32
+ }
33
+ const resourceId = await resolveBindingResourceId(api, binding);
34
+ await api.post(`/services/${serviceId}/bindings`, {
35
+ bindingName: binding.name,
36
+ resourceType: binding.type,
37
+ resourceId,
38
+ canWrite: !!binding.canWrite,
39
+ });
40
+ log(` bound env.${binding.name} -> ${binding.type}:${binding.resource}${binding.canWrite ? ' (read/write)' : ''}`);
41
+ }
42
+ }
43
+
44
+ async function syncSecrets(api, log, serviceId, secretNames, { yes }) {
45
+ if (!secretNames || secretNames.length === 0) return;
46
+ for (const name of secretNames) {
47
+ let value = process.env[name];
48
+ if (!value) {
49
+ if (yes) {
50
+ log(` skipping secret ${name} (no ${name} env var set, and --yes disables prompting)`);
51
+ continue;
52
+ }
53
+ value = await prompt(` Enter value for secret ${name} (or press Enter to skip): `);
54
+ if (!value) { log(` skipped ${name}`); continue; }
55
+ }
56
+ await api.post(`/services/${serviceId}/secrets`, { name, value });
57
+ log(` set secret env.${name}`);
58
+ }
59
+ }
60
+
61
+ /** Deploys one Edge Compute service: find-or-create by name, upload its
62
+ * code, then sync bindings and secrets. Bindings/secrets are synced
63
+ * every run (idempotent — see syncBindings/syncSecrets above); the code
64
+ * upload always overwrites, same as `wrangler deploy` always overwriting
65
+ * a Worker's script. */
66
+ async function forgeWorker(api, log, entry, opts) {
67
+ log(`Edge Compute: ${entry.name}`);
68
+ if (!fs.existsSync(entry.main)) throw new Error(`main file not found: ${entry.main}`);
69
+ const code = fs.readFileSync(entry.main, 'utf8');
70
+
71
+ let svc = await api.findByName('/services', entry.name);
72
+ if (!svc) {
73
+ log(` creating service...`);
74
+ svc = await api.post('/services', { name: entry.name });
75
+ }
76
+ await api.put(`/services/${svc.id}/code`, { code });
77
+ log(` code uploaded (${code.length} bytes)`);
78
+
79
+ await syncBindings(api, log, svc.id, entry.bindings);
80
+ await syncSecrets(api, log, svc.id, entry.secrets, opts);
81
+
82
+ log(` done -> test at ${api.apiBase}/run/${entry.name}`);
83
+ }
84
+
85
+ export { forgeWorker };
@@ -1,36 +1,36 @@
1
- 'use strict';
2
- const fs = require('fs');
3
- const path = require('path');
4
-
5
- /** Recursively lists every file under `dir`, returning paths relative to
6
- * `dir` with forward slashes (matching how the dashboard itself stores
7
- * Pages/Storage keys) — regardless of the host OS's own separator. */
8
- function walkFiles(dir) {
9
- const out = [];
10
- const walk = (current) => {
11
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
12
- if (entry.name.startsWith('.')) continue; // .git, .DS_Store, etc.
13
- const full = path.join(current, entry.name);
14
- if (entry.isDirectory()) walk(full);
15
- else out.push(path.relative(dir, full).split(path.sep).join('/'));
16
- }
17
- };
18
- if (!fs.existsSync(dir)) throw new Error(`Directory not found: ${dir}`);
19
- walk(dir);
20
- return out;
21
- }
22
-
23
- const EXT_TO_CONTENT_TYPE = {
24
- html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
25
- mjs: 'application/javascript', json: 'application/json', svg: 'image/svg+xml',
26
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
27
- webp: 'image/webp', ico: 'image/x-icon', txt: 'text/plain', md: 'text/markdown',
28
- xml: 'application/xml', pdf: 'application/pdf', woff: 'font/woff', woff2: 'font/woff2',
29
- };
30
-
31
- function guessContentType(filePath) {
32
- const ext = path.extname(filePath).slice(1).toLowerCase();
33
- return EXT_TO_CONTENT_TYPE[ext] || 'application/octet-stream';
34
- }
35
-
36
- module.exports = { walkFiles, guessContentType };
1
+ 'use strict';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ /** Recursively lists every file under `dir`, returning paths relative to
6
+ * `dir` with forward slashes (matching how the dashboard itself stores
7
+ * Pages/Storage keys) — regardless of the host OS's own separator. */
8
+ function walkFiles(dir) {
9
+ const out = [];
10
+ const walk = (current) => {
11
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
12
+ if (entry.name.startsWith('.')) continue; // .git, .DS_Store, etc.
13
+ const full = path.join(current, entry.name);
14
+ if (entry.isDirectory()) walk(full);
15
+ else out.push(path.relative(dir, full).split(path.sep).join('/'));
16
+ }
17
+ };
18
+ if (!fs.existsSync(dir)) throw new Error(`Directory not found: ${dir}`);
19
+ walk(dir);
20
+ return out;
21
+ }
22
+
23
+ const EXT_TO_CONTENT_TYPE = {
24
+ html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
25
+ mjs: 'application/javascript', json: 'application/json', svg: 'image/svg+xml',
26
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
27
+ webp: 'image/webp', ico: 'image/x-icon', txt: 'text/plain', md: 'text/markdown',
28
+ xml: 'application/xml', pdf: 'application/pdf', woff: 'font/woff', woff2: 'font/woff2',
29
+ };
30
+
31
+ function guessContentType(filePath) {
32
+ const ext = path.extname(filePath).slice(1).toLowerCase();
33
+ return EXT_TO_CONTENT_TYPE[ext] || 'application/octet-stream';
34
+ }
35
+
36
+ export { walkFiles, guessContentType };
@@ -0,0 +1,143 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // src/handlers/login-callback.js — dalus.cobinar.com's OAuth code exchange
3
+ //
4
+ // Fully self-contained: this worker's own client_id/secret, its own copy of
5
+ // the couple of small helpers it needs, nothing imported from cobinar-main's
6
+ // project. The only thing shared with the rest of Cobinar Systems is the
7
+ // identity provider itself (auth.cobinar.com — same one every Cobinar
8
+ // product is a client of) and the SSO-code-handoff CONTRACT: the KV key
9
+ // format cobinar-developers-worker's own /auth/redeem-code expects. That
10
+ // contract is about whose identity just signed in, not which worker or
11
+ // which OAuth client asked, so it needs no changes to work from here.
12
+ //
13
+ // Frontend contract:
14
+ // POST /api/login/callback
15
+ // { "code": "<authorization code from ?code=>",
16
+ // "redirect_uri": "<the exact redirect_uri used in /oauth/authorize>" }
17
+ // → 200 { ok:true, user:{ uid, email, name, picture }, ssoCode:"..." }
18
+ // → 4xx { ok:false, error:"human readable message" }
19
+ //
20
+ // Required bindings (see wrangler.jsonc / .dev.vars.example):
21
+ // DALUS_AUTH_CLIENT_ID, DALUS_AUTH_CLIENT_SECRET, DALUS_AUTH_BASE, SSO_KV
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+
24
+ import { corsHeaders } from '../cors.mjs';
25
+
26
+ const DEFAULT_AUTH_BASE = 'https://auth.cobinar.com';
27
+ const SSO_CODE_TTL_SECONDS = 120;
28
+
29
+ export async function handleLoginCallback(request, env) {
30
+ const headers = { 'Content-Type': 'application/json', ...corsHeaders(request) };
31
+ const fail = (message, status = 400) =>
32
+ new Response(JSON.stringify({ ok: false, error: message }), { status, headers });
33
+
34
+ const authBase = env.DALUS_AUTH_BASE || DEFAULT_AUTH_BASE;
35
+ const clientId = env.DALUS_AUTH_CLIENT_ID;
36
+ const clientSecret = env.DALUS_AUTH_CLIENT_SECRET;
37
+ if (!clientId || !clientSecret) {
38
+ return fail('Sign-in is not configured yet on the server.', 500);
39
+ }
40
+
41
+ let body;
42
+ try {
43
+ body = await request.json();
44
+ } catch {
45
+ return fail('Malformed request.');
46
+ }
47
+
48
+ const code = typeof body?.code === 'string' ? body.code.trim() : '';
49
+ if (!code) return fail('Missing authorization code.');
50
+
51
+ const redirectUri = typeof body?.redirect_uri === 'string' ? body.redirect_uri.trim() : '';
52
+ if (!/^https:\/\/[a-zA-Z0-9.-]+(\/[^\s]*)?$/.test(redirectUri)) {
53
+ return fail('Missing or invalid redirect_uri.');
54
+ }
55
+
56
+ // ── Step 1: exchange the code for tokens (server-side only) ───────────────
57
+ let tokenJson;
58
+ try {
59
+ const tokenRes = await fetch(`${authBase}/oauth/token`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({
63
+ grant_type: 'authorization_code',
64
+ code,
65
+ client_id: clientId,
66
+ client_secret: clientSecret,
67
+ redirect_uri: redirectUri,
68
+ }),
69
+ });
70
+ tokenJson = await safeJson(tokenRes);
71
+ if (!tokenRes.ok || !tokenJson?.access_token) {
72
+ console.error('[login-callback] token exchange rejected:', tokenRes.status, JSON.stringify(tokenJson), 'redirect_uri sent:', redirectUri);
73
+ return fail(friendlyOAuthError(tokenJson), tokenRes.status === 401 ? 401 : 400);
74
+ }
75
+ } catch (err) {
76
+ console.error('[login-callback] token fetch threw:', err?.name, err?.message);
77
+ return fail('Could not reach the sign-in service. Please try again.', 502);
78
+ }
79
+
80
+ // ── Step 2: fetch the signed-in user's profile ─────────────────────────────
81
+ let userInfo;
82
+ try {
83
+ const userRes = await fetch(`${authBase}/oauth/userinfo`, {
84
+ headers: { Authorization: `Bearer ${tokenJson.access_token}` },
85
+ });
86
+ userInfo = await safeJson(userRes);
87
+ if (!userRes.ok || !userInfo) {
88
+ console.error('[login-callback] userinfo rejected:', userRes.status, JSON.stringify(userInfo));
89
+ return fail('Signed in, but could not load your profile. Please try again.', 502);
90
+ }
91
+ } catch (err) {
92
+ console.error('[login-callback] userinfo fetch threw:', err?.name, err?.message);
93
+ return fail('Signed in, but could not load your profile. Please try again.', 502);
94
+ }
95
+
96
+ const user = {
97
+ uid: userInfo.uid || userInfo.sub || null,
98
+ email: userInfo.email || userInfo.cobinarEmail || null,
99
+ name: userInfo.name || null,
100
+ picture: userInfo.picture || null,
101
+ };
102
+ if (!user.uid || !user.email) {
103
+ return fail('Sign-in succeeded but the profile was incomplete.', 502);
104
+ }
105
+
106
+ // ── Step 3: hand off an SSO code — cobinar-developers-worker redeems this
107
+ // for the workerToken dalus actually needs; this handler never mints
108
+ // one itself. ─────────────────────────────────────────────────────────
109
+ const ssoCode = crypto.randomUUID().replace(/-/g, '');
110
+ try {
111
+ await env.SSO_KV.put(
112
+ `sso:${ssoCode}`,
113
+ JSON.stringify({ uid: user.uid, email: user.email, name: user.name, picture: user.picture }),
114
+ { expirationTtl: SSO_CODE_TTL_SECONDS },
115
+ );
116
+ } catch (err) {
117
+ console.error('[login-callback] SSO_KV write failed:', err?.name, err?.message);
118
+ return fail('Signed in, but could not finish preparing your credentials. Please try again.', 502);
119
+ }
120
+
121
+ return new Response(JSON.stringify({ ok: true, user, ssoCode }), { status: 200, headers });
122
+ }
123
+
124
+ async function safeJson(res) {
125
+ try {
126
+ return await res.json();
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function friendlyOAuthError(tokenJson) {
133
+ switch (tokenJson?.error || '') {
134
+ case 'invalid_grant':
135
+ return 'That sign-in link expired or was already used. Please sign in again.';
136
+ case 'invalid_client':
137
+ return 'Sign-in is misconfigured on our end. Please try again later.';
138
+ case 'access_denied':
139
+ return 'Sign-in was cancelled.';
140
+ default:
141
+ return 'Could not complete sign-in. Please try again.';
142
+ }
143
+ }
@@ -0,0 +1,45 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // src/handlers/services.js — where do Cobinar's other workers live right now
3
+ //
4
+ // One JSON endpoint, backed by KV instead of source code, specifically so
5
+ // a worker's public URL can change WITHOUT redeploying whatever reads it —
6
+ // today that's the dalus CLI (an npm package, the slowest thing in this
7
+ // whole system to get everyone to update), so it looks this up once at the
8
+ // start of `dalus login` instead of shipping a fixed URL in the package.
9
+ //
10
+ // GET /api/services
11
+ // → 200 { "dalus": "https://8bnk.dalus.cobinar.com" }
12
+ //
13
+ // Values live in SSO_KV under a `service:<name>` key (the same shared
14
+ // namespace this worker already uses for the SSO code hand-off — reused
15
+ // here for a second, unrelated-but-convenient purpose: a permanent value
16
+ // instead of the hand-off's short-TTL ones, so give it its own key prefix
17
+ // to keep the two apart). Set/update one with:
18
+ //
19
+ // wrangler kv key put --binding=SSO_KV "service:dalus" "https://8bnk.dalus.cobinar.com"
20
+ //
21
+ // No redeploy of THIS worker needed either when a URL changes — that's the
22
+ // whole point. FALLBACK below is only so this endpoint returns something
23
+ // sane before that command has ever been run once.
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+
26
+ const FALLBACK = {
27
+ dalus: 'https://8bnk.dalus.cobinar.com',
28
+ };
29
+
30
+ export async function handleServices(request, env) {
31
+ const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' };
32
+
33
+ const result = {};
34
+ for (const name of Object.keys(FALLBACK)) {
35
+ let value = null;
36
+ try {
37
+ value = await env.SSO_KV.get(`service:${name}`);
38
+ } catch (err) {
39
+ console.error('[services] KV read failed for', name, ':', err?.message);
40
+ }
41
+ result[name] = value || FALLBACK[name];
42
+ }
43
+
44
+ return new Response(JSON.stringify(result), { status: 200, headers });
45
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // src/index.js — dalus.cobinar.com · Entry Point
3
+ //
4
+ // Everything about the dalus CLI that lives on the web: the landing page,
5
+ // docs, and the browser-based sign-in flow it opens (see public/login/).
6
+ // A standalone Worker, independent of cobinar-main — the only thing it
7
+ // shares with the rest of Cobinar Systems is auth.cobinar.com (the identity
8
+ // provider every product is a client of) and the SSO_KV binding used to
9
+ // hand a signed-in identity off to cobinar-developers-worker.
10
+ //
11
+ // Routes:
12
+ // POST /api/login/callback — OAuth code exchange (public — a visitor
13
+ // has no session yet, this is what
14
+ // creates the SSO code for one)
15
+ // (anything else) — env.ASSETS (index.html, docs, login
16
+ // pages, css)
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+
19
+ import { handleCors } from './cors.mjs';
20
+ import { handleLoginCallback } from './handlers/login-callback.mjs';
21
+
22
+ export default {
23
+ async fetch(request, env) {
24
+ const url = new URL(request.url);
25
+ const { pathname } = url;
26
+ const { method } = request;
27
+
28
+ if (method === 'OPTIONS') return handleCors(request);
29
+
30
+ if (pathname === '/api/login/callback' && method === 'POST') {
31
+ return handleLoginCallback(request, env);
32
+ }
33
+
34
+ return env.ASSETS.fetch(request);
35
+ },
36
+ };
package/src/auth.js DELETED
@@ -1,78 +0,0 @@
1
- // src/auth.js
2
-
3
- const GOOGLE_JWK_URL = 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
4
-
5
- let _keyCache = null;
6
- let _cacheExpires = 0;
7
-
8
- async function getVerificationKeys() {
9
- const now = Date.now();
10
- if (_keyCache && now < _cacheExpires) return _keyCache;
11
-
12
- const res = await fetch(GOOGLE_JWK_URL);
13
- if (!res.ok) throw new Error('Failed to fetch Firebase JWKs');
14
- const jwks = await res.json();
15
-
16
- const cc = res.headers.get('Cache-Control') || '';
17
- const maxAge = parseInt((cc.match(/max-age=(\d+)/) || [, '3600'])[1], 10);
18
- _cacheExpires = now + maxAge * 1000;
19
-
20
- _keyCache = new Map();
21
- await Promise.all(
22
- (jwks.keys || []).map(async (jwk) => {
23
- const key = await crypto.subtle.importKey(
24
- 'jwk',
25
- jwk,
26
- { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
27
- false,
28
- ['verify'],
29
- );
30
- _keyCache.set(jwk.kid, key);
31
- }),
32
- );
33
- return _keyCache;
34
- }
35
-
36
- function base64urlDecode(str) {
37
- const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
38
- const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
39
- return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
40
- }
41
-
42
- function b64DecodeJson(b64url) {
43
- return JSON.parse(new TextDecoder().decode(base64urlDecode(b64url)));
44
- }
45
-
46
- export async function verifyFirebaseToken(idToken, projectId) {
47
- const parts = idToken.split('.');
48
- if (parts.length !== 3) throw new Error('Malformed JWT: expected 3 segments');
49
- const [headerB64, payloadB64, sigB64] = parts;
50
-
51
- let header, payload;
52
- try {
53
- header = b64DecodeJson(headerB64);
54
- payload = b64DecodeJson(payloadB64);
55
- } catch {
56
- throw new Error('Could not decode JWT segments');
57
- }
58
-
59
- const now = Math.floor(Date.now() / 1000);
60
- if (header.alg !== 'RS256') throw new Error(`Unexpected algorithm: ${header.alg}`);
61
- if (payload.aud !== projectId) throw new Error(`Invalid audience: expected "${projectId}", got "${payload.aud}"`);
62
- if (payload.iss !== `https://securetoken.google.com/${projectId}`) throw new Error('Invalid issuer');
63
- if (payload.exp < now) throw new Error('Token expired');
64
- if (payload.iat > now + 60) throw new Error('Token issued in the future — clock skew too large');
65
- if (!payload.sub) throw new Error('Missing subject claim');
66
-
67
- const keys = await getVerificationKeys();
68
- const key = keys.get(header.kid);
69
- if (!key) throw new Error(`Unknown key ID: ${header.kid}`);
70
-
71
- const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
72
- const signature = base64urlDecode(sigB64);
73
- const valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, signingInput);
74
- if (!valid) throw new Error('Signature verification failed');
75
-
76
- payload.uid = payload.sub || payload.user_id;
77
- return payload;
78
- }
@@ -1,78 +0,0 @@
1
- // src/auth.js
2
-
3
- const GOOGLE_JWK_URL = 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
4
-
5
- let _keyCache = null;
6
- let _cacheExpires = 0;
7
-
8
- async function getVerificationKeys() {
9
- const now = Date.now();
10
- if (_keyCache && now < _cacheExpires) return _keyCache;
11
-
12
- const res = await fetch(GOOGLE_JWK_URL);
13
- if (!res.ok) throw new Error('Failed to fetch Firebase JWKs');
14
- const jwks = await res.json();
15
-
16
- const cc = res.headers.get('Cache-Control') || '';
17
- const maxAge = parseInt((cc.match(/max-age=(\d+)/) || [, '3600'])[1], 10);
18
- _cacheExpires = now + maxAge * 1000;
19
-
20
- _keyCache = new Map();
21
- await Promise.all(
22
- (jwks.keys || []).map(async (jwk) => {
23
- const key = await crypto.subtle.importKey(
24
- 'jwk',
25
- jwk,
26
- { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
27
- false,
28
- ['verify'],
29
- );
30
- _keyCache.set(jwk.kid, key);
31
- }),
32
- );
33
- return _keyCache;
34
- }
35
-
36
- function base64urlDecode(str) {
37
- const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
38
- const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
39
- return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
40
- }
41
-
42
- function b64DecodeJson(b64url) {
43
- return JSON.parse(new TextDecoder().decode(base64urlDecode(b64url)));
44
- }
45
-
46
- export async function verifyFirebaseToken(idToken, projectId) {
47
- const parts = idToken.split('.');
48
- if (parts.length !== 3) throw new Error('Malformed JWT: expected 3 segments');
49
- const [headerB64, payloadB64, sigB64] = parts;
50
-
51
- let header, payload;
52
- try {
53
- header = b64DecodeJson(headerB64);
54
- payload = b64DecodeJson(payloadB64);
55
- } catch {
56
- throw new Error('Could not decode JWT segments');
57
- }
58
-
59
- const now = Math.floor(Date.now() / 1000);
60
- if (header.alg !== 'RS256') throw new Error(`Unexpected algorithm: ${header.alg}`);
61
- if (payload.aud !== projectId) throw new Error(`Invalid audience: expected "${projectId}", got "${payload.aud}"`);
62
- if (payload.iss !== `https://securetoken.google.com/${projectId}`) throw new Error('Invalid issuer');
63
- if (payload.exp < now) throw new Error('Token expired');
64
- if (payload.iat > now + 60) throw new Error('Token issued in the future — clock skew too large');
65
- if (!payload.sub) throw new Error('Missing subject claim');
66
-
67
- const keys = await getVerificationKeys();
68
- const key = keys.get(header.kid);
69
- if (!key) throw new Error(`Unknown key ID: ${header.kid}`);
70
-
71
- const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
72
- const signature = base64urlDecode(sigB64);
73
- const valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, signingInput);
74
- if (!valid) throw new Error('Signature verification failed');
75
-
76
- payload.uid = payload.sub || payload.user_id;
77
- return payload;
78
- }
@@ -1,34 +0,0 @@
1
- // src/cors.js
2
-
3
- export function getAllowedOrigins(env) {
4
- return (env.ALLOWED_ORIGINS || '')
5
- .split(/[,\n\r]+/)
6
- .map((o) => o.trim())
7
- .map((o) => o.replace(/[\r\n\t\x00-\x1F\x7F]/g, ''))
8
- .filter((o) => /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?$/.test(o));
9
- }
10
-
11
- export function corsHeaders(env, requestOrigin = '') {
12
- const allowed = getAllowedOrigins(env);
13
- const origin = allowed.includes(requestOrigin) ? requestOrigin : allowed[0] || '';
14
- const headers = {
15
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
16
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
17
- 'Access-Control-Expose-Headers': 'X-Upload-Key, X-Served-By',
18
- Vary: 'Origin',
19
- };
20
- if (origin) {
21
- headers['Access-Control-Allow-Origin'] = origin;
22
- headers['Access-Control-Allow-Credentials'] = 'true';
23
- headers['Access-Control-Max-Age'] = '86400';
24
- }
25
- return headers;
26
- }
27
-
28
- export function handleCors(request, env) {
29
- const origin = request.headers.get('Origin') || '';
30
- return new Response(null, {
31
- status: 204,
32
- headers: corsHeaders(env, origin),
33
- });
34
- }