Skip to content
Open
31 changes: 12 additions & 19 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,34 +94,29 @@ function makeAuthRouter({ authService, storageService }) {
return res.status(500).json({ error: 'Failed to list storages' });
}
});

router.get('/storage/name-availability', requireAuth, async (req, res) => {
router.get('/storage/discover', requireAuth, async (req, res) => {
try {
const provider = req.query.provider || 'github';
const name = req.query.name;
if (provider !== 'github') {
return res.status(400).json({
error: 'Only provider=github is supported for name availability',
error: 'Only provider=github is supported in Phase 1',
});
}
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'name is required' });
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}

const result = await storageService.checkGitHubRepoNameAvailability(
name,
clients,
);
return res.json({ provider: 'github', ...result });
const result = await storageService.discoverAccountStores(provider, clients, {
sessionStorageRef: req.user?.storage || null,
});
return res.json(result);
} catch (err) {
console.error(err);
return res.status(500).json({
error: err.message || 'Failed to check repository name availability',
error: err.message || 'Failed to discover storage',
});
}
});
Expand All @@ -134,19 +129,17 @@ function makeAuthRouter({ authService, storageService }) {
error: 'Only provider=github is supported for repository creation',
});
}
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'name is required' });
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}

const installUrl = await authService.getInstallationSetupUrl();
const result = await storageService.createGitHubRepository(name, clients, {
installUrl,
});
const result =
typeof name === 'string' && name.trim()
? await storageService.createGitHubRepository(name.trim(), clients, { installUrl })
: await storageService.createNextVizablyGitHubRepository(clients, { installUrl });
return res.status(201).json({
provider: 'github',
storageRef: result.storageRef,
Expand Down
167 changes: 164 additions & 3 deletions backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
*/
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const { normalizeGitHubRepoName } = require('../../shared/githubRepoName');
const {
applyVizablyRepoPrefix,
nextVizablyStoreName,
normalizeGitHubRepoName,
VIZABLY_DEFAULT_STORE_NAME,
} = require('../../shared/githubRepoName');
const { collectAllGitHubPages, findInGitHubPages } = require('./githubPagination');

const MANIFEST_PATH = 'vizably.json';
Expand Down Expand Up @@ -160,7 +165,7 @@ class StorageService {
* Create a private empty GitHub repo for the signed-in user (App UAT).
* Does not initialize a Vizably store — caller runs fit-check then init.
*
* @param {string} name repository name (not owner/name)
* @param {string} name repository name (not owner/name); stored as `viz_<name>`
* @param {StorageClients} clients must include githubUserClient (or githubClient as UAT)
* @param {object} [options]
* @param {string} [options.installUrl] App install URL when needsInstall
Expand Down Expand Up @@ -215,6 +220,162 @@ class StorageService {
return { storageRef, needsInstall, installUrl };
}

/**
* Create the next unused default store: `viz_scans`, then `viz_scans-2`, …
*
* @param {StorageClients} clients
* @param {object} [options]
* @param {string} [options.installUrl]
*/
async createNextVizablyGitHubRepository(clients, options = {}) {
const taken = [];
for (let n = 1; n <= 50; n += 1) {
const name = nextVizablyStoreName(taken);
try {
return await this.createGitHubRepository(name, clients, options);
} catch (err) {
if (err.code === 'REPO_NAME_TAKEN') {
taken.push(name);
continue;
}
throw err;
}
}
const err = new Error('Could not find an available Vizably repository name');
err.status = 422;
err.code = 'REPO_NAME_TAKEN';
throw err;
}

/**
* Find existing Vizably account stores (manifest-based, provider-neutral).
* Order: session storageRef → GET expected name (`viz_scans`) → list repos.
*
* @param {'github' | 'google'} provider
* @param {StorageClients} clients
* @param {object} [options]
* @param {object} [options.sessionStorageRef]
* @returns {Promise<{
* provider: string,
* stores: Array<{ storageRef: object, validation: object }>,
* source: 'session' | 'expected-name' | 'list' | null,
* }>}
*/
async discoverAccountStores(provider, clients, options = {}) {
if (provider === 'google') {
return { provider, stores: [], source: null };
}
if (provider !== 'github') {
throw new Error('Unsupported storage provider');
}
return this._discoverGitHubAccountStores(clients, options);
}

/**
* @param {StorageClients} clients
* @param {{ sessionStorageRef?: object }} [options]
* @private
*/
async _discoverGitHubAccountStores(clients, { sessionStorageRef } = {}) {
const octokit = clients.githubUserClient ?? clients.githubClient;
if (!octokit) {
throw new Error('GitHub client is required to discover storage');
}

const consider = async (storageRef) => {
const validation = await this.validateStorage('github', storageRef, clients);
if (!this._isDiscoveredAccountStore(validation)) {
return null;
}
return {
storageRef: {
id: storageRef.id,
full_name: storageRef.full_name,
html_url: storageRef.html_url,
name: storageRef.name || storageRef.full_name?.split('/')[1],
},
validation,
};
};

if (sessionStorageRef?.full_name || sessionStorageRef?.id) {
try {
const hit = await consider(sessionStorageRef);
if (hit) {
return { provider: 'github', stores: [hit], source: 'session' };
}
} catch {
// Stale session ref — fall through to name GET / listing.
}
}

let owner;
try {
const { data: user } = await octokit.rest.users.getAuthenticated();
owner = user.login;
} catch (err) {
throw new Error(
err?.status === 401
? 'GitHub authentication failed. Sign out and sign in again.'
: 'Could not look up your GitHub username.',
);
}

try {
const { data: repo } = await octokit.rest.repos.get({
owner,
repo: VIZABLY_DEFAULT_STORE_NAME,
});
const hit = await consider({
id: repo.node_id,
full_name: repo.full_name,
html_url: repo.html_url,
name: repo.name,
});
if (hit) {
return { provider: 'github', stores: [hit], source: 'expected-name' };
}
} catch (err) {
if (err?.status === 429 || (err?.status === 403 && /rate limit/i.test(String(err.message)))) {
throw err;
}
}

const repos = await this.listGitHubRepos(octokit);
const stores = [];
for (const repo of repos) {
try {
const hit = await consider({
id: repo.id,
full_name: repo.full_name,
html_url: repo.html_url,
});
if (hit) {
stores.push(hit);
}
} catch {
// Skip repos we cannot inspect.
}
}
return { provider: 'github', stores, source: 'list' };
}

/**
* A discovered account store is identified by vizably.json (or legacy
* equalview.json), never by repository name.
* @param {{ status?: string, reason?: string | null }} validation
* @private
*/
_isDiscoveredAccountStore(validation) {
if (!validation) {
return false;
}
if (validation.status === 'loadable' || validation.status === 'incompatible') {
return true;
}
return validation.status === 'invalid' && validation.reason === 'malformed_manifest';
}

/**
* True when a Vizably App installation with Contents write includes this repo.
* Does not fall back to the user's personal push bit — create needs the App.
Expand Down Expand Up @@ -390,7 +551,7 @@ class StorageService {
throw err;
}

const normalized = normalizeGitHubRepoName(name);
const normalized = applyVizablyRepoPrefix(name);
if (!normalized) {
const err = new Error('Repository name is required');
err.status = 400;
Expand Down
99 changes: 53 additions & 46 deletions backend/tests/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,45 +318,6 @@ test('GET /api/auth/storages returns mapped GitHub repos', async () => {
assert.equal(res.body.storages[0].id, 'R_kg');
});

test('GET /api/auth/storage/name-availability returns availability result', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: { mock: true } }),
},
storageService: {
checkGitHubRepoNameAvailability: async (name) => ({
name,
normalizedName: name,
full_name: `sam/${name}`,
status: 'available',
message: `sam/${name} is available.`,
}),
},
});
const res = await request(app).get(
'/api/auth/storage/name-availability?provider=github&name=fresh-repo',
);
assert.equal(res.status, 200);
assert.equal(res.body.status, 'available');
assert.equal(res.body.full_name, 'sam/fresh-repo');
});

test('GET /api/auth/storage/name-availability requires name', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: {} }),
},
storageService: {},
});
const res = await request(app).get(
'/api/auth/storage/name-availability?provider=github',
);
assert.equal(res.status, 400);
assert.match(res.body.error, /name is required/);
});

test('POST /api/auth/storage/create returns storageRef and needsInstall', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
Expand Down Expand Up @@ -388,18 +349,64 @@ test('POST /api/auth/storage/create returns storageRef and needsInstall', async
assert.match(res.body.installUrl, /installations\/new/);
});

test('POST /api/auth/storage/create requires name', async () => {
test('GET /api/auth/storage/discover returns discovered stores', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: {} }),
getInstallationSetupUrl: async () => 'https://github.com/settings/installations',
clientsFor: async () => ({ githubUserClient: { mock: true } }),
},
storageService: {
discoverAccountStores: async (provider, _clients, options) => {
assert.equal(provider, 'github');
assert.equal(options.sessionStorageRef, AUTHED_USER.storage);
return {
provider: 'github',
stores: [
{
storageRef: {
id: 'R_kg',
full_name: 'sam/viz_scans',
html_url: 'https://github.com/sam/viz_scans',
},
validation: { status: 'loadable' },
},
],
source: 'expected-name',
};
},
},
storageService: {},
});
const res = await request(app).post('/api/auth/storage/create').send({});
assert.equal(res.status, 400);
assert.match(res.body.error, /name is required/);
const res = await request(app).get('/api/auth/storage/discover?provider=github');
assert.equal(res.status, 200);
assert.equal(res.body.source, 'expected-name');
assert.equal(res.body.stores[0].storageRef.full_name, 'sam/viz_scans');
});

test('POST /api/auth/storage/create uses the default store when name is omitted', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: { mock: true } }),
getInstallationSetupUrl: async () =>
'https://github.com/apps/vizably/installations/new',
},
storageService: {
createNextVizablyGitHubRepository: async (_clients, options) => ({
storageRef: {
id: 'R_kgNew',
name: 'viz_scans',
full_name: 'sam/viz_scans',
private: true,
html_url: 'https://github.com/sam/viz_scans',
},
needsInstall: false,
installUrl: options.installUrl,
}),
},
});
const res = await request(app).post('/api/auth/storage/create').send({ provider: 'github' });
assert.equal(res.status, 201);
assert.equal(res.body.storageRef.name, 'viz_scans');
});

test('POST /api/auth/storage/create returns probe failures without needsInstall', async () => {
Expand Down
Loading