class AybClient {
constructor(options = {}) {
if (!options.appId) throw new Error('appId is required');
this.appId = options.appId;
this.storageKey = options.storageKey || `ayb_${this.appId}`;
this._config = null;
}
loadConfig() {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
this._config = JSON.parse(saved);
return true;
}
return false;
}
saveConfig(url, token) {
const parsed = AybClient.parseDatabaseUrl(url);
this._config = { ...parsed, token };
localStorage.setItem(this.storageKey, JSON.stringify(this._config));
}
disconnect() {
this._config = null;
localStorage.removeItem(this.storageKey);
}
isConnected() {
return !!this._config;
}
getConnectionInfo() {
if (!this._config) return null;
return {
baseUrl: this._config.baseUrl,
entity: this._config.entity,
database: this._config.database,
databaseUrl: `${this._config.baseUrl}/v1/${this._config.entity}/${this._config.database}`
};
}
async query(sql, maxRetries = 0) {
if (!this._config) {
throw new Error('Not connected. Call saveConfig() or loadConfig() first.');
}
const { baseUrl, entity, database, token } = this._config;
const url = `${baseUrl}/v1/${entity}/${database}/query`;
const response = await this._fetchWithRetry(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'text/plain'
},
body: sql
}, maxRetries);
if (!response.ok) {
const text = await response.text();
throw new Error(`Query failed: ${text}`);
}
return response.json();
}
async queryObjects(sql) {
const result = await this.query(sql);
if (!result.fields || !result.rows) return [];
return result.rows.map(row => {
const obj = {};
result.fields.forEach((field, i) => {
obj[field] = row[i];
});
return obj;
});
}
async _fetchWithRetry(url, options, maxRetries = 0) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fetch(url, options);
} catch (e) {
lastError = e;
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt + 1) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
}
static escapeSQL(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/'/g, "''");
}
static parseDatabaseUrl(url) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(p => p);
if (pathParts.length >= 3 && pathParts[0] === 'v1') {
return { baseUrl: urlObj.origin, entity: pathParts[1], database: pathParts[2] };
} else if (pathParts.length >= 2) {
return { baseUrl: urlObj.origin, entity: pathParts[0], database: pathParts[1] };
}
throw new Error('Invalid database URL. Expected: https://host/entity/database');
}
}
class AybOAuth extends AybClient {
constructor(options) {
if (!options.appName) throw new Error('appName is required');
if (!options.queryPermissionLevel) throw new Error('queryPermissionLevel is required');
if (!['read-only', 'read-write'].includes(options.queryPermissionLevel)) {
throw new Error('queryPermissionLevel must be "read-only" or "read-write"');
}
super({
appId: options.appId || options.appName,
storageKey: options.storageKey
});
if (!options.serverUrl) throw new Error('serverUrl is required');
this.serverUrl = options.serverUrl;
this.appName = options.appName;
this.queryPermissionLevel = options.queryPermissionLevel;
}
getConnectionInfo() {
const base = super.getConnectionInfo();
if (!base) return null;
return { ...base, queryPermissionLevel: this._config.queryPermissionLevel };
}
async authorize(options = {}) {
const codeVerifier = this._generateCodeVerifier();
const codeChallenge = await this._sha256(codeVerifier);
const state = this._generateState();
sessionStorage.setItem('ayb_pkce_verifier', codeVerifier);
sessionStorage.setItem('ayb_oauth_state', state);
sessionStorage.setItem('ayb_oauth_server', this.serverUrl);
const callbackUrl = options.callbackPath
? window.location.origin + options.callbackPath
: window.location.origin + window.location.pathname;
const params = new URLSearchParams({
response_type: 'code',
redirect_uri: callbackUrl,
scope: this.queryPermissionLevel,
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
app_name: this.appName
});
window.location.href = `${this.serverUrl}/oauth/authorize?${params}`;
}
async handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (!code && !error) {
return false;
}
if (error) {
this._cleanUrl();
throw new Error(`Authorization failed: ${error}`);
}
const savedState = sessionStorage.getItem('ayb_oauth_state');
if (state !== savedState) {
this._cleanUrl();
throw new Error('State mismatch - possible CSRF attack');
}
const serverUrl = sessionStorage.getItem('ayb_oauth_server') || this.serverUrl;
const codeVerifier = sessionStorage.getItem('ayb_pkce_verifier');
if (!codeVerifier) {
this._cleanUrl();
throw new Error('Missing PKCE verifier - authorization flow may have been interrupted');
}
const response = await this._fetchWithRetry(`${serverUrl}/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: code,
redirect_uri: window.location.origin + window.location.pathname,
code_verifier: codeVerifier
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
this._cleanUrl();
throw new Error(errorData.error_description || 'Token exchange failed');
}
const tokenData = await response.json();
this.saveConfig(tokenData.database_url, tokenData.access_token);
this._config.queryPermissionLevel = tokenData.query_permission_level;
localStorage.setItem(this.storageKey, JSON.stringify(this._config));
sessionStorage.removeItem('ayb_pkce_verifier');
sessionStorage.removeItem('ayb_oauth_state');
sessionStorage.removeItem('ayb_oauth_server');
this._cleanUrl();
return true;
}
_generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return this._base64UrlEncode(array);
}
_generateState() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return this._base64UrlEncode(array);
}
async _sha256(str) {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
return this._base64UrlEncode(new Uint8Array(hash));
}
_base64UrlEncode(array) {
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
_cleanUrl() {
const url = new URL(window.location.href);
url.searchParams.delete('code');
url.searchParams.delete('state');
url.searchParams.delete('error');
window.history.replaceState({}, '', url.pathname + url.search);
}
}
async function restoreOAuth(options) {
const storageKey = options.storageKey || `ayb_${options.appId || options.appName}`;
const params = new URLSearchParams(window.location.search);
if (params.has('code') || params.has('error')) {
const ayb = new AybOAuth({
...options,
serverUrl: options.serverUrl || sessionStorage.getItem('ayb_oauth_server'),
});
await ayb.handleCallback();
return ayb;
}
const saved = localStorage.getItem(storageKey);
if (saved) {
const ayb = new AybOAuth({
...options,
serverUrl: options.serverUrl || JSON.parse(saved).baseUrl,
});
ayb.loadConfig();
return ayb;
}
return null;
}
function createServerSelectionModal(options) {
const serverUrls = options.serverUrls && options.serverUrls.length > 0
? options.serverUrls
: ['https://thedata.zone'];
const dialog = document.createElement('dialog');
dialog.style.cssText = 'border: 1px solid #ccc; border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; font-family: system-ui, sans-serif;';
const title = document.createElement('h3');
title.textContent = 'Connect a database';
title.style.cssText = 'margin: 0 0 4px 0; font-size: 18px;';
const subtitle = document.createElement('p');
subtitle.textContent = "Pick a server and database on which we'll store your data.";
subtitle.style.cssText = 'margin: 0 0 16px 0; font-size: 14px; color: #666;';
const label = document.createElement('label');
label.textContent = 'Server';
label.style.cssText = 'display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px;';
const select = document.createElement('select');
select.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; margin-bottom: 12px;';
serverUrls.forEach(url => {
const opt = document.createElement('option');
opt.value = url;
opt.textContent = url;
select.appendChild(opt);
});
const otherOpt = document.createElement('option');
otherOpt.value = '__other__';
otherOpt.textContent = 'Other...';
select.appendChild(otherOpt);
const customInput = document.createElement('input');
customInput.type = 'text';
customInput.placeholder = 'https://your-server.example.com';
customInput.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; margin-bottom: 12px; box-sizing: border-box; display: none;';
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.type = 'button';
cancelBtn.style.cssText = 'padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; font-size: 14px;';
const connectBtn = document.createElement('button');
connectBtn.textContent = 'Connect';
connectBtn.type = 'button';
connectBtn.style.cssText = 'padding: 8px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px;';
function getSelectedUrl() {
if (select.value === '__other__') {
return customInput.value.trim();
}
return select.value;
}
function updateConnectState() {
connectBtn.disabled = !getSelectedUrl();
connectBtn.style.opacity = connectBtn.disabled ? '0.5' : '1';
}
select.addEventListener('change', () => {
customInput.style.display = select.value === '__other__' ? 'block' : 'none';
updateConnectState();
});
customInput.addEventListener('input', updateConnectState);
cancelBtn.addEventListener('click', () => {
dialog.close();
dialog.remove();
});
connectBtn.addEventListener('click', () => {
const serverUrl = getSelectedUrl();
if (!serverUrl) return;
const ayb = new AybOAuth({
appName: options.appName,
queryPermissionLevel: options.queryPermissionLevel,
serverUrl: serverUrl,
appId: options.appId,
storageKey: options.storageKey,
});
ayb.authorize();
});
dialog.appendChild(title);
dialog.appendChild(subtitle);
dialog.appendChild(label);
dialog.appendChild(select);
dialog.appendChild(customInput);
btnRow.appendChild(cancelBtn);
btnRow.appendChild(connectBtn);
dialog.appendChild(btnRow);
document.body.appendChild(dialog);
dialog.showModal();
updateConnectState();
}
async function runMigrations(client, appId, migrations) {
const escapedAppId = AybClient.escapeSQL(appId);
await client.query(`CREATE TABLE IF NOT EXISTS _ayb_migrations (
app_id TEXT NOT NULL,
version INTEGER NOT NULL,
applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (app_id, version)
)`);
const result = await client.query(
`SELECT MAX(version) FROM _ayb_migrations WHERE app_id = '${escapedAppId}'`
);
const currentVersion = parseInt(result.rows?.[0]?.[0], 10) || 0;
if (currentVersion > migrations.length) {
throw new Error(
`Migration state corrupted for app '${appId}': database has version ${currentVersion} ` +
`but only ${migrations.length} migration(s) provided. Did you remove migrations from the list?`
);
}
for (let i = currentVersion; i < migrations.length; i++) {
try {
await client.query(migrations[i]);
} catch (e) {
const msg = e.message.toLowerCase();
if (!msg.includes('duplicate column') && !msg.includes('already exists')) {
throw e;
}
}
await client.query(
`INSERT OR REPLACE INTO _ayb_migrations (app_id, version) VALUES ('${escapedAppId}', ${i + 1})`
);
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { AybClient, AybOAuth, restoreOAuth, createServerSelectionModal, runMigrations };
}
if (typeof window !== 'undefined') {
window.AybClient = AybClient;
window.AybOAuth = AybOAuth;
window.restoreOAuth = restoreOAuth;
window.createServerSelectionModal = createServerSelectionModal;
window.runMigrations = runMigrations;
}