@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.
- package/.turbo/turbo-build.log +13 -12
- package/AUTH_IMPLEMENTATION_GUIDE.md +20 -18
- package/changelog.md +18 -2
- package/dist/index.d.mts +78 -21
- package/dist/index.d.ts +78 -21
- package/dist/index.js +1250 -779
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1246 -779
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/readme.md +17 -1
- package/src/AuthContext.tsx +202 -702
- package/src/config.ts +1 -19
- package/src/core/auth/AuthManager.ts +858 -0
- package/src/core/db/RemoteCollection.ts +30 -16
- package/src/core/db/index.ts +1 -1
- package/src/core/db/types.ts +13 -1
- package/src/index.ts +7 -1
- package/src/sync/index.ts +15 -29
- package/src/sync/syncProtocol.js +84 -22
- package/src/sync/tokenRegistry.ts +20 -0
- package/src/updater/updateMigrations.ts +3 -3
- package/src/updater/versionUpdater.ts +3 -10
- package/src/utils/normalizeClientId.ts +22 -0
- package/src/utils/resolveDid.ts +101 -0
- package/src/utils/schema.ts +3 -4
- package/src/utils/storage.ts +4 -1
|
@@ -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 -
|
|
78
|
+
// Handle 401 Unauthorized - force refresh then retry once
|
|
75
79
|
if (response.status === 401 && !isRetry) {
|
|
76
|
-
this.log('Got 401,
|
|
77
|
-
|
|
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
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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)
|
package/src/core/db/index.ts
CHANGED
|
@@ -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'
|
package/src/core/db/types.ts
CHANGED
|
@@ -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 {
|
|
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({
|
|
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, {
|
|
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
|
-
|
|
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
|
|
package/src/sync/syncProtocol.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/schema.ts
CHANGED
|
@@ -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
|
|
package/src/utils/storage.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|