@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cobinar/dalus",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Deploy Cobinar projects (Edge Compute, Static Hosting, Object Storage, Document DB, Vault) from the command line.",
5
5
  "bin": {
6
6
  "dalus": "./bin/dalus.js"
@@ -0,0 +1,32 @@
1
+ @echo off
2
+ echo 🚀 Preparing to publish new @cobinar/dalus version...
3
+
4
+ :: Navigate to your exact project directory
5
+ cd /d "D:\Downloads\Cobinar-Lobby + Dashboard + Da;us CLI\dalus"
6
+
7
+ echo.
8
+ echo 🔐 Checking npm authentication...
9
+ call npm whoami >nul 2>&1
10
+ if %ERRORLEVEL% neq 0 (
11
+ echo ⚠️ Your npm session expired.
12
+ echo Please log in to your npm account - press ENTER to open browser.
13
+ call npm login
14
+ ) else (
15
+ echo ✅ Authenticated successfully.
16
+ )
17
+
18
+ echo.
19
+ echo 📦 Bumping version...
20
+ call npm version patch
21
+
22
+ echo.
23
+ echo ☁️ Publishing to npm - waiting for auth token if 2FA is required...
24
+ call npm publish --access public
25
+
26
+ echo.
27
+ echo 🔄 Downloading and forcing install of the latest version...
28
+ call npm install -g @cobinar/dalus@latest --force
29
+
30
+ echo.
31
+ echo ✅ Deployment complete!
32
+ pause
package/src/auth.js ADDED
@@ -0,0 +1,78 @@
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
+ }
@@ -0,0 +1,78 @@
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
+ }
@@ -0,0 +1,34 @@
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
+ }
@@ -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
+ //
@@ -5,13 +5,23 @@ const readline = require('readline');
5
5
  const { execFile } = require('child_process');
6
6
  const { saveCredentials, clearCredentials, loadCredentials, CREDENTIALS_PATH } = require('../config');
7
7
 
8
- const DEFAULT_WEB_BASE = 'https://cobinar.com';
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://8bnk.dalus.cobinar.com';
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';
9
19
  // cobinar-developers-worker — mints the actual bearer token dashboard-worker
10
- // checks (a signed "workerToken"), from the one-time code cobinar.com's
11
- // callback hands us. Not the same thing as cobinar.com itself or as
12
- // auth.cobinar.com (Cobinar's general sign-in provider, which cobinar.com
13
- // is a client of) — those are three separate services. See the comment on
14
- // exchangeSsoCode below for the exact handoff.
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.
15
25
  const DEFAULT_DEVELOPERS_BASE = 'https://worker.dashboard.cobinar.com';
16
26
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
17
27
 
@@ -23,14 +33,55 @@ function ask(question) {
23
33
  }
24
34
 
25
35
  function openBrowser(url) {
26
- // execFile, not exec — no shell involved, so the URL can't be
27
- // misinterpreted as shell syntax no matter what's in it.
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.
28
43
  const done = () => {}; // best-effort; the URL is always printed too
29
44
  if (process.platform === 'darwin') execFile('open', [url], done);
30
- else if (process.platform === 'win32') execFile('cmd', ['/c', 'start', '""', url], done);
45
+ else if (process.platform === 'win32') execFile('rundll32', ['url.dll,FileProtocolHandler', url], done);
31
46
  else execFile('xdg-open', [url], done);
32
47
  }
33
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
+
34
85
  function postJson(urlString, body) {
35
86
  return new Promise((resolve, reject) => {
36
87
  const url = new URL(urlString);
@@ -54,6 +105,7 @@ function postJson(urlString, body) {
54
105
  },
55
106
  );
56
107
  req.on('error', reject);
108
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out')));
57
109
  req.write(data);
58
110
  req.end();
59
111
  });
@@ -77,15 +129,14 @@ async function exchangeSsoCode(developersBase, code) {
77
129
  return json;
78
130
  }
79
131
 
80
- // Starts a one-shot local server, opens the browser to Cobinar's own web
81
- // login (cobinar-main's public/cli-login/ page), and waits for that page to
82
- // POST a one-time SSO code back here once the normal auth.cobinar.com round
83
- // trip finishes. dalus never talks to auth.cobinar.com directly and never
84
- // gets its own client_id/secret it rides along on cobinar.com's existing
85
- // one, same as any other visitor signing in there. See cobinar-main's
86
- // public/cli-login/index.html and public/auth/callback.html for the other
87
- // half of this handshake, and exchangeSsoCode above for what happens to the
88
- // code once it arrives.
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.
89
140
  function browserLogin(webBase, developersBase) {
90
141
  // "ls" (local state): proves the browser tab that finishes is the one
91
142
  // THIS process opened, not some other local process guessing the port
@@ -153,7 +204,15 @@ function browserLogin(webBase, developersBase) {
153
204
 
154
205
  server.listen(0, '127.0.0.1', () => {
155
206
  const port = server.address().port;
156
- const loginUrl = `${webBase}/cli-login?port=${port}&ls=${ls}`;
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}`;
157
216
  console.log('Opening your browser to sign in with Cobinar...');
158
217
  console.log(`If it doesn't open automatically, visit:\n ${loginUrl}\n`);
159
218
  openBrowser(loginUrl);
@@ -198,7 +257,7 @@ async function loginCommand(args) {
198
257
  }
199
258
 
200
259
  const webBaseArgIdx = args.indexOf('--web-base');
201
- const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] : DEFAULT_WEB_BASE).replace(/\/$/, '');
260
+ const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] : await discoverDalusBase()).replace(/\/$/, '');
202
261
  const developersBaseArgIdx = args.indexOf('--developers-base');
203
262
  const developersBase = (developersBaseArgIdx !== -1 ? args[developersBaseArgIdx + 1] : DEFAULT_DEVELOPERS_BASE).replace(/\/$/, '');
204
263
 
@@ -226,4 +285,4 @@ async function loginCommand(args) {
226
285
  console.log('Run "dalus forge" from a project directory to deploy.');
227
286
  }
228
287
 
229
- module.exports = { loginCommand, browserLogin, exchangeSsoCode };
288
+ module.exports = { loginCommand, browserLogin, exchangeSsoCode, discoverDalusBase };
@@ -0,0 +1,72 @@
1
+ // src/storage.js
2
+ import { generateId } from './utils.js';
3
+
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 };
8
+
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()}`;
13
+ }
14
+ const prefix = slot ? `${slot}-${uid}` : uid;
15
+ return `cobinar/${collection}/${entityId}/${prefix}.${ext.toLowerCase()}`;
16
+ }
17
+
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
+ }
31
+
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
+ });
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
+ }
55
+
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}`;
62
+ }
63
+
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,7 @@
1
+ // src/utils.js
2
+
3
+ export function generateId() {
4
+ const buf = new Uint8Array(8);
5
+ crypto.getRandomValues(buf);
6
+ return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 12);
7
+ }