Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
67
fork

Configure Feed

Select the types of activity you want to include in your feed.

Storage Namespace Isolation Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Prevent storage collisions when multiple apps on the same domain use quickslice-client-js by deriving a unique namespace from clientId.

Architecture: Compute an 8-character SHA-256 hash of clientId at client init. Thread this namespace through all storage operations: localStorage/sessionStorage keys, IndexedDB database name, and lock keys. No migration — existing users re-login once.

Tech Stack: TypeScript, Web Crypto API (SHA-256), localStorage, sessionStorage, IndexedDB


Task 1: Add namespace hash utility#

Files:

  • Modify: src/utils/crypto.ts:1-36

Step 1: Add namespace hash function

Add after the existing sha256Base64Url function:

/**
 * Generate an 8-character namespace hash from clientId
 */
export async function generateNamespaceHash(clientId: string): Promise<string> {
  const encoder = new TextEncoder();
  const hash = await crypto.subtle.digest('SHA-256', encoder.encode(clientId));
  const hashArray = Array.from(new Uint8Array(hash));
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  return hashHex.substring(0, 8);
}

Step 2: Verify build passes

Run: npm run build Expected: Build succeeds with no errors

Step 3: Commit

git add src/utils/crypto.ts
git commit -m "feat: add namespace hash utility for storage isolation"

Task 2: Make storage keys dynamic#

Files:

  • Modify: src/storage/keys.ts:1-16

Step 1: Replace static keys with factory function

Replace the entire file contents:

/**
 * Storage key factory - generates namespaced keys
 */
export interface StorageKeys {
  accessToken: string;
  refreshToken: string;
  tokenExpiresAt: string;
  clientId: string;
  userDid: string;
  codeVerifier: string;
  oauthState: string;
  redirectUri: string;
}

export function createStorageKeys(namespace: string): StorageKeys {
  return {
    accessToken: `quickslice_${namespace}_access_token`,
    refreshToken: `quickslice_${namespace}_refresh_token`,
    tokenExpiresAt: `quickslice_${namespace}_token_expires_at`,
    clientId: `quickslice_${namespace}_client_id`,
    userDid: `quickslice_${namespace}_user_did`,
    codeVerifier: `quickslice_${namespace}_code_verifier`,
    oauthState: `quickslice_${namespace}_oauth_state`,
    redirectUri: `quickslice_${namespace}_redirect_uri`,
  };
}

export type StorageKey = string;

Step 2: Verify build fails

Run: npm run build Expected: Build fails — other files still import STORAGE_KEYS

Step 3: Commit (WIP)

git add src/storage/keys.ts
git commit -m "wip: make storage keys dynamic with namespace"

Task 3: Update storage module to accept keys#

Files:

  • Modify: src/storage/storage.ts:1-37

Step 1: Rewrite storage module

Replace the entire file contents:

import { StorageKeys } from './keys';

/**
 * Create a namespaced storage interface
 */
export function createStorage(keys: StorageKeys) {
  return {
    get(key: keyof StorageKeys): string | null {
      const storageKey = keys[key];
      // OAuth flow state stays in sessionStorage (per-tab)
      if (key === 'codeVerifier' || key === 'oauthState') {
        return sessionStorage.getItem(storageKey);
      }
      // Tokens go in localStorage (shared across tabs)
      return localStorage.getItem(storageKey);
    },

    set(key: keyof StorageKeys, value: string): void {
      const storageKey = keys[key];
      if (key === 'codeVerifier' || key === 'oauthState') {
        sessionStorage.setItem(storageKey, value);
      } else {
        localStorage.setItem(storageKey, value);
      }
    },

    remove(key: keyof StorageKeys): void {
      const storageKey = keys[key];
      sessionStorage.removeItem(storageKey);
      localStorage.removeItem(storageKey);
    },

    clear(): void {
      (Object.keys(keys) as Array<keyof StorageKeys>).forEach((key) => {
        const storageKey = keys[key];
        sessionStorage.removeItem(storageKey);
        localStorage.removeItem(storageKey);
      });
    },
  };
}

export type Storage = ReturnType<typeof createStorage>;

Step 2: Verify build still fails

Run: npm run build Expected: Build fails — consumers still use old imports

Step 3: Commit (WIP)

git add src/storage/storage.ts
git commit -m "wip: storage module accepts namespaced keys"

Task 4: Update lock module with namespace#

Files:

  • Modify: src/storage/lock.ts:1-57

Step 1: Rewrite lock module to accept namespace

Replace the entire file contents:

const LOCK_TIMEOUT = 5000; // 5 seconds

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function getLockKey(namespace: string, key: string): string {
  return `quickslice_${namespace}_lock_${key}`;
}

/**
 * Acquire a lock using localStorage for multi-tab coordination
 */
export async function acquireLock(
  namespace: string,
  key: string,
  timeout = LOCK_TIMEOUT
): Promise<string | null> {
  const lockKey = getLockKey(namespace, key);
  const lockValue = `${Date.now()}_${Math.random()}`;
  const deadline = Date.now() + timeout;

  while (Date.now() < deadline) {
    const existing = localStorage.getItem(lockKey);

    if (existing) {
      // Check if lock is stale (older than timeout)
      const [timestamp] = existing.split('_');
      if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) {
        // Lock is stale, remove it
        localStorage.removeItem(lockKey);
      } else {
        // Lock is held, wait and retry
        await sleep(50);
        continue;
      }
    }

    // Try to acquire
    localStorage.setItem(lockKey, lockValue);

    // Verify we got it (handle race condition)
    await sleep(10);
    if (localStorage.getItem(lockKey) === lockValue) {
      return lockValue; // Lock acquired
    }
  }

  return null; // Failed to acquire
}

/**
 * Release a lock
 */
export function releaseLock(namespace: string, key: string, lockValue: string): void {
  const lockKey = getLockKey(namespace, key);
  // Only release if we still hold it
  if (localStorage.getItem(lockKey) === lockValue) {
    localStorage.removeItem(lockKey);
  }
}

Step 2: Verify build still fails

Run: npm run build Expected: Build fails — tokens.ts uses old lock signature

Step 3: Commit (WIP)

git add src/storage/lock.ts
git commit -m "wip: lock module uses namespace prefix"

Task 5: Update DPoP module with namespaced database#

Files:

  • Modify: src/auth/dpop.ts:1-147

Step 1: Change database name to use namespace

Replace lines 1-36 with:

import { generateRandomString } from '../utils/base64url';
import { sha256Base64Url } from '../utils/crypto';

const DB_VERSION = 1;
const KEY_STORE = 'dpop-keys';
const KEY_ID = 'dpop-key';

interface DPoPKeyData {
  id: string;
  privateKey: CryptoKey;
  publicJwk: JsonWebKey;
  createdAt: number;
}

// Cache database connections per namespace
const dbPromises = new Map<string, Promise<IDBDatabase>>();

function getDbName(namespace: string): string {
  return `quickslice-oauth-${namespace}`;
}

function openDatabase(namespace: string): Promise<IDBDatabase> {
  const existing = dbPromises.get(namespace);
  if (existing) return existing;

  const promise = new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(getDbName(namespace), DB_VERSION);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;
      if (!db.objectStoreNames.contains(KEY_STORE)) {
        db.createObjectStore(KEY_STORE, { keyPath: 'id' });
      }
    };
  });

  dbPromises.set(namespace, promise);
  return promise;
}

Step 2: Update getDPoPKey to accept namespace

Replace the getDPoPKey function (lines 38-48) with:

async function getDPoPKey(namespace: string): Promise<DPoPKeyData | null> {
  const db = await openDatabase(namespace);
  return new Promise((resolve, reject) => {
    const tx = db.transaction(KEY_STORE, 'readonly');
    const store = tx.objectStore(KEY_STORE);
    const request = store.get(KEY_ID);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result || null);
  });
}

Step 3: Update storeDPoPKey to accept namespace

Replace the storeDPoPKey function (lines 50-68) with:

async function storeDPoPKey(
  namespace: string,
  privateKey: CryptoKey,
  publicJwk: JsonWebKey
): Promise<void> {
  const db = await openDatabase(namespace);
  return new Promise((resolve, reject) => {
    const tx = db.transaction(KEY_STORE, 'readwrite');
    const store = tx.objectStore(KEY_STORE);
    const request = store.put({
      id: KEY_ID,
      privateKey,
      publicJwk,
      createdAt: Date.now(),
    });

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve();
  });
}

Step 4: Update getOrCreateDPoPKey to accept namespace

Replace the getOrCreateDPoPKey function (lines 70-96) with:

export async function getOrCreateDPoPKey(namespace: string): Promise<DPoPKeyData> {
  const keyData = await getDPoPKey(namespace);

  if (keyData) {
    return keyData;
  }

  // Generate new P-256 key pair
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    false, // NOT extractable - critical for security
    ['sign']
  );

  // Export public key as JWK
  const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);

  // Store in IndexedDB
  await storeDPoPKey(namespace, keyPair.privateKey, publicJwk);

  return {
    id: KEY_ID,
    privateKey: keyPair.privateKey,
    publicJwk,
    createdAt: Date.now(),
  };
}

Step 5: Update createDPoPProof to accept namespace

Replace the createDPoPProof function (lines 98-131) with:

/**
 * Create a DPoP proof JWT
 */
export async function createDPoPProof(
  namespace: string,
  method: string,
  url: string,
  accessToken: string | null = null
): Promise<string> {
  const keyData = await getOrCreateDPoPKey(namespace);

  // Strip WebCrypto-specific fields from JWK for interoperability
  const { kty, crv, x, y } = keyData.publicJwk;
  const minimalJwk = { kty, crv, x, y };

  const header = {
    alg: 'ES256',
    typ: 'dpop+jwt',
    jwk: minimalJwk,
  };

  const payload: Record<string, unknown> = {
    jti: generateRandomString(16),
    htm: method,
    htu: url,
    iat: Math.floor(Date.now() / 1000),
  };

  // Add access token hash if provided (for resource requests)
  if (accessToken) {
    payload.ath = await sha256Base64Url(accessToken);
  }

  return await signJwt(header, payload, keyData.privateKey);
}

Step 6: Update clearDPoPKeys to accept namespace

Replace the clearDPoPKeys function (lines 133-146) with:

/**
 * Clear DPoP keys from IndexedDB
 */
export async function clearDPoPKeys(namespace: string): Promise<void> {
  const db = await openDatabase(namespace);
  return new Promise((resolve, reject) => {
    const tx = db.transaction(KEY_STORE, 'readwrite');
    const store = tx.objectStore(KEY_STORE);
    const request = store.clear();

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve();
  });
}

Step 7: Add signJwt import

Add to the imports at line 2:

import { sha256Base64Url, signJwt } from '../utils/crypto';

Step 8: Verify build still fails

Run: npm run build Expected: Build fails — consumers don't pass namespace

Step 9: Commit (WIP)

git add src/auth/dpop.ts
git commit -m "wip: dpop module uses namespaced IndexedDB database"

Task 6: Update tokens module#

Files:

  • Modify: src/auth/tokens.ts:1-137

Step 1: Rewrite tokens module to use storage instance

Replace the entire file contents:

import { Storage } from '../storage/storage';
import { acquireLock, releaseLock } from '../storage/lock';
import { createDPoPProof } from './dpop';

const TOKEN_REFRESH_BUFFER_MS = 60000; // 60 seconds before expiry

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Refresh tokens using the refresh token
 */
async function refreshTokens(
  storage: Storage,
  namespace: string,
  tokenUrl: string
): Promise<string> {
  const refreshToken = storage.get('refreshToken');
  const clientId = storage.get('clientId');

  if (!refreshToken || !clientId) {
    throw new Error('No refresh token available');
  }

  const dpopProof = await createDPoPProof(namespace, 'POST', tokenUrl);

  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      DPoP: dpopProof,
    },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId,
    }),
  });

  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}));
    throw new Error(
      `Token refresh failed: ${errorData.error_description || response.statusText}`
    );
  }

  const tokens = await response.json();

  // Store new tokens (rotation - new refresh token each time)
  storage.set('accessToken', tokens.access_token);
  if (tokens.refresh_token) {
    storage.set('refreshToken', tokens.refresh_token);
  }

  const expiresAt = Date.now() + tokens.expires_in * 1000;
  storage.set('tokenExpiresAt', expiresAt.toString());

  return tokens.access_token;
}

/**
 * Get a valid access token, refreshing if necessary.
 * Uses multi-tab locking to prevent duplicate refresh requests.
 */
export async function getValidAccessToken(
  storage: Storage,
  namespace: string,
  tokenUrl: string
): Promise<string> {
  const accessToken = storage.get('accessToken');
  const expiresAt = parseInt(storage.get('tokenExpiresAt') || '0');

  // Check if token is still valid (with buffer)
  if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) {
    return accessToken;
  }

  // Need to refresh - acquire lock first
  const lockKey = 'token_refresh';
  const lockValue = await acquireLock(namespace, lockKey);

  if (!lockValue) {
    // Failed to acquire lock, another tab is refreshing
    // Wait a bit and check cache again
    await sleep(100);
    const freshToken = storage.get('accessToken');
    const freshExpiry = parseInt(storage.get('tokenExpiresAt') || '0');
    if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) {
      return freshToken;
    }
    throw new Error('Failed to refresh token');
  }

  try {
    // Double-check after acquiring lock
    const freshToken = storage.get('accessToken');
    const freshExpiry = parseInt(storage.get('tokenExpiresAt') || '0');
    if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) {
      return freshToken;
    }

    // Actually refresh
    return await refreshTokens(storage, namespace, tokenUrl);
  } finally {
    releaseLock(namespace, lockKey, lockValue);
  }
}

/**
 * Store tokens from OAuth response
 */
export function storeTokens(
  storage: Storage,
  tokens: {
    access_token: string;
    refresh_token?: string;
    expires_in: number;
    sub?: string;
  }
): void {
  storage.set('accessToken', tokens.access_token);
  if (tokens.refresh_token) {
    storage.set('refreshToken', tokens.refresh_token);
  }

  const expiresAt = Date.now() + tokens.expires_in * 1000;
  storage.set('tokenExpiresAt', expiresAt.toString());

  if (tokens.sub) {
    storage.set('userDid', tokens.sub);
  }
}

/**
 * Check if we have a valid session
 */
export function hasValidSession(storage: Storage): boolean {
  const accessToken = storage.get('accessToken');
  const refreshToken = storage.get('refreshToken');
  return !!(accessToken || refreshToken);
}

Step 2: Verify build still fails

Run: npm run build Expected: Build fails — oauth.ts and client.ts use old signatures

Step 3: Commit (WIP)

git add src/auth/tokens.ts
git commit -m "wip: tokens module accepts storage and namespace"

Task 7: Update oauth module#

Files:

  • Modify: src/auth/oauth.ts:1-141

Step 1: Rewrite oauth module

Replace the entire file contents:

import { Storage } from '../storage/storage';
import { createDPoPProof, clearDPoPKeys } from './dpop';
import { generateCodeVerifier, generateCodeChallenge, generateState } from './pkce';
import { storeTokens } from './tokens';

export interface LoginOptions {
  handle?: string;
  redirectUri?: string;
  scope?: string;
}

/**
 * Initiate OAuth login flow with PKCE
 */
export async function initiateLogin(
  storage: Storage,
  authorizeUrl: string,
  clientId: string,
  options: LoginOptions = {}
): Promise<void> {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = generateState();

  // Build redirect URI (use provided or derive from current page)
  const redirectUri = options.redirectUri || (window.location.origin + window.location.pathname);

  // Store for callback
  storage.set('codeVerifier', codeVerifier);
  storage.set('oauthState', state);
  storage.set('clientId', clientId);
  storage.set('redirectUri', redirectUri);

  // Build authorization URL
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: 'code',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: state,
  });

  if (options.handle) {
    params.set('login_hint', options.handle);
  }

  if (options.scope) {
    params.set('scope', options.scope);
  }

  window.location.href = `${authorizeUrl}?${params.toString()}`;
}

/**
 * Handle OAuth callback - exchange code for tokens
 * Returns true if callback was handled, false if not a callback
 */
export async function handleOAuthCallback(
  storage: Storage,
  namespace: string,
  tokenUrl: string
): Promise<boolean> {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
  const error = params.get('error');

  if (error) {
    throw new Error(
      `OAuth error: ${error} - ${params.get('error_description') || ''}`
    );
  }

  if (!code || !state) {
    return false; // Not a callback
  }

  // Verify state
  const storedState = storage.get('oauthState');
  if (state !== storedState) {
    throw new Error('OAuth state mismatch - possible CSRF attack');
  }

  // Get stored values
  const codeVerifier = storage.get('codeVerifier');
  const clientId = storage.get('clientId');
  const redirectUri = storage.get('redirectUri');

  if (!codeVerifier || !clientId || !redirectUri) {
    throw new Error('Missing OAuth session data');
  }

  // Exchange code for tokens with DPoP
  const dpopProof = await createDPoPProof(namespace, 'POST', tokenUrl);

  const tokenResponse = await fetch(tokenUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      DPoP: dpopProof,
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: redirectUri,
      client_id: clientId,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenResponse.ok) {
    const errorData = await tokenResponse.json().catch(() => ({}));
    throw new Error(
      `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`
    );
  }

  const tokens = await tokenResponse.json();

  // Store tokens
  storeTokens(storage, tokens);

  // Clean up OAuth state
  storage.remove('codeVerifier');
  storage.remove('oauthState');
  storage.remove('redirectUri');

  // Clear URL params
  window.history.replaceState({}, document.title, window.location.pathname);

  return true;
}

/**
 * Logout - clear all stored data
 */
export async function logout(
  storage: Storage,
  namespace: string,
  options: { reload?: boolean } = {}
): Promise<void> {
  storage.clear();
  await clearDPoPKeys(namespace);

  if (options.reload !== false) {
    window.location.reload();
  }
}

Step 2: Verify build still fails

Run: npm run build Expected: Build fails — client.ts uses old signatures

Step 3: Commit (WIP)

git add src/auth/oauth.ts
git commit -m "wip: oauth module accepts storage and namespace"

Task 8: Update graphql module#

Files:

  • Modify: src/graphql.ts:1-53

Step 1: Rewrite graphql module

Replace the entire file contents:

import { createDPoPProof } from './auth/dpop';
import { getValidAccessToken } from './auth/tokens';
import { Storage } from './storage/storage';

export interface GraphQLResponse<T = unknown> {
  data?: T;
  errors?: Array<{ message: string; path?: string[] }>;
}

/**
 * Execute a GraphQL query or mutation
 */
export async function graphqlRequest<T = unknown>(
  storage: Storage,
  namespace: string,
  graphqlUrl: string,
  tokenUrl: string,
  query: string,
  variables: Record<string, unknown> = {},
  requireAuth = false
): Promise<T> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  };

  if (requireAuth) {
    const token = await getValidAccessToken(storage, namespace, tokenUrl);
    if (!token) {
      throw new Error('Not authenticated');
    }

    // Create DPoP proof bound to this request
    const dpopProof = await createDPoPProof(namespace, 'POST', graphqlUrl, token);

    headers['Authorization'] = `DPoP ${token}`;
    headers['DPoP'] = dpopProof;
  }

  const response = await fetch(graphqlUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
  });

  if (!response.ok) {
    throw new Error(`GraphQL request failed: ${response.statusText}`);
  }

  const result: GraphQLResponse<T> = await response.json();

  if (result.errors && result.errors.length > 0) {
    throw new Error(`GraphQL error: ${result.errors[0].message}`);
  }

  return result.data as T;
}

Step 2: Verify build still fails

Run: npm run build Expected: Build fails — client.ts uses old import

Step 3: Commit (WIP)

git add src/graphql.ts
git commit -m "wip: graphql module accepts storage and namespace"

Task 9: Update client module (main integration)#

Files:

  • Modify: src/client.ts:1-155

Step 1: Rewrite client module

Replace the entire file contents:

import { createStorageKeys } from './storage/keys';
import { createStorage, Storage } from './storage/storage';
import { getOrCreateDPoPKey } from './auth/dpop';
import { initiateLogin, handleOAuthCallback, logout as doLogout, LoginOptions } from './auth/oauth';
import { getValidAccessToken, hasValidSession } from './auth/tokens';
import { graphqlRequest } from './graphql';
import { generateNamespaceHash } from './utils/crypto';

export interface QuicksliceClientOptions {
  server: string;
  clientId: string;
  redirectUri?: string;
  scope?: string;
}

export interface User {
  did: string;
}

export class QuicksliceClient {
  private server: string;
  private clientId: string;
  private redirectUri?: string;
  private scope?: string;
  private graphqlUrl: string;
  private authorizeUrl: string;
  private tokenUrl: string;
  private initialized = false;
  private namespace: string = '';
  private storage: Storage | null = null;

  constructor(options: QuicksliceClientOptions) {
    this.server = options.server.replace(/\/$/, ''); // Remove trailing slash
    this.clientId = options.clientId;
    this.redirectUri = options.redirectUri;
    this.scope = options.scope;

    this.graphqlUrl = `${this.server}/graphql`;
    this.authorizeUrl = `${this.server}/oauth/authorize`;
    this.tokenUrl = `${this.server}/oauth/token`;
  }

  /**
   * Initialize the client - must be called before other methods
   */
  async init(): Promise<void> {
    if (this.initialized) return;

    // Generate namespace from clientId
    this.namespace = await generateNamespaceHash(this.clientId);

    // Create namespaced storage
    const keys = createStorageKeys(this.namespace);
    this.storage = createStorage(keys);

    // Ensure DPoP key exists
    await getOrCreateDPoPKey(this.namespace);

    this.initialized = true;
  }

  private getStorage(): Storage {
    if (!this.storage) {
      throw new Error('Client not initialized. Call init() first.');
    }
    return this.storage;
  }

  /**
   * Start OAuth login flow
   */
  async loginWithRedirect(options: LoginOptions = {}): Promise<void> {
    await this.init();
    await initiateLogin(this.getStorage(), this.authorizeUrl, this.clientId, {
      ...options,
      redirectUri: options.redirectUri || this.redirectUri,
      scope: options.scope || this.scope,
    });
  }

  /**
   * Handle OAuth callback after redirect
   * Returns true if callback was handled
   */
  async handleRedirectCallback(): Promise<boolean> {
    await this.init();
    return await handleOAuthCallback(this.getStorage(), this.namespace, this.tokenUrl);
  }

  /**
   * Logout and clear all stored data
   */
  async logout(options: { reload?: boolean } = {}): Promise<void> {
    await this.init();
    await doLogout(this.getStorage(), this.namespace, options);
  }

  /**
   * Check if user is authenticated
   */
  async isAuthenticated(): Promise<boolean> {
    await this.init();
    return hasValidSession(this.getStorage());
  }

  /**
   * Get current user's DID (from stored token data)
   * For richer profile info, use client.query() with your own schema
   */
  async getUser(): Promise<User | null> {
    await this.init();
    if (!hasValidSession(this.getStorage())) {
      return null;
    }

    const did = this.getStorage().get('userDid');
    if (!did) {
      return null;
    }

    return { did };
  }

  /**
   * Get access token (auto-refreshes if needed)
   */
  async getAccessToken(): Promise<string> {
    await this.init();
    return await getValidAccessToken(this.getStorage(), this.namespace, this.tokenUrl);
  }

  /**
   * Execute a GraphQL query (authenticated)
   */
  async query<T = unknown>(
    query: string,
    variables: Record<string, unknown> = {}
  ): Promise<T> {
    await this.init();
    return await graphqlRequest<T>(
      this.getStorage(),
      this.namespace,
      this.graphqlUrl,
      this.tokenUrl,
      query,
      variables,
      true
    );
  }

  /**
   * Execute a GraphQL mutation (authenticated)
   */
  async mutate<T = unknown>(
    mutation: string,
    variables: Record<string, unknown> = {}
  ): Promise<T> {
    return this.query<T>(mutation, variables);
  }

  /**
   * Execute a public GraphQL query (no auth)
   */
  async publicQuery<T = unknown>(
    query: string,
    variables: Record<string, unknown> = {}
  ): Promise<T> {
    await this.init();
    return await graphqlRequest<T>(
      this.getStorage(),
      this.namespace,
      this.graphqlUrl,
      this.tokenUrl,
      query,
      variables,
      false
    );
  }
}

Step 2: Verify build passes

Run: npm run build Expected: Build succeeds

Step 3: Commit

git add src/client.ts
git commit -m "feat: integrate namespace throughout client for storage isolation"

Task 10: Squash WIP commits and finalize#

Step 1: Interactive rebase to squash WIP commits

Run: git log --oneline -10 to see recent commits

Step 2: Squash into a single feature commit

git rebase -i HEAD~9

Mark all but the first commit as "squash", use message:

feat: add storage namespace isolation for multi-app support

Derive unique namespace from clientId hash to prevent storage collisions
when multiple apps use quickslice-client-js on the same domain.

Changes:
- Storage keys prefixed with 8-char SHA-256 hash of clientId
- IndexedDB database name includes namespace
- Lock keys include namespace
- Breaking: existing users will need to re-login once

Step 3: Verify final build

Run: npm run build Expected: Build succeeds


Summary#

Task Description
1 Add namespace hash utility
2 Make storage keys dynamic
3 Update storage module
4 Update lock module
5 Update DPoP module
6 Update tokens module
7 Update oauth module
8 Update graphql module
9 Update client module (integration)
10 Squash commits and finalize

Total files modified: 9 Breaking change: Users will appear logged out and need to re-login once after update.