Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ npm start --prefix backend

The runtime uses Node.js 24+, Firebase Admin application-default credentials and the existing Google Cloud project. Production has no process-memory storage fallback. The test suite injects an isolated memory store and fake identity verifier, exercises the real HTTP/OAuth/MCP handlers and never contacts a third-party tool or sends email.

Production configuration: `PUBLIC_ORIGIN`, `GOOGLE_CLOUD_PROJECT`, `FIREBASE_API_KEY`, `ADMIN_EMAILS`, optional `PORT`. The Firebase web API key is a public project identifier, not a server credential. Do not commit local environment files, service-account keys, browser cookies, OAuth tokens or deployment receipts. Cloud Run uses its runtime service account instead of a downloaded key.
Production configuration: `PUBLIC_ORIGIN`, `GOOGLE_CLOUD_PROJECT`, `FIREBASE_API_KEY`, `ADMIN_EMAILS`, optional `PORT` and `SIGN_IN_PROVIDERS` (comma-separated `google.com` and/or `github.com` only after enabling the matching Firebase provider). The Firebase web API key is a public project identifier, not a server credential. Do not commit local environment files, service-account keys, browser cookies, OAuth tokens or deployment receipts. Cloud Run uses its runtime service account instead of a downloaded key.

## Accounts and profiles

Expand Down Expand Up @@ -50,3 +50,5 @@ User-supplied strings are escaped in HTML and JSON-LD. Install text is displayed
## Deployment

Use `python scripts/stage_cloudrun.py` to create a clean allowlisted context containing only runtime source, locked package manifests, layout and a fresh public build. Tests, local credentials, private receipts, plans and stale build files are excluded. The container runs as the unprivileged Node user. Preserve min instances 0, max instances 2 and request-based CPU billing when deploying. Firebase Hosting keeps custom-domain HTTPS and routes to Cloud Run. New public data is read from Firestore rather than requiring a source deployment for each approved listing.

Social sign-in uses the official pinned Firebase browser SDK from Google's CDN, in-memory persistence and a popup. Only configured provider IDs appear in the public config; client secrets stay in Firebase. Provider ID tokens are exchanged for the existing secure server cookie, then the SDK signs out. No provider API access is requested beyond basic identity and, for GitHub, email. The safe return path preserves a pending MCP consent request. Provider collisions require the existing sign-in method and never silently merge profiles.
2 changes: 1 addition & 1 deletion backend/render.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions backend/server.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions backend/test/platform.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,14 @@ test('HTTP: bounded request rates apply before account authorization or file acc
const limited=await call('/api/me',{user:'none'});assert.equal(limited.status,429);
assert.ok(limited.headers.get('ratelimit'));
});

test('HTTP: only configured social providers are public and private auth returns stay off search engines',async t=>{
const {call}=await httpFixture(t,{signInProviders:'google.com,unconfigured.example,github.com'});
const config=await call('/api/config');assert.equal(config.headers.get('cache-control'),'no-store');
const data=await config.json();assert.deepEqual(data.signInProviders,['google.com','github.com']);assert.ok(!data.clientSecret);
const page=await call('/account/sign-in/?next=%2Fauthorize%2Fconsent%2F%3Frequest%3Dexample');
assert.equal(page.headers.get('cache-control'),'private, no-store');
assert.equal(page.headers.get('x-robots-tag'),'noindex, nofollow');
assert.ok((await page.text()).includes('id="social-sign-in"'));
assert.ok(page.headers.get('content-security-policy').includes('https://www.gstatic.com/firebasejs/13.1.0/'));
});
21 changes: 21 additions & 0 deletions site/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,28 @@
async function attempt(fn){try{await fn();}catch(e){if(e.status===401&&mode!=='auth'&&mode!=='email-action'){signIn();return;}say(e.message,true);}}

if(mode==='auth'){
let socialBusy=false;
api('/api/config').then(async config=>{
if(!config.signInProviders?.length)return;
const social=await import('/social-auth.js'), connect=social.prepareSocialSignIn(config);
const box=document.getElementById('social-sign-in');
for(const [providerId,label]of [['google.com','Google'],['github.com','GitHub']]){
if(!config.signInProviders.includes(providerId))continue;
const button=el('button','Continue with '+label,'button');button.type='button';
button.onclick=async()=>{
if(socialBusy)return;socialBusy=true;say('Opening '+label+' sign-in...');
try{
await connect(providerId,async idToken=>{await api('/api/account/session','POST',{idToken});});
location.assign(safeNext(new URLSearchParams(location.search).get('next')));
}catch(error){say(error.status?error.message:social.socialError(error),true);}
finally{socialBusy=false;}
};
box.append(button);
}
box.hidden=false;
}).catch(()=>say('Social sign-in could not load. You can still sign in with email.',true));
document.getElementById('auth-form').addEventListener('submit',e=>{e.preventDefault();attempt(async()=>{
if(socialBusy)return;
const form=e.currentTarget, data=fields(form), action=e.submitter?.value||'signin';
if(action==='reset'){if(!data.email||!form.elements.email.validity.valid)throw new Error('Enter your email address first.');await firebase('sendOobCode',{requestType:'PASSWORD_RESET',email:data.email});say('If an account uses this address, a password reset email will arrive.');return;}
say(action==='signup'?'Creating your account...':'Signing in...');
Expand Down
26 changes: 26 additions & 0 deletions site/social-auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { initializeApp } from 'https://www.gstatic.com/firebasejs/13.1.0/firebase-app.js';
import { initializeAuth, inMemoryPersistence, browserPopupRedirectResolver, GoogleAuthProvider, GithubAuthProvider, signInWithPopup, signOut } from 'https://www.gstatic.com/firebasejs/13.1.0/firebase-auth.js';

// Provider tokens stay in memory. The server session remains the HttpOnly cookie.
export function prepareSocialSignIn(config) {
const auth = initializeAuth(initializeApp({apiKey:config.apiKey,authDomain:config.authDomain,projectId:config.projectId},'toolindex-social'), {
persistence:inMemoryPersistence,popupRedirectResolver:browserPopupRedirectResolver
});
return async (providerId, consumeToken) => {
if (!config.signInProviders.includes(providerId)) throw new Error('This sign-in method is unavailable.');
const provider = providerId==='google.com' ? new GoogleAuthProvider() : new GithubAuthProvider();
if (providerId==='github.com') provider.addScope('user:email');
const result = await signInWithPopup(auth,provider);
try { await consumeToken(await result.user.getIdToken()); }
finally { await signOut(auth); }
};
}

export function socialError(error) {
const code=error.code || '';
if (code==='auth/popup-closed-by-user' || code==='auth/cancelled-popup-request') return 'Sign-in cancelled. You can try again.';
if (code==='auth/popup-blocked') return 'Allow the sign-in popup, then choose your provider again.';
if (code==='auth/account-exists-with-different-credential') return 'This email already uses another sign-in method. Use that method to keep your existing account.';
if (code==='auth/unauthorized-domain' || code==='auth/operation-not-allowed' || code==='auth/invalid-oauth-client-id') return 'This sign-in provider is being configured. Please use email sign-in for now.';
return 'Could not complete provider sign-in. Please try again or use email sign-in.';
}
2 changes: 2 additions & 0 deletions site/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,5 @@ button:disabled, input:disabled { cursor: default; }
input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:3px solid #e88546; outline-offset:3px; }
@media(max-width:800px) { .workspace-grid { grid-template-columns:1fr; gap:36px; } .site-header nav { flex-wrap:wrap; gap:12px; } .header { align-items:flex-start; } .account-top { align-items:flex-start; } }
@media(max-width:440px) { .field-row { grid-template-columns:1fr; } .site-header .github { display:none; } }

.social-sign-in:not([hidden]) { display: grid; gap: 10px; margin: 24px 0; }
Loading