@cobinar/dalus 0.1.10 → 0.1.12
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/bin/{dalus.js → dalus.mjs} +3 -3
- package/convert_to_mjs.py +59 -0
- package/fix_requires.py +57 -0
- package/package.json +3 -3
- 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/commands/{login.js → login.mjs} +288 -288
- package/src/{config.js → config.mjs} +147 -147
- package/src/forgers/{database.js → database.mjs} +34 -34
- package/src/forgers/{pages.js → pages.mjs} +35 -35
- package/src/forgers/{storage.js → storage.mjs} +39 -39
- 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.js → login-callback.mjs} +143 -143
- package/src/handlers/{services.js → services.mjs} +1 -1
- package/src/{index.js → index.mjs} +36 -36
- package/worker/index.js +658 -0
- /package/src/{cors.js → cors.mjs} +0 -0
|
@@ -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 };
|
|
@@ -1,143 +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.
|
|
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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,36 +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.
|
|
20
|
-
import { handleLoginCallback } from './handlers/login-callback.
|
|
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
|
-
};
|
|
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
|
+
};
|