@basictech/react 0.7.0 → 0.8.0-beta.1

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.
@@ -59,23 +59,26 @@ export class RemoteCollection<T extends { id: string } = Record<string, any> & {
59
59
 
60
60
  this.log(`${method} ${url}`, body ? JSON.stringify(body) : '')
61
61
 
62
+ const headers: Record<string, string> = {
63
+ 'Authorization': `Bearer ${token}`
64
+ }
65
+ if (body) {
66
+ headers['Content-Type'] = 'application/json'
67
+ }
68
+
62
69
  const response = await fetch(url, {
63
70
  method,
64
- headers: {
65
- 'Content-Type': 'application/json',
66
- 'Authorization': `Bearer ${token}`
67
- },
71
+ headers,
68
72
  ...(body ? { body: JSON.stringify(body) } : {})
69
73
  })
70
74
 
71
75
  const responseData = await response.json().catch(() => ({}))
72
76
 
73
77
  if (!response.ok) {
74
- // Handle 401 Unauthorized - token may have expired
78
+ // Handle 401 Unauthorized - force refresh then retry once
75
79
  if (response.status === 401 && !isRetry) {
76
- this.log('Got 401, retrying with fresh token...')
77
- // getToken() should refresh the token if expired
78
- // Retry the request once
80
+ this.log('Got 401, forcing token refresh and retrying...')
81
+ await this.config.getToken({ forceRefresh: true })
79
82
  return this.request<R>(method, path, body, true)
80
83
  }
81
84
 
@@ -83,16 +86,27 @@ export class RemoteCollection<T extends { id: string } = Record<string, any> & {
83
86
  console.error(`[RemoteDB] Error ${response.status}:`, responseData)
84
87
  }
85
88
 
86
- // Call onAuthError callback if provided and this is an auth error
87
- if (response.status === 401 && this.config.onAuthError) {
88
- this.config.onAuthError({
89
- status: response.status,
90
- message: 'Authentication failed',
91
- response: responseData
92
- })
89
+ // Call onAuthError callback for auth/authz errors
90
+ if (this.config.onAuthError) {
91
+ if (response.status === 401) {
92
+ this.config.onAuthError({
93
+ status: response.status,
94
+ message: 'Authentication failed',
95
+ response: responseData,
96
+ errorType: 'expired',
97
+ afterRetry: isRetry,
98
+ })
99
+ } else if (response.status === 403) {
100
+ this.config.onAuthError({
101
+ status: response.status,
102
+ message: responseData.message || 'Forbidden - insufficient permissions or missing scope',
103
+ response: responseData,
104
+ errorType: 'forbidden',
105
+ afterRetry: isRetry,
106
+ })
107
+ }
93
108
  }
94
109
 
95
- // Try different error message fields that APIs commonly use
96
110
  const errorMessage = responseData.message || responseData.error || responseData.detail ||
97
111
  (typeof responseData === 'string' ? responseData : `API request failed: ${response.status}`)
98
112
  throw new RemoteDBError(errorMessage, response.status, responseData)
@@ -1,5 +1,5 @@
1
1
  // Core DB exports
2
- export type { Collection, BasicDB, DBMode, RemoteDBConfig } from './types'
2
+ export type { Collection, BasicDB, DBMode, RemoteDBConfig, GetTokenOptions } from './types'
3
3
  export type { AuthError } from './types'
4
4
  export { RemoteDBError } from './types'
5
5
  export { RemoteDB } from './RemoteDB'
@@ -91,6 +91,10 @@ export interface AuthError {
91
91
  status: number
92
92
  message: string
93
93
  response?: any
94
+ /** Classifies the error for UI display (e.g. "session expired" vs "forbidden") */
95
+ errorType: 'expired' | 'forbidden' | 'revoked' | 'network' | 'unknown'
96
+ /** True if this error occurred after a retry with a refreshed token */
97
+ afterRetry: boolean
94
98
  }
95
99
 
96
100
  /**
@@ -109,13 +113,21 @@ export class RemoteDBError extends Error {
109
113
  }
110
114
  }
111
115
 
116
+ /**
117
+ * Options for getToken (e.g. force refresh after 401)
118
+ */
119
+ export interface GetTokenOptions {
120
+ /** When true, refresh the access token before returning (e.g. after server returned 401) */
121
+ forceRefresh?: boolean
122
+ }
123
+
112
124
  /**
113
125
  * Configuration for RemoteDB
114
126
  */
115
127
  export interface RemoteDBConfig {
116
128
  serverUrl: string
117
129
  projectId: string
118
- getToken: () => Promise<string>
130
+ getToken: (options?: GetTokenOptions) => Promise<string>
119
131
  schema?: any
120
132
  /** Enable debug logging (default: false) */
121
133
  debug?: boolean
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  BasicContextType,
14
14
  AuthResult
15
15
  } from "./AuthContext"
16
+ export { DBStatus } from "./AuthContext"
16
17
 
17
18
  // Core DB exports
18
19
  export type {
@@ -20,10 +21,15 @@ export type {
20
21
  BasicDB,
21
22
  Collection,
22
23
  RemoteDBConfig,
24
+ GetTokenOptions,
23
25
  AuthError
24
26
  } from "./core/db"
25
27
 
26
28
  export { RemoteDB, RemoteCollection, RemoteDBError, NotAuthenticatedError } from "./core/db"
27
29
 
28
30
  // Storage utilities
29
- export { STORAGE_KEYS } from "./utils/storage"
31
+ export { STORAGE_KEYS } from "./utils/storage"
32
+
33
+ // DID resolution
34
+ export { resolveDid, resolveHandle, resolveDidWebUrl } from "./utils/resolveDid"
35
+ export type { ResolvedDid } from "./utils/resolveDid"
package/src/sync/index.ts CHANGED
@@ -4,7 +4,8 @@ import { v7 as uuidv7 } from 'uuid';
4
4
  import { Dexie } from 'dexie';
5
5
 
6
6
  import { log } from '../config'
7
- import { validateSchema, validateData } from '@basictech/schema'
7
+ import { validateData } from '@basictech/schema'
8
+ import { setTokenGetter } from './tokenRegistry'
8
9
 
9
10
  // Track initialization state
10
11
  let dexieExtensionsLoaded = false;
@@ -58,40 +59,29 @@ export class BasicSync extends Dexie {
58
59
  constructor(name: string, options: any) {
59
60
  super(name, options);
60
61
 
61
- // --- INIT SCHEMA --- //
62
-
63
- //todo: handle versions?
64
-
65
- // TODO: validate schema
62
+ // --- INIT SCHEMA --- //
66
63
  this.basic_schema = options.schema
67
64
  this.version(1).stores(this._convertSchemaToDxSchema(this.basic_schema))
68
-
69
65
  this.version(2).stores({})
70
- // this.verssion
71
66
 
72
-
73
- // create an alias for toArray
74
- // @ts-ignore
67
+ // @ts-ignore - alias for toArray
75
68
  this.Collection.prototype.get = this.Collection.prototype.toArray
76
-
77
-
78
- // --- SYNC --- //
79
-
80
- // this.syncable.on("statusChanged", (status, url) => {
81
- // console.log("statusChanged", status, url)
82
- // })
83
-
84
69
  }
85
70
 
86
- async connect({ access_token, ws_url }: { access_token: string, ws_url?: string }) {
71
+ async connect({ getToken, ws_url }: { getToken: (opts?: { forceRefresh?: boolean }) => Promise<string>, ws_url?: string }) {
87
72
  const WS_URL = ws_url || 'wss://pds.basic.id/ws'
88
73
 
89
74
  log('Connecting to', WS_URL)
90
75
 
76
+ // Store getToken in module-level registry (not in options) because
77
+ // dexie-syncable serializes options into IndexedDB via structured clone,
78
+ // which cannot handle functions.
79
+ setTokenGetter(WS_URL, getToken)
80
+
91
81
  await this.updateSyncNodes();
92
82
 
93
83
  log('Starting connection...')
94
- return this.syncable.connect("websocket", WS_URL, { authToken: access_token, schema: this.basic_schema });
84
+ return this.syncable.connect("websocket", WS_URL, { schema: this.basic_schema });
95
85
  }
96
86
 
97
87
  async disconnect({ ws_url }: { ws_url?: string } = {}) {
@@ -128,7 +118,6 @@ export class BasicSync extends Dexie {
128
118
  log(`HEISENBUG: Setting ${node.id} to ${node.id === largestNodeId ? 'master' : '0'}`);
129
119
  }
130
120
 
131
- // add delay to ensure sync nodes are updated // i dont think this helps?
132
121
  await new Promise(resolve => setTimeout(resolve, 1000));
133
122
 
134
123
  if (typeof window !== 'undefined') {
@@ -149,8 +138,10 @@ export class BasicSync extends Dexie {
149
138
 
150
139
  _convertSchemaToDxSchema(schema: any) {
151
140
  const stores = Object.entries(schema.tables).map(([key, table]: any) => {
152
-
153
- const indexedFields = Object.entries(table.fields).filter(([key, field]: any) => field.indexed).map(([key, field]: any) => `,${key}`).join('')
141
+ const indexedFields = Object.entries(table.fields)
142
+ .filter(([, field]: any) => field.indexed)
143
+ .map(([fieldKey]: any) => `,${fieldKey}`)
144
+ .join('')
154
145
  return {
155
146
  [key]: 'id' + indexedFields
156
147
  }
@@ -160,11 +151,6 @@ export class BasicSync extends Dexie {
160
151
  }
161
152
 
162
153
  debugeroo() {
163
- // console.log("debugeroo", this.syncable)
164
-
165
- // this.syncable.list().then(x => console.log(x))
166
-
167
- // this.syncable
168
154
  return this.syncable
169
155
  }
170
156
 
@@ -1,11 +1,24 @@
1
1
  "use client"
2
2
  import { Dexie } from "dexie";
3
3
  import { log } from "../config";
4
+ import { getTokenGetter } from "./tokenRegistry";
5
+
6
+ function decodeJwtExp(token) {
7
+ try {
8
+ var parts = token.split(".");
9
+ if (parts.length !== 3) return null;
10
+ var payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
11
+ return typeof payload.exp === "number" ? payload.exp : null;
12
+ } catch (_) {
13
+ return null;
14
+ }
15
+ }
4
16
 
5
17
  export const syncProtocol = function () {
6
18
  log("Initializing syncProtocol");
7
19
  // Constants:
8
20
  var RECONNECT_DELAY = 5000; // Reconnect delay in case of errors such as network down.
21
+ var TOKEN_REFRESH_BUFFER = 60; // Refresh token this many seconds before exp
9
22
 
10
23
  Dexie.Syncable.registerSyncProtocol("websocket", {
11
24
  sync: function (
@@ -24,13 +37,12 @@ export const syncProtocol = function () {
24
37
  // The following vars are needed because we must know which callback to ack when server sends it's ack to us.
25
38
  var requestId = 0;
26
39
  var acceptCallbacks = {};
40
+ var refreshTimer = null;
27
41
 
28
42
  // Connect the WebSocket to given url:
29
43
  log("Connecting to", url)
30
44
  var ws = new WebSocket(url);
31
45
 
32
- // console.log("ws OPTIONS", options);
33
-
34
46
  // sendChanges() method:
35
47
  function sendChanges(changes, baseRevision, partial, onChangesAccepted) {
36
48
  log("sendChanges", changes.length, baseRevision);
@@ -62,26 +74,72 @@ export const syncProtocol = function () {
62
74
 
63
75
 
64
76
 
65
- // When WebSocket opens, send our changes to the server.
66
- ws.onopen = function (event) {
67
- // Initiate this socket connection by sending our clientIdentity. If we dont have a clientIdentity yet,
68
- // server will call back with a new client identity that we should use in future WebSocket connections.
69
-
70
- // send the schema to the server
71
- log("Opening socket - sending clientIdentity", context.clientIdentity);
72
- ws.send(
73
- JSON.stringify({
74
- type: "clientIdentity",
75
- clientIdentity: context.clientIdentity || null,
76
- authToken: options.authToken,
77
- schema: options.schema
78
- }),
79
- );
77
+ function clearRefreshTimer() {
78
+ if (refreshTimer) {
79
+ clearTimeout(refreshTimer);
80
+ refreshTimer = null;
81
+ }
82
+ }
83
+
84
+ // Resolve the getToken function from the module-level registry.
85
+ // It's stored there (not in options) because dexie-syncable serializes
86
+ // options into IndexedDB, and functions can't survive structured clone.
87
+ function resolveGetToken() {
88
+ var fn = getTokenGetter(url);
89
+ if (!fn) throw new Error("No token getter registered for " + url);
90
+ return fn;
91
+ }
80
92
 
93
+ // Schedule a proactive token refresh before the JWT expires.
94
+ // Sends a tokenUpdate message on the existing WebSocket so the
95
+ // server can accept the new token without dropping the connection.
96
+ function scheduleTokenRefresh(tokenStr) {
97
+ clearRefreshTimer();
98
+ var exp = decodeJwtExp(tokenStr);
99
+ if (!exp) return;
100
+ var msUntilRefresh = (exp - TOKEN_REFRESH_BUFFER) * 1000 - Date.now();
101
+ if (msUntilRefresh <= 0) return;
102
+ log("Scheduling proactive token refresh in", Math.round(msUntilRefresh / 1000), "s");
103
+ refreshTimer = setTimeout(async function () {
104
+ try {
105
+ var newToken = await resolveGetToken()({ forceRefresh: true });
106
+ if (ws.readyState === WebSocket.OPEN) {
107
+ log("Sending tokenUpdate on existing WebSocket");
108
+ ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
109
+ scheduleTokenRefresh(newToken);
110
+ }
111
+ } catch (err) {
112
+ log("Proactive token refresh failed (non-fatal):", err);
113
+ }
114
+ }, msUntilRefresh);
115
+ }
116
+
117
+ // When WebSocket opens, get a fresh token and send our identity to the server.
118
+ // This runs on every open, including reconnects after ERROR_WILL_RETRY,
119
+ // so each attempt gets a fresh token via getToken().
120
+ ws.onopen = async function (event) {
121
+ try {
122
+ var token = await resolveGetToken()();
123
+ log("Opening socket - sending clientIdentity", context.clientIdentity);
124
+ ws.send(
125
+ JSON.stringify({
126
+ type: "clientIdentity",
127
+ clientIdentity: context.clientIdentity || null,
128
+ authToken: token,
129
+ schema: options.schema
130
+ }),
131
+ );
132
+ scheduleTokenRefresh(token);
133
+ } catch (err) {
134
+ log("Failed to get token for WebSocket:", err);
135
+ ws.close();
136
+ onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
137
+ }
81
138
  };
82
139
 
83
140
  // If network down or other error, tell the framework to reconnect again in some time:
84
141
  ws.onerror = function (event) {
142
+ clearRefreshTimer();
85
143
  ws.close();
86
144
  log("ws.onerror", event);
87
145
  onError(event?.message, RECONNECT_DELAY);
@@ -89,7 +147,7 @@ export const syncProtocol = function () {
89
147
 
90
148
  // If socket is closed (network disconnected), inform framework and make it reconnect
91
149
  ws.onclose = function (event) {
92
- // console.log('🙅 ws.onclose', event)
150
+ clearRefreshTimer();
93
151
  onError("Socket closed: " + event.reason, RECONNECT_DELAY);
94
152
  };
95
153
 
@@ -114,7 +172,7 @@ export const syncProtocol = function () {
114
172
  // partial: true if server has additionalChanges to send. False if these changes were the last known. (applicable if type="changes")
115
173
  // }
116
174
  var requestFromServer = JSON.parse(event.data);
117
- log("requestFromServer", requestFromServer, { acceptCallback, isFirstRound });
175
+ log("requestFromServer", requestFromServer, { isFirstRound });
118
176
 
119
177
  if (requestFromServer.type == "clientIdentity") {
120
178
  context.clientIdentity = requestFromServer.clientIdentity;
@@ -151,8 +209,8 @@ export const syncProtocol = function () {
151
209
  onChangesAccepted,
152
210
  );
153
211
  },
154
- // Specify a disconnect function that will close our socket so that we dont continue to monitor changes.
155
212
  disconnect: function () {
213
+ clearRefreshTimer();
156
214
  ws.close();
157
215
  },
158
216
  });
@@ -164,9 +222,13 @@ export const syncProtocol = function () {
164
222
  acceptCallback(); // Tell framework that server has acknowledged the changes sent.
165
223
  delete acceptCallbacks[requestId.toString()];
166
224
  } else if (requestFromServer.type == "error") {
167
- var requestId = requestFromServer.requestId;
168
225
  ws.close();
169
- onError(requestFromServer.message, Infinity); // Don't reconnect - an error in application level means we have done something wrong.
226
+ if (requestFromServer.code === "TOKEN_EXPIRED" || requestFromServer.code === "UNAUTHORIZED") {
227
+ log("Auth error from server, will reconnect with fresh token:", requestFromServer.message);
228
+ onError(requestFromServer.message, RECONNECT_DELAY);
229
+ } else {
230
+ onError(requestFromServer.message, Infinity);
231
+ }
170
232
  } else {
171
233
  log("unknown message", requestFromServer);
172
234
  ws.close();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Module-level registry for token getter functions, keyed by WebSocket URL.
3
+ *
4
+ * dexie-syncable serializes the `options` object into IndexedDB via
5
+ * structured clone, which cannot handle functions. This registry keeps
6
+ * the getToken function out of `options` so it survives serialization
7
+ * while remaining accessible to the sync protocol on every (re)connect.
8
+ */
9
+
10
+ type GetTokenFn = (options?: { forceRefresh?: boolean }) => Promise<string>
11
+
12
+ const registry = new Map<string, GetTokenFn>()
13
+
14
+ export function setTokenGetter(url: string, fn: GetTokenFn): void {
15
+ registry.set(url, fn)
16
+ }
17
+
18
+ export function getTokenGetter(url: string): GetTokenFn | undefined {
19
+ return registry.get(url)
20
+ }
@@ -1,12 +1,12 @@
1
1
  import { BasicStorage } from '../utils/storage'
2
2
  import { Migration } from './versionUpdater'
3
-
3
+ import { log } from '../config'
4
4
 
5
5
  export const addMigrationTimestamp: Migration = {
6
- fromVersion: '0.6.0',
6
+ fromVersion: '0.6.0',
7
7
  toVersion: '0.7.0',
8
8
  async migrate(storage: BasicStorage) {
9
- console.log('Running test migration')
9
+ log('Running migration 0.6.0 → 0.7.0')
10
10
  storage.set('test_migration', 'true')
11
11
  }
12
12
  }
@@ -1,4 +1,5 @@
1
1
  import { BasicStorage } from '../utils/storage'
2
+ import { log } from '../config'
2
3
 
3
4
  export interface VersionInfo {
4
5
  version: string
@@ -53,7 +54,7 @@ export class VersionUpdater {
53
54
  // Run migrations
54
55
  for (const migration of migrationsToRun) {
55
56
  try {
56
- console.log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`)
57
+ log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`)
57
58
  await migration.migrate(this.storage)
58
59
  } catch (error) {
59
60
  console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error)
@@ -89,18 +90,10 @@ export class VersionUpdater {
89
90
 
90
91
  private getMigrationsToRun(fromVersion: string, toVersion: string): Migration[] {
91
92
  return this.migrations.filter(migration => {
92
- // Migration should run if we're crossing the version boundary
93
- // i.e., stored version is less than migration.toVersion AND current version is >= migration.toVersion
94
93
  const storedLessThanMigrationTo = this.compareVersions(fromVersion, migration.toVersion) < 0
95
94
  const currentGreaterThanOrEqualMigrationTo = this.compareVersions(toVersion, migration.toVersion) >= 0
96
-
97
- console.log(`Checking migration ${migration.fromVersion} → ${migration.toVersion}:`)
98
- console.log(` stored ${fromVersion} < migration.to ${migration.toVersion}: ${storedLessThanMigrationTo}`)
99
- console.log(` current ${toVersion} >= migration.to ${migration.toVersion}: ${currentGreaterThanOrEqualMigrationTo}`)
100
-
101
95
  const shouldRun = storedLessThanMigrationTo && currentGreaterThanOrEqualMigrationTo
102
- console.log(` Should run: ${shouldRun}`)
103
-
96
+ log(`Migration ${migration.fromVersion} ${migration.toVersion}: shouldRun=${shouldRun}`)
104
97
  return shouldRun
105
98
  })
106
99
  }
@@ -0,0 +1,22 @@
1
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
2
+
3
+ /**
4
+ * Normalize a project_id / client_id to the canonical did:web format
5
+ * before sending it to the PDS.
6
+ *
7
+ * - did:web:... -> passthrough (already canonical)
8
+ * - bare UUID -> did:web:{adminHostname}:projects:{hex}
9
+ * - "self" -> passthrough
10
+ */
11
+ export function normalizeClientId(projectId: string, adminHostname: string = 'api.basic.tech'): string {
12
+ if (!projectId) return projectId
13
+ if (projectId === 'self') return projectId
14
+ if (projectId.startsWith('did:')) return projectId
15
+
16
+ if (UUID_RE.test(projectId)) {
17
+ const hex = projectId.replace(/-/g, '').toLowerCase()
18
+ return `did:web:${adminHostname}:projects:${hex}`
19
+ }
20
+
21
+ return projectId
22
+ }
@@ -0,0 +1,101 @@
1
+ export type ResolvedDid = {
2
+ did: string
3
+ handle?: string
4
+ didDocument: Record<string, unknown>
5
+ pdsUrl: string
6
+ authorization_endpoint: string
7
+ token_endpoint: string
8
+ userinfo_endpoint: string
9
+ }
10
+
11
+ /**
12
+ * Convert a did:web DID to the HTTPS URL where its DID document lives.
13
+ *
14
+ * did:web:pds.basic.id:did:abc123 -> https://pds.basic.id/did/abc123/did.json
15
+ * did:web:example.com -> https://example.com/.well-known/did.json
16
+ */
17
+ export function resolveDidWebUrl(did: string): string | null {
18
+ if (!did.startsWith('did:web:')) return null
19
+
20
+ const rest = did.slice(8) // strip 'did:web:'
21
+ if (!rest) return null
22
+ const parts = rest.split(':')
23
+
24
+ // Decode the hostname (first part, may contain %3A for port)
25
+ const hostname = parts[0]!.replace(/%3A/gi, ':')
26
+
27
+ if (parts.length === 1) {
28
+ return `https://${hostname}/.well-known/did.json`
29
+ }
30
+
31
+ const pathParts = parts.slice(1).map(p => decodeURIComponent(p))
32
+ return `https://${hostname}/${pathParts.join('/')}/did.json`
33
+ }
34
+
35
+ /**
36
+ * Given a DID document, extract PDS URL and discover OAuth endpoints.
37
+ */
38
+ async function resolveFromDocument(did: string, didDocument: Record<string, unknown>): Promise<ResolvedDid> {
39
+ const services = didDocument.service as Array<{ id: string; type: string; serviceEndpoint: string }> | undefined
40
+ const pdsService = services?.find(
41
+ (s) => s.id === '#basic_pds' || s.id === `${did}#basic_pds`
42
+ )
43
+ if (!pdsService) {
44
+ throw new Error(`DID document has no #basic_pds service entry`)
45
+ }
46
+ const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, '')
47
+
48
+ const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`)
49
+ if (!oauthRes.ok) {
50
+ throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`)
51
+ }
52
+ const oauth = await oauthRes.json()
53
+
54
+ return {
55
+ did,
56
+ didDocument,
57
+ pdsUrl,
58
+ authorization_endpoint: oauth.authorization_endpoint,
59
+ token_endpoint: oauth.token_endpoint,
60
+ userinfo_endpoint: oauth.userinfo_endpoint,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Fetch a DID document by DID, extract the PDS URL, and discover OAuth endpoints.
66
+ */
67
+ export async function resolveDid(did: string): Promise<ResolvedDid> {
68
+ const url = resolveDidWebUrl(did)
69
+ if (!url) {
70
+ throw new Error(`Unsupported DID method: ${did}`)
71
+ }
72
+
73
+ const didRes = await fetch(url)
74
+ if (!didRes.ok) {
75
+ throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`)
76
+ }
77
+ const didDocument = await didRes.json()
78
+
79
+ return resolveFromDocument(did, didDocument)
80
+ }
81
+
82
+ /**
83
+ * Resolve a handle (e.g. "alice.basic.id") to a DID and discover PDS + OAuth endpoints.
84
+ *
85
+ * Fetches https://{handle}/.well-known/did.json per the did:web spec.
86
+ */
87
+ export async function resolveHandle(handle: string): Promise<ResolvedDid> {
88
+ const res = await fetch(`https://${handle}/.well-known/did.json`)
89
+ if (!res.ok) {
90
+ throw new Error(`Handle resolution failed for ${handle}: ${res.status}`)
91
+ }
92
+ const didDocument = await res.json()
93
+ const did = didDocument.id as string
94
+ if (!did) {
95
+ throw new Error(`Handle response has no 'id' field`)
96
+ }
97
+
98
+ const resolved = await resolveFromDocument(did, didDocument)
99
+ resolved.handle = handle
100
+ return resolved
101
+ }
@@ -30,8 +30,6 @@ export async function getSchemaStatus(schema: any): Promise<{
30
30
  }
31
31
  })
32
32
 
33
- console.log('latestSchema', latestSchema)
34
-
35
33
  if (!latestSchema.version) {
36
34
  return {
37
35
  valid: false,
@@ -105,11 +103,12 @@ export async function validateAndCheckSchema(schema: any): Promise<{
105
103
  }
106
104
  }
107
105
 
108
- let schemaStatus = { valid: false }
106
+ let schemaStatus: { valid: boolean, status?: string, latest?: any } = { valid: false }
109
107
  if (schema.version !== 0) {
110
108
  schemaStatus = await getSchemaStatus(schema)
111
109
  log('schemaStatus', schemaStatus)
112
- } else {
110
+ } else {
111
+ schemaStatus = { valid: false, status: 'unpublished' }
113
112
  log("schema not published - at version 0")
114
113
  }
115
114
 
@@ -25,7 +25,10 @@ export const STORAGE_KEYS = {
25
25
  AUTH_STATE: 'basic_auth_state',
26
26
  REDIRECT_URI: 'basic_redirect_uri',
27
27
  SERVER_URL: 'basic_server_url',
28
- DEBUG: 'basic_debug'
28
+ PDS_ENDPOINTS: 'basic_pds_endpoints',
29
+ LAST_CONNECT_REPORT: 'basic_last_connect_report',
30
+ DEBUG: 'basic_debug',
31
+ CODE_VERIFIER: 'basic_code_verifier'
29
32
  } as const
30
33
 
31
34
  export function getCookie(name: string): string {