@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.
- package/README.md +10 -36
- package/bin/{dalus.js → dalus.mjs} +5 -14
- package/convert_to_mjs.py +59 -0
- package/fix_requires.py +57 -0
- package/package.json +4 -4
- package/publish-dalus.bat +1 -1
- package/src/{api-client.js → api-client.mjs} +67 -67
- package/src/commands/{forge.js → forge.mjs} +104 -104
- package/src/commands/{init.js → init.mjs} +53 -53
- package/src/{forgers/login.js → commands/login.mjs} +288 -288
- package/src/{config.js → config.mjs} +147 -147
- package/src/cors.mjs +19 -0
- package/src/forgers/{database.js → database.mjs} +34 -34
- package/src/forgers/{pages.js → pages.mjs} +35 -35
- package/src/forgers/storage.mjs +39 -0
- package/src/forgers/{vault.js → vault.mjs} +27 -27
- package/src/forgers/{workers.js → workers.mjs} +85 -85
- package/src/{fs-utils.js → fs-utils.mjs} +36 -36
- package/src/handlers/login-callback.mjs +143 -0
- package/src/handlers/services.mjs +45 -0
- package/src/index.mjs +36 -0
- package/src/auth.js +0 -78
- package/src/commands/auth.js +0 -78
- package/src/commands/cors.js +0 -34
- package/src/commands/index.js +0 -115
- package/src/commands/login.js +0 -288
- package/src/commands/storage.js +0 -72
- package/src/commands/utils.js +0 -7
- package/src/commands/validators.js +0 -101
- package/src/cors.js +0 -34
- package/src/forgers/auth.js +0 -78
- package/src/forgers/cors.js +0 -34
- package/src/forgers/index.js +0 -115
- package/src/forgers/storage.js +0 -72
- package/src/forgers/utils.js +0 -7
- package/src/forgers/validators.js +0 -101
- package/src/index.js +0 -115
- package/src/login.js +0 -288
- package/src/storage.js +0 -72
- package/src/utils.js +0 -7
- package/src/validators.js +0 -101
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/commands/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
|
-
}
|
package/src/commands/cors.js
DELETED
|
@@ -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
|
-
}
|