@cobinar/dalus 0.1.3 → 0.1.6
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/package.json +1 -1
- package/publish-dalus.bat +32 -0
- package/src/auth.js +78 -0
- package/src/commands/auth.js +78 -0
- package/src/commands/cors.js +34 -0
- package/src/commands/index.js +115 -0
- package/src/commands/login.js +80 -21
- package/src/commands/storage.js +72 -0
- package/src/commands/utils.js +7 -0
- package/src/commands/validators.js +101 -0
- package/src/cors.js +34 -0
- package/src/forgers/auth.js +78 -0
- package/src/forgers/cors.js +34 -0
- package/src/forgers/index.js +115 -0
- package/src/forgers/login.js +288 -0
- package/src/forgers/storage.js +63 -30
- package/src/forgers/utils.js +7 -0
- package/src/forgers/validators.js +101 -0
- package/src/index.js +115 -0
- package/src/login.js +288 -0
- package/src/storage.js +72 -0
- package/src/utils.js +7 -0
- package/src/validators.js +101 -0
package/src/forgers/storage.js
CHANGED
|
@@ -1,39 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const { walkFiles, guessContentType } = require('../fs-utils');
|
|
1
|
+
// src/storage.js
|
|
2
|
+
import { generateId } from './utils.js';
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* deploy tool's usual "this directory IS the desired state" model). */
|
|
11
|
-
async function forgeStorage(api, log, entry) {
|
|
12
|
-
log(`Object Storage: ${entry.name}`);
|
|
4
|
+
const FOLDER_COLLECTIONS = new Set(['posts', 'products', 'projects']);
|
|
5
|
+
const FLAT_COLLECTIONS = new Set(['images', 'files']);
|
|
6
|
+
export const ALL_COLLECTIONS = new Set([...FOLDER_COLLECTIONS, ...FLAT_COLLECTIONS]);
|
|
7
|
+
export { FOLDER_COLLECTIONS, FLAT_COLLECTIONS };
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
export function generateUploadPath(collection, entityId, ext, slot) {
|
|
10
|
+
const uid = generateId();
|
|
11
|
+
if (FLAT_COLLECTIONS.has(collection)) {
|
|
12
|
+
return `cobinar/${collection}/${uid}.${ext.toLowerCase()}`;
|
|
18
13
|
}
|
|
14
|
+
const prefix = slot ? `${slot}-${uid}` : uid;
|
|
15
|
+
return `cobinar/${collection}/${entityId}/${prefix}.${ext.toLowerCase()}`;
|
|
16
|
+
}
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
export async function uploadFile(bucket, key, body, contentType, metadata = {}) {
|
|
19
|
+
await bucket.put(key, body, {
|
|
20
|
+
httpMetadata: {
|
|
21
|
+
contentType,
|
|
22
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
23
|
+
},
|
|
24
|
+
customMetadata: {
|
|
25
|
+
uploadedAt: new Date().toISOString(),
|
|
26
|
+
...sanitizeMetadata(metadata),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return { key };
|
|
30
|
+
}
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
32
|
+
export async function deleteFiles(bucket, keys) {
|
|
33
|
+
if (!keys || keys.length === 0) return;
|
|
34
|
+
await bucket.delete(keys);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function streamFile(bucket, key, extraHeaders = {}) {
|
|
38
|
+
const object = await bucket.get(key);
|
|
39
|
+
if (!object) {
|
|
40
|
+
return new Response(JSON.stringify({ ok: false, error: 'File not found' }), {
|
|
41
|
+
status: 404,
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
});
|
|
34
44
|
}
|
|
45
|
+
const headers = new Headers({
|
|
46
|
+
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
|
|
47
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
48
|
+
ETag: object.etag,
|
|
49
|
+
'Last-Modified': object.uploaded?.toUTCString() || '',
|
|
50
|
+
'X-Served-By': 'cobinar-r2',
|
|
51
|
+
...extraHeaders,
|
|
52
|
+
});
|
|
53
|
+
return new Response(object.body, { headers });
|
|
54
|
+
}
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
export function getPublicUrl(key, env) {
|
|
57
|
+
if (env.R2_PUBLIC_DOMAIN) {
|
|
58
|
+
return `https://${env.R2_PUBLIC_DOMAIN}/${key}`;
|
|
59
|
+
}
|
|
60
|
+
const domain = env.WORKER_DOMAIN || 'cobinar-r2.workers.dev';
|
|
61
|
+
return `https://${domain}/assets/${key}`;
|
|
37
62
|
}
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
function sanitizeMetadata(obj) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
67
|
+
if (v !== null && v !== undefined) {
|
|
68
|
+
out[k] = String(v);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/validators.js
|
|
2
|
+
|
|
3
|
+
export const ALLOWED_TYPES = {
|
|
4
|
+
image: {
|
|
5
|
+
mimes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml'],
|
|
6
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'svg'],
|
|
7
|
+
maxBytes: 25 * 1024 * 1024,
|
|
8
|
+
label: 'Image',
|
|
9
|
+
},
|
|
10
|
+
projectImage: {
|
|
11
|
+
mimes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'],
|
|
12
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'avif'],
|
|
13
|
+
maxBytes: 10 * 1024 * 1024,
|
|
14
|
+
label: 'Project image',
|
|
15
|
+
},
|
|
16
|
+
productImage: {
|
|
17
|
+
mimes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'],
|
|
18
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'avif'],
|
|
19
|
+
maxBytes: 10 * 1024 * 1024,
|
|
20
|
+
label: 'Product image',
|
|
21
|
+
},
|
|
22
|
+
postMedia: {
|
|
23
|
+
mimes: [
|
|
24
|
+
'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif',
|
|
25
|
+
'video/mp4', 'video/webm', 'video/quicktime', 'application/pdf',
|
|
26
|
+
],
|
|
27
|
+
extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'mp4', 'webm', 'mov', 'pdf'],
|
|
28
|
+
maxBytes: 50 * 1024 * 1024,
|
|
29
|
+
label: 'Post media',
|
|
30
|
+
},
|
|
31
|
+
file: {
|
|
32
|
+
mimes: [
|
|
33
|
+
'application/pdf', 'application/zip', 'application/x-zip-compressed',
|
|
34
|
+
'application/octet-stream', 'text/plain', 'text/csv',
|
|
35
|
+
'application/vnd.ms-excel',
|
|
36
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
37
|
+
'application/msword',
|
|
38
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
39
|
+
'application/json',
|
|
40
|
+
],
|
|
41
|
+
extensions: ['pdf', 'zip', 'txt', 'csv', 'xls', 'xlsx', 'doc', 'docx', 'json', 'bin'],
|
|
42
|
+
maxBytes: 100 * 1024 * 1024,
|
|
43
|
+
label: 'File',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_RULE = {
|
|
48
|
+
posts: 'postMedia',
|
|
49
|
+
products: 'productImage',
|
|
50
|
+
projects: 'projectImage',
|
|
51
|
+
images: 'image',
|
|
52
|
+
files: 'file',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function validateFile(mimeType, sizeBytes, filename, ruleName) {
|
|
56
|
+
const rule = ALLOWED_TYPES[ruleName];
|
|
57
|
+
if (!rule) return { valid: false, error: `Unknown validation rule: "${ruleName}"` };
|
|
58
|
+
|
|
59
|
+
const baseMime = mimeType.split(';')[0].trim().toLowerCase();
|
|
60
|
+
if (!rule.mimes.includes(baseMime)) {
|
|
61
|
+
return { valid: false, error: `${rule.label} must be one of: ${rule.mimes.join(', ')}. Got: ${baseMime}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rawExt = (filename.split('.').pop() || '').toLowerCase();
|
|
65
|
+
if (!rule.extensions.includes(rawExt)) {
|
|
66
|
+
return { valid: false, error: `${rule.label} extension must be .${rule.extensions.join(', .')}. Got: .${rawExt}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (sizeBytes > rule.maxBytes) {
|
|
70
|
+
const maxMb = (rule.maxBytes / 1024 / 1024).toFixed(0);
|
|
71
|
+
const gotMb = (sizeBytes / 1024 / 1024).toFixed(1);
|
|
72
|
+
return { valid: false, error: `${rule.label} must be under ${maxMb} MB. Got: ${gotMb} MB` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { valid: true, ext: rawExt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function extFromMime(mimeType) {
|
|
79
|
+
const map = {
|
|
80
|
+
'image/jpeg': 'jpg',
|
|
81
|
+
'image/png': 'png',
|
|
82
|
+
'image/webp': 'webp',
|
|
83
|
+
'image/gif': 'gif',
|
|
84
|
+
'image/avif': 'avif',
|
|
85
|
+
'image/svg+xml': 'svg',
|
|
86
|
+
'video/mp4': 'mp4',
|
|
87
|
+
'video/webm': 'webm',
|
|
88
|
+
'video/quicktime': 'mov',
|
|
89
|
+
'application/pdf': 'pdf',
|
|
90
|
+
'application/zip': 'zip',
|
|
91
|
+
'application/x-zip-compressed': 'zip',
|
|
92
|
+
'text/plain': 'txt',
|
|
93
|
+
'text/csv': 'csv',
|
|
94
|
+
'application/vnd.ms-excel': 'xls',
|
|
95
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
96
|
+
'application/msword': 'doc',
|
|
97
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
98
|
+
'application/json': 'json',
|
|
99
|
+
};
|
|
100
|
+
return map[mimeType.split(';')[0].trim().toLowerCase()] || 'bin';
|
|
101
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// src/index.js — Cobinar R2 Worker · Entry Point
|
|
3
|
+
// Created by Marc-Arthur Samuel Dalus · Cobinar Systems
|
|
4
|
+
//
|
|
5
|
+
// Routes:
|
|
6
|
+
// GET /health — Uptime check (no auth)
|
|
7
|
+
// GET /assets/:key — Stream public R2 asset (CDN fallback)
|
|
8
|
+
// POST /api/auth/callback — Cobinar Auth (OAuth) code exchange (public —
|
|
9
|
+
// this is how a visitor becomes authenticated)
|
|
10
|
+
// GET /api/services — where Cobinar's other workers currently
|
|
11
|
+
// live (public, read-only; see that
|
|
12
|
+
// handler for why this is KV-backed)
|
|
13
|
+
// POST /api/upload — Upload file to R2 (Firebase auth + admin only)
|
|
14
|
+
// DELETE /api/delete — Delete R2 object (Firebase auth + admin only)
|
|
15
|
+
// (anything else) — Delegated to env.ASSETS (the site in ./public)
|
|
16
|
+
//
|
|
17
|
+
// assets.run_worker_first is set to `true` (unconditional) rather than a
|
|
18
|
+
// glob-pattern array scoped to /api/* — Cloudflare's asset-vs-worker
|
|
19
|
+
// precedence for that project turned out to return 405 for every method
|
|
20
|
+
// other than GET regardless of pattern, so this Worker now runs for every
|
|
21
|
+
// request and explicitly hands off anything it doesn't recognize to
|
|
22
|
+
// env.ASSETS.fetch(request) itself, rather than depending on that layer.
|
|
23
|
+
//
|
|
24
|
+
// Bindings (wrangler.toml):
|
|
25
|
+
// COBINAR_R2 R2Bucket — cobinar bucket
|
|
26
|
+
// FIREBASE_PROJECT_ID — cobinar-prod
|
|
27
|
+
// ALLOWED_ORIGINS — CORS allowlist
|
|
28
|
+
// WORKER_DOMAIN — worker.cobinar.com
|
|
29
|
+
// R2_PUBLIC_DOMAIN — cdn.cobinar.com
|
|
30
|
+
// ADMIN_EMAIL — cobinar@cobinar.com
|
|
31
|
+
// MAX_UPLOAD_SIZE — 104857600 (100 MB)
|
|
32
|
+
// COBINAR_AUTH_CLIENT_ID — Cobinar Auth OAuth client id (public)
|
|
33
|
+
// COBINAR_AUTH_CLIENT_SECRET — SECRET — set via `wrangler secret put`, never in this repo
|
|
34
|
+
// COBINAR_AUTH_BASE — https://auth.cobinar.com
|
|
35
|
+
//
|
|
36
|
+
// Folder layout in bucket:
|
|
37
|
+
// cobinar/posts/{postId}/{slot}-{uuid}.{ext} ← post media
|
|
38
|
+
// cobinar/products/{productId}/{slot}-{uuid}.{ext} ← product images
|
|
39
|
+
// cobinar/projects/{projectId}/{slot}-{uuid}.{ext} ← project assets
|
|
40
|
+
// cobinar/images/{uuid}.{ext} ← flat image store
|
|
41
|
+
// cobinar/files/{uuid}.{ext} ← flat file store
|
|
42
|
+
//
|
|
43
|
+
// CDN delivery (once cdn.cobinar.com R2 custom domain is configured):
|
|
44
|
+
// https://cdn.cobinar.com/{key} → direct R2 edge (zero Worker cost)
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
import { handleCors, corsHeaders } from './cors.js';
|
|
48
|
+
import { verifyFirebaseToken } from './auth.js';
|
|
49
|
+
import { handleUpload } from './handlers/upload.js';
|
|
50
|
+
import { handleDelete } from './handlers/delete.js';
|
|
51
|
+
import { handleServe } from './handlers/serve.js';
|
|
52
|
+
import { handleHealth } from './handlers/health.js';
|
|
53
|
+
import { handleAuthCallback } from './handlers/auth-callback.js';
|
|
54
|
+
import { handleServices } from './handlers/services.js';
|
|
55
|
+
|
|
56
|
+
export default {
|
|
57
|
+
async fetch(request, env, ctx) {
|
|
58
|
+
const url = new URL(request.url);
|
|
59
|
+
const path = url.pathname;
|
|
60
|
+
const method = request.method;
|
|
61
|
+
|
|
62
|
+
// ── CORS preflight ──────────────────────────────────────────────────────
|
|
63
|
+
if (method === 'OPTIONS') return handleCors(request, env);
|
|
64
|
+
|
|
65
|
+
// ── Health check (no auth) ──────────────────────────────────────────────
|
|
66
|
+
if (path === '/health' && method === 'GET') return handleHealth(request, env);
|
|
67
|
+
|
|
68
|
+
// ── Public asset serving — CDN fallback until cdn.cobinar.com is live ───
|
|
69
|
+
if (method === 'GET' && path.startsWith('/assets/')) {
|
|
70
|
+
return handleServe(request, env, path.slice('/assets/'.length));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Cobinar Auth code exchange — public. A visitor has no session yet;
|
|
74
|
+
// this is the one request that creates one, so it can't require one. ──
|
|
75
|
+
if (path === '/api/auth/callback' && method === 'POST') {
|
|
76
|
+
return handleAuthCallback(request, env);
|
|
77
|
+
}
|
|
78
|
+
if (path === '/api/services' && method === 'GET') {
|
|
79
|
+
return handleServices(request, env);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Authenticated API routes ────────────────────────────────────────────
|
|
83
|
+
if (path.startsWith('/api/')) {
|
|
84
|
+
const token = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '').trim();
|
|
85
|
+
if (!token) return jsonError('Missing Authorization header', 401, env, request);
|
|
86
|
+
|
|
87
|
+
let claims;
|
|
88
|
+
try {
|
|
89
|
+
claims = await verifyFirebaseToken(token, env.FIREBASE_PROJECT_ID);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return jsonError('Invalid or expired token: ' + e.message, 401, env, request);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (path === '/api/upload' && method === 'POST') return handleUpload(request, env, claims);
|
|
95
|
+
if (path === '/api/delete' && method === 'DELETE') return handleDelete(request, env, claims);
|
|
96
|
+
|
|
97
|
+
return jsonError('Unknown API route', 404, env, request);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Everything else: hand off to static assets (site pages, css, js, icons).
|
|
101
|
+
// Explicit here rather than relying on Cloudflare's asset-vs-worker
|
|
102
|
+
// precedence, since that's exactly the layer that's been unreliable. ──
|
|
103
|
+
return env.ASSETS.fetch(request);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function jsonError(message, status, env, request) {
|
|
108
|
+
const origin = request?.headers?.get('Origin') || '';
|
|
109
|
+
return new Response(JSON.stringify({ ok: false, error: message }), {
|
|
110
|
+
status,
|
|
111
|
+
headers: { 'Content-Type': 'application/json', ...corsHeaders(env, origin) },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Created by Marc-Arthur Samuel Dalus · Cobinar Systems
|
|
115
|
+
//
|
package/src/login.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
const { execFile } = require('child_process');
|
|
6
|
+
const { saveCredentials, clearCredentials, loadCredentials, CREDENTIALS_PATH } = require('../config');
|
|
7
|
+
|
|
8
|
+
// Where dalus's OWN worker (the login page this opens) currently lives is
|
|
9
|
+
// looked up here, not hardcoded — see cobinar's src/handlers/services.js.
|
|
10
|
+
// That's the one, deliberately-fixed anchor point everything else in this
|
|
11
|
+
// file is relative to; if IT ever needs to move, that's a cobinar.com
|
|
12
|
+
// redeploy, not a new npm release everyone has to go update.
|
|
13
|
+
const SERVICES_ENDPOINT = 'https://cobinar.com/api/services';
|
|
14
|
+
// Only used if the discovery call above fails outright (a network hiccup,
|
|
15
|
+
// cobinar.com briefly unreachable) -- not the source of truth, just keeps
|
|
16
|
+
// `dalus login` from being completely dead during a transient outage of
|
|
17
|
+
// that one lookup. --web-base always wins over both.
|
|
18
|
+
const FALLBACK_DALUS_BASE = 'https://8bnk.dalus.cobinar.com';
|
|
19
|
+
// cobinar-developers-worker — mints the actual bearer token dashboard-worker
|
|
20
|
+
// checks (a signed "workerToken"), from the one-time code dalus's own
|
|
21
|
+
// worker hands us. Not the same thing as dalus.cobinar.com itself or as
|
|
22
|
+
// auth.cobinar.com (Cobinar's general sign-in provider, which dalus's
|
|
23
|
+
// worker is one client of) — those are three separate services. See the
|
|
24
|
+
// comment on exchangeSsoCode below for the exact handoff.
|
|
25
|
+
const DEFAULT_DEVELOPERS_BASE = 'https://worker.dashboard.cobinar.com';
|
|
26
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
function ask(question) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
31
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function openBrowser(url) {
|
|
36
|
+
// execFile, not exec — no shell involved for the process WE spawn. That
|
|
37
|
+
// alone isn't quite enough on Windows: `cmd /c start` hands its arguments
|
|
38
|
+
// to cmd.exe's OWN re-parsing of the whole line as a new command, where an
|
|
39
|
+
// unescaped "&" means "run two commands", not "a literal character in a
|
|
40
|
+
// URL" — which is exactly what truncated a real sign-in URL at its first
|
|
41
|
+
// "&" here before. rundll32 is a normal Win32 program, not a shell, so it
|
|
42
|
+
// never re-interprets "&" (or anything else) in its argument at all.
|
|
43
|
+
const done = () => {}; // best-effort; the URL is always printed too
|
|
44
|
+
if (process.platform === 'darwin') execFile('open', [url], done);
|
|
45
|
+
else if (process.platform === 'win32') execFile('rundll32', ['url.dll,FileProtocolHandler', url], done);
|
|
46
|
+
else execFile('xdg-open', [url], done);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const REQUEST_TIMEOUT_MS = 8000;
|
|
50
|
+
|
|
51
|
+
function getJson(urlString) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const url = new URL(urlString);
|
|
54
|
+
const req = require(url.protocol === 'http:' ? 'http' : 'https')
|
|
55
|
+
.get(url, (res) => {
|
|
56
|
+
let out = '';
|
|
57
|
+
res.on('data', (c) => (out += c));
|
|
58
|
+
res.on('end', () => {
|
|
59
|
+
let parsed = null;
|
|
60
|
+
try { parsed = JSON.parse(out); } catch { /* leaves parsed null below */ }
|
|
61
|
+
resolve({ status: res.statusCode, json: parsed });
|
|
62
|
+
});
|
|
63
|
+
})
|
|
64
|
+
.on('error', reject);
|
|
65
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out')));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The one lookup this whole file depends on: where does dalus's own worker
|
|
70
|
+
// (the login page browserLogin below sends people to) currently live. See
|
|
71
|
+
// SERVICES_ENDPOINT's comment above for why this is a runtime fetch and not
|
|
72
|
+
// a constant.
|
|
73
|
+
async function discoverDalusBase() {
|
|
74
|
+
try {
|
|
75
|
+
const { status, json } = await getJson(SERVICES_ENDPOINT);
|
|
76
|
+
if (status === 200 && json && typeof json.dalus === 'string' && json.dalus) {
|
|
77
|
+
return json.dalus;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Falls through to FALLBACK_DALUS_BASE below.
|
|
81
|
+
}
|
|
82
|
+
return FALLBACK_DALUS_BASE;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function postJson(urlString, body) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const url = new URL(urlString);
|
|
88
|
+
const data = JSON.stringify(body);
|
|
89
|
+
const req = require(url.protocol === 'http:' ? 'http' : 'https').request(
|
|
90
|
+
{
|
|
91
|
+
hostname: url.hostname,
|
|
92
|
+
port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
93
|
+
path: url.pathname + url.search,
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
96
|
+
},
|
|
97
|
+
(res) => {
|
|
98
|
+
let out = '';
|
|
99
|
+
res.on('data', (c) => (out += c));
|
|
100
|
+
res.on('end', () => {
|
|
101
|
+
let parsed = null;
|
|
102
|
+
try { parsed = JSON.parse(out); } catch { /* leaves parsed null below */ }
|
|
103
|
+
resolve({ status: res.statusCode, json: parsed });
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
req.on('error', reject);
|
|
108
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out')));
|
|
109
|
+
req.write(data);
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The other half of cobinar-developers-worker's POST /auth/redeem-code:
|
|
115
|
+
// cobinar.com's callback page writes { uid, email, name, picture } to a KV
|
|
116
|
+
// namespace it shares with cobinar-developers-worker, keyed by a one-time
|
|
117
|
+
// code, and hands US that code (over the loopback server below, once the
|
|
118
|
+
// browser gets there). Redeeming it here — not on cobinar.com's server, and
|
|
119
|
+
// not in the browser — is what actually mints the workerToken dashboard-
|
|
120
|
+
// worker will accept; nothing before this point has needed dashboard-worker
|
|
121
|
+
// to trust anything, since a workerToken is the first credential in this
|
|
122
|
+
// whole flow it actually checks.
|
|
123
|
+
async function exchangeSsoCode(developersBase, code) {
|
|
124
|
+
const { status, json } = await postJson(`${developersBase}/auth/redeem-code`, { code });
|
|
125
|
+
if (status !== 200 || !json || typeof json.workerToken !== 'string') {
|
|
126
|
+
const detail = json && json.error ? json.error : `HTTP ${status}`;
|
|
127
|
+
throw new Error(`Could not finish signing in (${detail}). Run "dalus login" again.`);
|
|
128
|
+
}
|
|
129
|
+
return json;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Starts a one-shot local server, opens dalus's own worker (wherever
|
|
133
|
+
// discoverDalusBase() says it currently lives) to sign in, and waits for
|
|
134
|
+
// its /login/callback page to POST a one-time SSO code back here once the
|
|
135
|
+
// auth.cobinar.com round trip finishes. dalus's worker has its own
|
|
136
|
+
// client_id/secret, registered independently of cobinar.com's — see that
|
|
137
|
+
// worker's public/login/index.html and public/login/callback.html for the
|
|
138
|
+
// other half of this handshake, and exchangeSsoCode above for what happens
|
|
139
|
+
// to the code once it arrives here.
|
|
140
|
+
function browserLogin(webBase, developersBase) {
|
|
141
|
+
// "ls" (local state): proves the browser tab that finishes is the one
|
|
142
|
+
// THIS process opened, not some other local process guessing the port
|
|
143
|
+
// and racing it — the port alone isn't a secret (any local process can
|
|
144
|
+
// see what's listening), this is.
|
|
145
|
+
const ls = crypto.randomBytes(24).toString('hex');
|
|
146
|
+
const allowedOrigin = new URL(webBase).origin;
|
|
147
|
+
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
let settled = false;
|
|
150
|
+
|
|
151
|
+
const server = http.createServer((req, res) => {
|
|
152
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
153
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
154
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
155
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
156
|
+
|
|
157
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
158
|
+
if (url.pathname !== '/complete' || req.method !== 'POST') { res.writeHead(404); res.end(); return; }
|
|
159
|
+
|
|
160
|
+
let body = '';
|
|
161
|
+
req.on('data', (chunk) => {
|
|
162
|
+
body += chunk;
|
|
163
|
+
if (body.length > 1e6) req.destroy(); // this payload is a short code + a small profile, never anywhere near this size
|
|
164
|
+
});
|
|
165
|
+
req.on('end', async () => {
|
|
166
|
+
let parsed = null;
|
|
167
|
+
try { parsed = JSON.parse(body); } catch { /* falls through to the ok:false below */ }
|
|
168
|
+
|
|
169
|
+
if (!parsed || parsed.ls !== ls || typeof parsed.code !== 'string' || !parsed.code) {
|
|
170
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
171
|
+
res.end(JSON.stringify({ ok: false }));
|
|
172
|
+
return; // deliberately not finish()/reject() here — a stray or malicious request to this port shouldn't cancel a login still in progress
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const redeemed = await exchangeSsoCode(developersBase, parsed.code);
|
|
177
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
178
|
+
res.end(JSON.stringify({ ok: true }));
|
|
179
|
+
finish(null, {
|
|
180
|
+
token: redeemed.workerToken,
|
|
181
|
+
user: parsed.user || { email: redeemed.cobinarEmail, name: redeemed.displayName, picture: redeemed.photoURL },
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
186
|
+
finish(err);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
function finish(err, result) {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
server.close();
|
|
196
|
+
if (err) reject(err); else resolve(result);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const timer = setTimeout(() => {
|
|
200
|
+
finish(new Error(`Timed out waiting for sign-in (${LOGIN_TIMEOUT_MS / 1000}s). Run "dalus login" again.`));
|
|
201
|
+
}, LOGIN_TIMEOUT_MS);
|
|
202
|
+
|
|
203
|
+
server.on('error', (err) => finish(err));
|
|
204
|
+
|
|
205
|
+
server.listen(0, '127.0.0.1', () => {
|
|
206
|
+
const port = server.address().port;
|
|
207
|
+
// port and ls travel together as one opaque value, not two params
|
|
208
|
+
// joined by "&" — see openBrowser's comment above for exactly why
|
|
209
|
+
// that character caused real trouble here. Neither value can ever
|
|
210
|
+
// contain a "." (port is digits, ls is hex), so joining/splitting on
|
|
211
|
+
// it is unambiguous. The trailing slash on /login/ is deliberate
|
|
212
|
+
// too: requesting the bare path and hoping a redirect adds the slash
|
|
213
|
+
// back is one more hop that could, in principle, drop the query
|
|
214
|
+
// string — this just starts at the real address.
|
|
215
|
+
const loginUrl = `${webBase}/login/?d=${port}.${ls}`;
|
|
216
|
+
console.log('Opening your browser to sign in with Cobinar...');
|
|
217
|
+
console.log(`If it doesn't open automatically, visit:\n ${loginUrl}\n`);
|
|
218
|
+
openBrowser(loginUrl);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function loginCommand(args) {
|
|
224
|
+
if (args.includes('--logout')) {
|
|
225
|
+
clearCredentials();
|
|
226
|
+
console.log('Logged out.');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (args.includes('--whoami')) {
|
|
230
|
+
const creds = loadCredentials();
|
|
231
|
+
if (!creds) { console.log('Not logged in.'); return; }
|
|
232
|
+
console.log(`API base: ${creds.apiBase}`);
|
|
233
|
+
if (creds.user && creds.user.email) console.log(`Signed in as: ${creds.user.email}`);
|
|
234
|
+
console.log(`Token: ${creds.token.slice(0, 8)}...${creds.token.slice(-4)} (stored in ${CREDENTIALS_PATH})`);
|
|
235
|
+
console.log('Note: this token expires 2 hours after signing in — "dalus login" again once it does.');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tokenArgIdx = args.indexOf('--token');
|
|
240
|
+
const apiBaseArgIdx = args.indexOf('--api-base');
|
|
241
|
+
|
|
242
|
+
// --token stays as a manual escape hatch for CI/headless boxes that
|
|
243
|
+
// can't open a browser at all — not the default anymore, but not worth
|
|
244
|
+
// removing until there's a real headless flow (one-time code / email,
|
|
245
|
+
// planned later) to replace it with.
|
|
246
|
+
if (tokenArgIdx !== -1) {
|
|
247
|
+
const token = args[tokenArgIdx + 1];
|
|
248
|
+
const apiBase = apiBaseArgIdx !== -1 ? args[apiBaseArgIdx + 1] : await ask('Cobinar dashboard-worker URL: ');
|
|
249
|
+
if (!token || !apiBase) {
|
|
250
|
+
console.error('Both a worker URL and a token are required.');
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token });
|
|
255
|
+
console.log(`Saved to ${CREDENTIALS_PATH}. Run "dalus forge" from a project directory to deploy.`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const webBaseArgIdx = args.indexOf('--web-base');
|
|
260
|
+
const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] : await discoverDalusBase()).replace(/\/$/, '');
|
|
261
|
+
const developersBaseArgIdx = args.indexOf('--developers-base');
|
|
262
|
+
const developersBase = (developersBaseArgIdx !== -1 ? args[developersBaseArgIdx + 1] : DEFAULT_DEVELOPERS_BASE).replace(/\/$/, '');
|
|
263
|
+
|
|
264
|
+
let result;
|
|
265
|
+
try {
|
|
266
|
+
result = await browserLogin(webBase, developersBase);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error(err.message);
|
|
269
|
+
process.exitCode = 1;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const apiBase = apiBaseArgIdx !== -1
|
|
274
|
+
? args[apiBaseArgIdx + 1]
|
|
275
|
+
: await ask('Cobinar dashboard-worker URL (e.g. https://worker.lobby.cobinar.com): ');
|
|
276
|
+
if (!apiBase) {
|
|
277
|
+
console.error('A dashboard-worker URL is required.');
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token: result.token, user: result.user || null });
|
|
283
|
+
console.log(`Signed in${result.user && result.user.email ? ' as ' + result.user.email : ''}. Saved to ${CREDENTIALS_PATH}.`);
|
|
284
|
+
console.log('This session lasts 2 hours — "dalus login" again once it expires.');
|
|
285
|
+
console.log('Run "dalus forge" from a project directory to deploy.');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { loginCommand, browserLogin, exchangeSsoCode, discoverDalusBase };
|