@basictech/react 0.7.0 → 0.8.0-beta.2
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 +24 -2
- package/dist/index.d.mts +121 -48
- package/dist/index.d.ts +121 -48
- package/dist/index.js +1996 -758
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1981 -749
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
- package/readme.md +50 -1
- package/src/AuthContext.tsx +294 -818
- package/src/config.ts +1 -19
- package/src/context.tsx +104 -0
- 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/dev/BasicDevToolbar.tsx +665 -0
- package/src/index.ts +10 -3
- 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/network.ts +68 -15
- 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
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
|
}
|
package/src/utils/network.ts
CHANGED
|
@@ -1,44 +1,97 @@
|
|
|
1
1
|
// Network utilities for Basic React package
|
|
2
|
+
import semver from 'semver'
|
|
2
3
|
import { log } from '../config'
|
|
3
|
-
import { version as
|
|
4
|
+
import { version as pkgVersion } from '../../package.json'
|
|
4
5
|
|
|
5
6
|
export function isDevelopment(debug?: boolean): boolean {
|
|
7
|
+
if (debug === true) return true
|
|
8
|
+
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'development') return true
|
|
9
|
+
if (typeof window === 'undefined' || !window.location) return false
|
|
10
|
+
const host = window.location.hostname
|
|
6
11
|
return (
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
process.env.NODE_ENV === 'development' ||
|
|
13
|
-
debug === true
|
|
12
|
+
host === 'localhost' ||
|
|
13
|
+
host === '127.0.0.1' ||
|
|
14
|
+
host.includes('localhost') ||
|
|
15
|
+
host.includes('127.0.0.1') ||
|
|
16
|
+
host.includes('.local')
|
|
14
17
|
)
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
function normalizeVersion(v: string | null | undefined): string | null {
|
|
21
|
+
if (v == null) return null
|
|
22
|
+
const t = String(v).trim()
|
|
23
|
+
return t.length ? t : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function versionsMatch(a: string, b: string): boolean {
|
|
27
|
+
const na = a.trim()
|
|
28
|
+
const nb = b.trim()
|
|
29
|
+
if (na === nb) return true
|
|
30
|
+
const va = semver.valid(na)
|
|
31
|
+
const vb = semver.valid(nb)
|
|
32
|
+
if (va && vb) return semver.eq(va, vb)
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Use npm `beta` dist-tag when the installed version is a semver prerelease whose first id is `beta`. */
|
|
37
|
+
function usesBetaDistTag(version: string): boolean {
|
|
38
|
+
const pre = semver.prerelease(version)
|
|
39
|
+
const id = pre?.[0]
|
|
40
|
+
return typeof id === 'string' && id.toLowerCase() === 'beta'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type NpmInstallMeta = {
|
|
44
|
+
'dist-tags'?: { latest?: string; beta?: string }
|
|
45
|
+
}
|
|
46
|
+
|
|
17
47
|
export async function checkForNewVersion(): Promise<{
|
|
18
48
|
hasNewVersion: boolean,
|
|
19
49
|
latestVersion: string | null,
|
|
20
50
|
currentVersion: string | null
|
|
21
51
|
}> {
|
|
22
52
|
try {
|
|
23
|
-
const
|
|
53
|
+
const currentVersion = normalizeVersion(pkgVersion)
|
|
54
|
+
if (!currentVersion) {
|
|
55
|
+
return { hasNewVersion: false, latestVersion: null, currentVersion: null }
|
|
56
|
+
}
|
|
24
57
|
|
|
25
|
-
const response = await fetch(
|
|
58
|
+
const response = await fetch('https://registry.npmjs.org/@basictech/react', {
|
|
59
|
+
headers: { Accept: 'application/vnd.npm.install-v1+json' },
|
|
60
|
+
})
|
|
26
61
|
if (!response.ok) {
|
|
27
62
|
throw new Error('Failed to fetch version from npm');
|
|
28
63
|
}
|
|
29
64
|
|
|
30
|
-
const data = await response.json()
|
|
31
|
-
const
|
|
65
|
+
const data = (await response.json()) as NpmInstallMeta
|
|
66
|
+
const distTags = data['dist-tags'] ?? {}
|
|
67
|
+
const rawRegistry =
|
|
68
|
+
usesBetaDistTag(currentVersion)
|
|
69
|
+
? distTags.beta ?? distTags.latest
|
|
70
|
+
: distTags.latest
|
|
71
|
+
const latestVersion = normalizeVersion(rawRegistry ?? null)
|
|
72
|
+
if (!latestVersion) {
|
|
73
|
+
throw new Error('Missing dist-tags from npm registry')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const same = versionsMatch(currentVersion, latestVersion)
|
|
77
|
+
|
|
78
|
+
if (!same && isDevelopment()) {
|
|
79
|
+
log('[basic] version check mismatch:', {
|
|
80
|
+
currentVersion,
|
|
81
|
+
registryVersion: latestVersion,
|
|
82
|
+
channel: usesBetaDistTag(currentVersion) ? 'beta' : 'latest',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
32
85
|
|
|
33
|
-
if (
|
|
86
|
+
if (!same) {
|
|
34
87
|
console.warn('[basic] New version available:', latestVersion, `\nrun "npm install @basictech/react@${latestVersion}" to update`);
|
|
35
88
|
}
|
|
36
|
-
if (
|
|
89
|
+
if (usesBetaDistTag(currentVersion)) {
|
|
37
90
|
log('thank you for being on basictech/react beta :)')
|
|
38
91
|
}
|
|
39
92
|
|
|
40
93
|
return {
|
|
41
|
-
hasNewVersion:
|
|
94
|
+
hasNewVersion: !same,
|
|
42
95
|
latestVersion,
|
|
43
96
|
currentVersion
|
|
44
97
|
};
|
|
@@ -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 {
|