@interop/byoe-react-template 0.1.0
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/.github/workflows/ci.yml +44 -0
- package/.github/workflows/publish.yml +38 -0
- package/CHANGELOG.md +20 -0
- package/CLAUDE.md +55 -0
- package/LICENSE.md +20 -0
- package/README.md +217 -0
- package/_spec/phase2-handoff.md +143 -0
- package/eslint.config.js +40 -0
- package/index.html +12 -0
- package/package.json +60 -0
- package/playwright.config.ts +38 -0
- package/playwright.wallet.config.ts +82 -0
- package/playwright.was.config.ts +56 -0
- package/pnpm-workspace.yaml +11 -0
- package/prettier.config.js +9 -0
- package/scripts/provision-dev-grants.ts +42 -0
- package/src/App.tsx +34 -0
- package/src/app.config.ts +73 -0
- package/src/components/AppShell.tsx +58 -0
- package/src/components/DevGate.tsx +51 -0
- package/src/dev/bootstrap.ts +54 -0
- package/src/dev/devSeed.ts +13 -0
- package/src/dev/devSync.ts +80 -0
- package/src/main.tsx +24 -0
- package/src/pages/LoginPage.tsx +76 -0
- package/src/pages/NotesPage.tsx +166 -0
- package/src/stores/notes.ts +41 -0
- package/src/vite-env.d.ts +1 -0
- package/test/browser/notes.spec.ts +82 -0
- package/test/browser-wallet/walletLogin.spec.ts +312 -0
- package/test/browser-was/globalSetup.ts +6 -0
- package/test/browser-was/globalTeardown.ts +6 -0
- package/test/browser-was/replication.spec.ts +154 -0
- package/test/browser-was/serverLifecycle.ts +92 -0
- package/test/node/notesStore.test.ts +149 -0
- package/tsconfig.dev.json +17 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline (mocked) Playwright config: the default suite. Serves the app in
|
|
3
|
+
* dev auth mode (local dev-seed store, no wallet, no WAS server) and
|
|
4
|
+
* exercises the UI against the encrypted local replica only. No sibling
|
|
5
|
+
* checkouts or servers needed; this is the tier suited to CI.
|
|
6
|
+
*
|
|
7
|
+
* Run: pnpm run test:browser
|
|
8
|
+
*/
|
|
9
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
10
|
+
|
|
11
|
+
const APP_PORT = 5173
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
testDir: './test/browser',
|
|
15
|
+
fullyParallel: true,
|
|
16
|
+
forbidOnly: !!process.env.CI,
|
|
17
|
+
retries: process.env.CI ? 2 : 0,
|
|
18
|
+
workers: process.env.CI ? 1 : undefined,
|
|
19
|
+
reporter: 'html',
|
|
20
|
+
use: {
|
|
21
|
+
baseURL: `http://localhost:${APP_PORT}`,
|
|
22
|
+
trace: 'on-first-retry'
|
|
23
|
+
},
|
|
24
|
+
projects: [
|
|
25
|
+
{
|
|
26
|
+
name: 'chromium',
|
|
27
|
+
use: { ...devices['Desktop Chrome'] }
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
webServer: {
|
|
31
|
+
command: `pnpm exec vite --port ${APP_PORT} --strictPort`,
|
|
32
|
+
url: `http://localhost:${APP_PORT}/`,
|
|
33
|
+
reuseExistingServer: !process.env.CI,
|
|
34
|
+
env: {
|
|
35
|
+
VITE_AUTH_MODE: 'dev'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet-login (Login With Wallet) Playwright config. Boots three servers:
|
|
3
|
+
* a local was-teaching-server (sibling checkout, override with
|
|
4
|
+
* WAS_SERVER_DIR), the freewallet dev server (sibling checkout, override with
|
|
5
|
+
* FREEWALLET_DIR), and this app in wallet auth mode. CHAPI is driven through
|
|
6
|
+
* the library's non-production e2e bridge (no mediator). Ports are distinct
|
|
7
|
+
* from every other suite so configs can never clash.
|
|
8
|
+
*
|
|
9
|
+
* Run: pnpm run test:browser:wallet
|
|
10
|
+
*/
|
|
11
|
+
import { resolve } from 'node:path'
|
|
12
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
13
|
+
|
|
14
|
+
const APP_PORT = 5177
|
|
15
|
+
const WALLET_PORT = 5277
|
|
16
|
+
const WAS_PORT = 3103
|
|
17
|
+
export const APP_URL = `http://localhost:${APP_PORT}`
|
|
18
|
+
export const WALLET_URL = `http://localhost:${WALLET_PORT}`
|
|
19
|
+
export const WAS_URL = `http://localhost:${WAS_PORT}`
|
|
20
|
+
|
|
21
|
+
const WALLET_DIR = resolve(process.env.FREEWALLET_DIR ?? '../freewallet')
|
|
22
|
+
const WAS_SERVER_DIR = resolve(
|
|
23
|
+
process.env.WAS_SERVER_DIR ?? '../was-teaching-server'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export default defineConfig({
|
|
27
|
+
testDir: './test/browser-wallet',
|
|
28
|
+
testMatch: /.*\.spec\.ts/,
|
|
29
|
+
fullyParallel: false,
|
|
30
|
+
workers: 1,
|
|
31
|
+
forbidOnly: !!process.env.CI,
|
|
32
|
+
retries: 0,
|
|
33
|
+
reporter: [['html', { outputFolder: 'playwright-report-wallet' }]],
|
|
34
|
+
outputDir: 'test-results-wallet',
|
|
35
|
+
timeout: 360_000,
|
|
36
|
+
use: {
|
|
37
|
+
baseURL: APP_URL,
|
|
38
|
+
trace: 'retain-on-failure'
|
|
39
|
+
},
|
|
40
|
+
projects: [
|
|
41
|
+
{
|
|
42
|
+
name: 'chromium',
|
|
43
|
+
use: { ...devices['Desktop Chrome'] }
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
webServer: [
|
|
47
|
+
{
|
|
48
|
+
// Local WAS teaching server; SERVER_URL must exactly match the URL both
|
|
49
|
+
// the wallet and this app sign zcap requests against.
|
|
50
|
+
command: 'pnpm run dev',
|
|
51
|
+
cwd: WAS_SERVER_DIR,
|
|
52
|
+
url: WAS_URL,
|
|
53
|
+
reuseExistingServer: !process.env.CI,
|
|
54
|
+
env: { PORT: String(WAS_PORT), SERVER_URL: WAS_URL },
|
|
55
|
+
timeout: 60_000
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
// freewallet dev server pointed at the local WAS server (its KMS rides
|
|
59
|
+
// on the WAS server's in-process /kms facet).
|
|
60
|
+
command: `pnpm exec vite --cors --host --port ${WALLET_PORT} --strictPort`,
|
|
61
|
+
cwd: WALLET_DIR,
|
|
62
|
+
url: WALLET_URL,
|
|
63
|
+
reuseExistingServer: false,
|
|
64
|
+
env: { VITE_WAS_SERVER_URL: WAS_URL },
|
|
65
|
+
timeout: 60_000
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
// This app in wallet auth mode.
|
|
69
|
+
command: `pnpm exec vite --port ${APP_PORT} --strictPort`,
|
|
70
|
+
url: `${APP_URL}/`,
|
|
71
|
+
reuseExistingServer: false,
|
|
72
|
+
env: {
|
|
73
|
+
VITE_AUTH_MODE: 'wallet',
|
|
74
|
+
VITE_APP_ORIGIN: APP_URL,
|
|
75
|
+
VITE_WAS_SERVER_URL: WAS_URL,
|
|
76
|
+
VITE_WAS_SYNC_RETRY_MS: '1500',
|
|
77
|
+
VITE_WAS_SYNC_POLL_MS: '1500'
|
|
78
|
+
},
|
|
79
|
+
timeout: 60_000
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAS-backed Playwright config: real replication against a local
|
|
3
|
+
* was-teaching-server booted from a sibling checkout (override with
|
|
4
|
+
* WAS_SERVER_DIR), with dev grants provisioned in globalSetup. The app runs
|
|
5
|
+
* in dev auth mode with dev-sync on, on a dedicated port so it never clashes
|
|
6
|
+
* with the offline suite's dev server. Tests are serialized (a single shared
|
|
7
|
+
* Space).
|
|
8
|
+
*
|
|
9
|
+
* Run: pnpm run test:browser:was
|
|
10
|
+
*/
|
|
11
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
12
|
+
|
|
13
|
+
const APP_PORT = 5174
|
|
14
|
+
export const WAS_PORT = Number(process.env.WAS_E2E_PORT ?? 3102)
|
|
15
|
+
export const WAS_URL = `http://localhost:${WAS_PORT}`
|
|
16
|
+
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
testDir: './test/browser-was',
|
|
19
|
+
testMatch: /.*\.spec\.ts/,
|
|
20
|
+
// One shared WAS Space across tests; serialize to keep assertions clean.
|
|
21
|
+
fullyParallel: false,
|
|
22
|
+
workers: 1,
|
|
23
|
+
forbidOnly: !!process.env.CI,
|
|
24
|
+
retries: process.env.CI ? 1 : 0,
|
|
25
|
+
reporter: [['html', { outputFolder: 'playwright-report-was' }]],
|
|
26
|
+
outputDir: 'test-results-was',
|
|
27
|
+
timeout: 60_000,
|
|
28
|
+
globalSetup: './test/browser-was/globalSetup.ts',
|
|
29
|
+
globalTeardown: './test/browser-was/globalTeardown.ts',
|
|
30
|
+
use: {
|
|
31
|
+
baseURL: `http://localhost:${APP_PORT}`,
|
|
32
|
+
trace: 'on-first-retry'
|
|
33
|
+
},
|
|
34
|
+
projects: [
|
|
35
|
+
{
|
|
36
|
+
name: 'chromium',
|
|
37
|
+
use: { ...devices['Desktop Chrome'] }
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
webServer: {
|
|
41
|
+
command: `pnpm exec vite --port ${APP_PORT} --strictPort`,
|
|
42
|
+
url: `http://localhost:${APP_PORT}/`,
|
|
43
|
+
reuseExistingServer: false,
|
|
44
|
+
timeout: 60_000,
|
|
45
|
+
env: {
|
|
46
|
+
VITE_AUTH_MODE: 'dev',
|
|
47
|
+
VITE_WAS_DEV_SYNC: 'true',
|
|
48
|
+
// Must EXACTLY equal the server's SERVER_URL (zcap invocation targets).
|
|
49
|
+
VITE_WAS_SERVER_URL: WAS_URL,
|
|
50
|
+
// Snappier backoff + change-feed polling so multi-device convergence
|
|
51
|
+
// and offline/online recovery land within the test timeouts.
|
|
52
|
+
VITE_WAS_SYNC_RETRY_MS: '1500',
|
|
53
|
+
VITE_WAS_SYNC_POLL_MS: '1500'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
allowBuilds:
|
|
2
|
+
core-js: false
|
|
3
|
+
esbuild: true
|
|
4
|
+
minimumReleaseAgeExclude:
|
|
5
|
+
- '@interop/data-integrity-core@8.2.0'
|
|
6
|
+
- '@interop/storage-core@0.3.3 || 0.3.4'
|
|
7
|
+
- '@interop/was-client@0.13.3 || 0.14.5'
|
|
8
|
+
- '@interop/was-react@0.1.2 || 0.1.3'
|
|
9
|
+
- '@interop/x25519-key-agreement-key@5.1.2 || 5.2.0'
|
|
10
|
+
- '@interop/edv-client@17.6.1'
|
|
11
|
+
- '@interop/minimal-cipher@7.6.1'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev grant provisioning (CHAPI bypassed): against a running
|
|
3
|
+
* was-teaching-server, creates a dev Space plus the app's collections, and
|
|
4
|
+
* delegates a per-collection read/write zcap to the app DID derived from the
|
|
5
|
+
* dev seed. The signed grants land in a git-ignored JSON file the app loads
|
|
6
|
+
* in dev-sync mode.
|
|
7
|
+
*
|
|
8
|
+
* Run (with the server already up on $SERVER_URL):
|
|
9
|
+
* pnpm run provision:dev
|
|
10
|
+
*
|
|
11
|
+
* Reads (all optional):
|
|
12
|
+
* SERVER_URL WAS base URL (default http://localhost:3002)
|
|
13
|
+
* DEV_GRANTS_OUT output path (default <repo>/public/dev-grants.local.json)
|
|
14
|
+
*/
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { provisionDevGrants } from '@interop/was-react/dev'
|
|
18
|
+
import { COLLECTIONS } from '../src/app.config.ts'
|
|
19
|
+
import { DEV_SEED } from '../src/dev/devSeed.ts'
|
|
20
|
+
|
|
21
|
+
const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:3002'
|
|
22
|
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
23
|
+
const OUT_PATH =
|
|
24
|
+
process.env.DEV_GRANTS_OUT ??
|
|
25
|
+
join(repoRoot, 'public', 'dev-grants.local.json')
|
|
26
|
+
|
|
27
|
+
async function main(): Promise<void> {
|
|
28
|
+
const result = await provisionDevGrants({
|
|
29
|
+
serverUrl: SERVER_URL,
|
|
30
|
+
seed: DEV_SEED,
|
|
31
|
+
collections: COLLECTIONS.map(collection => collection.id),
|
|
32
|
+
spaceName: 'BYOE Notes (dev)',
|
|
33
|
+
outFile: OUT_PATH,
|
|
34
|
+
log: console.log
|
|
35
|
+
})
|
|
36
|
+
console.log(`\nWrote ${result.grants.length} grants to ${OUT_PATH}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main().catch(err => {
|
|
40
|
+
console.error('Provisioning failed:', err)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
})
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App root: CssBaseline, the HashRouter, and the route table. The login page
|
|
3
|
+
* sits outside the gate; the notes page renders behind the wallet-mode
|
|
4
|
+
* `ProtectedRoute` (from `@interop/was-react/mui`) or the dev-mode `DevGate`,
|
|
5
|
+
* inside the `AppShell` layout.
|
|
6
|
+
*/
|
|
7
|
+
import { HashRouter, Route, Routes } from 'react-router'
|
|
8
|
+
import { CssBaseline } from '@mui/material'
|
|
9
|
+
import { ProtectedRoute } from '@interop/was-react/mui'
|
|
10
|
+
import { AUTH_MODE } from '@/app.config'
|
|
11
|
+
import { AppShell } from '@/components/AppShell'
|
|
12
|
+
import { DevGate } from '@/components/DevGate'
|
|
13
|
+
import { LoginPage } from '@/pages/LoginPage'
|
|
14
|
+
import { NotesPage } from '@/pages/NotesPage'
|
|
15
|
+
|
|
16
|
+
export function App() {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<CssBaseline />
|
|
20
|
+
<HashRouter>
|
|
21
|
+
<Routes>
|
|
22
|
+
<Route path="/login" element={<LoginPage />} />
|
|
23
|
+
<Route
|
|
24
|
+
element={AUTH_MODE === 'wallet' ? <ProtectedRoute /> : <DevGate />}
|
|
25
|
+
>
|
|
26
|
+
<Route element={<AppShell />}>
|
|
27
|
+
<Route index element={<NotesPage />} />
|
|
28
|
+
</Route>
|
|
29
|
+
</Route>
|
|
30
|
+
</Routes>
|
|
31
|
+
</HashRouter>
|
|
32
|
+
</>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application configuration: environment-variable exports, the collection
|
|
3
|
+
* registry, and the one `WasAppConfig` the library consumes.
|
|
4
|
+
*
|
|
5
|
+
* When renaming this template into a real app, this file is the first stop:
|
|
6
|
+
* app name, credential type/vocab, and the collection list all live here.
|
|
7
|
+
*/
|
|
8
|
+
import type { WasAppConfig, WasCollectionConfig } from '@interop/was-react'
|
|
9
|
+
|
|
10
|
+
// Vite injects `import.meta.env` in the browser build; a plain Node context
|
|
11
|
+
// (the dev provisioning script, or a bare `tsx` import) has no such object, so
|
|
12
|
+
// fall back to an empty record rather than throwing on property access.
|
|
13
|
+
const env: Record<string, string | undefined> =
|
|
14
|
+
(import.meta.env as Record<string, string | undefined> | undefined) ?? {}
|
|
15
|
+
|
|
16
|
+
// This app's own origin: the CHAPI anti-phishing origin binding on the app-key
|
|
17
|
+
// credential. Must match the URL the app is actually served from.
|
|
18
|
+
export const APP_ORIGIN = env.VITE_APP_ORIGIN || 'http://localhost:5173'
|
|
19
|
+
|
|
20
|
+
// Auth mode: 'wallet' (default) gates the app behind Login With Wallet
|
|
21
|
+
// (CHAPI); 'dev' boots straight into a local dev-seed store with no login
|
|
22
|
+
// gate (offline-first, optionally dev-syncing under VITE_WAS_DEV_SYNC).
|
|
23
|
+
export const AUTH_MODE: 'dev' | 'wallet' =
|
|
24
|
+
env.VITE_AUTH_MODE === 'dev' ? 'dev' : 'wallet'
|
|
25
|
+
|
|
26
|
+
// Remote WAS server URL. In wallet mode this is the expected host of every
|
|
27
|
+
// granted zcap's invocationTarget (grants pointing anywhere else are rejected
|
|
28
|
+
// at login).
|
|
29
|
+
export const WAS_SERVER_URL = env.VITE_WAS_SERVER_URL || 'http://localhost:3002'
|
|
30
|
+
|
|
31
|
+
// Dev-sync mode (CHAPI bypassed; dev auth mode only): when truthy, the app
|
|
32
|
+
// loads a locally provisioned grants file and replicates to a running
|
|
33
|
+
// was-teaching-server using the dev seed's delegated zcaps. Off by default.
|
|
34
|
+
export const WAS_DEV_SYNC =
|
|
35
|
+
env.VITE_WAS_DEV_SYNC === 'true' || env.VITE_WAS_DEV_SYNC === '1'
|
|
36
|
+
|
|
37
|
+
// URL the app fetches the dev grants JSON from (a git-ignored file written
|
|
38
|
+
// into `public/` by `pnpm run provision:dev`; Vite serves `public/` at root).
|
|
39
|
+
export const WAS_DEV_GRANTS_URL =
|
|
40
|
+
env.VITE_WAS_DEV_GRANTS_URL || '/dev-grants.local.json'
|
|
41
|
+
|
|
42
|
+
// Replication tuning (optional). Undefined leaves the library defaults.
|
|
43
|
+
export const WAS_SYNC_RETRY_MS: number | undefined = env.VITE_WAS_SYNC_RETRY_MS
|
|
44
|
+
? Number(env.VITE_WAS_SYNC_RETRY_MS)
|
|
45
|
+
: undefined
|
|
46
|
+
export const WAS_SYNC_POLL_MS: number | undefined = env.VITE_WAS_SYNC_POLL_MS
|
|
47
|
+
? Number(env.VITE_WAS_SYNC_POLL_MS)
|
|
48
|
+
: undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The storage collections: each logical `key` (the app-side / RxDB collection
|
|
52
|
+
* handle) mapped to its WAS collection `id` (a deliberately unprefixed,
|
|
53
|
+
* generic name shared across interoperable apps). This template has one.
|
|
54
|
+
*/
|
|
55
|
+
export const COLLECTIONS: WasCollectionConfig[] = [
|
|
56
|
+
{ key: 'notes', id: 'notes' }
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** The app-wide was-react configuration. */
|
|
60
|
+
export const appConfig: WasAppConfig = {
|
|
61
|
+
appName: 'BYOE Notes',
|
|
62
|
+
appOrigin: APP_ORIGIN,
|
|
63
|
+
wasServerUrl: WAS_SERVER_URL,
|
|
64
|
+
collections: COLLECTIONS,
|
|
65
|
+
credential: {
|
|
66
|
+
credentialType: 'ByoeNotesAppKey',
|
|
67
|
+
vocabBase: 'urn:byoe-notes:vocab#'
|
|
68
|
+
},
|
|
69
|
+
sync: {
|
|
70
|
+
...(WAS_SYNC_RETRY_MS !== undefined && { retryMs: WAS_SYNC_RETRY_MS }),
|
|
71
|
+
...(WAS_SYNC_POLL_MS !== undefined && { pollMs: WAS_SYNC_POLL_MS })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app layout: a top bar with the app name, the library's `SyncStatusChip`
|
|
3
|
+
* (offline / syncing / synced / error rollup), and a logout button (wallet
|
|
4
|
+
* mode only), with the `ReconnectBanner` (shown when granted access nears
|
|
5
|
+
* expiry) above the routed page content.
|
|
6
|
+
*/
|
|
7
|
+
import { Outlet, useNavigate } from 'react-router'
|
|
8
|
+
import {
|
|
9
|
+
AppBar,
|
|
10
|
+
Box,
|
|
11
|
+
Container,
|
|
12
|
+
IconButton,
|
|
13
|
+
Toolbar,
|
|
14
|
+
Tooltip,
|
|
15
|
+
Typography
|
|
16
|
+
} from '@mui/material'
|
|
17
|
+
import LogoutIcon from '@mui/icons-material/Logout'
|
|
18
|
+
import { useLogout } from '@interop/was-react'
|
|
19
|
+
import { ReconnectBanner, SyncStatusChip } from '@interop/was-react/mui'
|
|
20
|
+
import { AUTH_MODE } from '@/app.config'
|
|
21
|
+
|
|
22
|
+
export function AppShell() {
|
|
23
|
+
const logout = useLogout()
|
|
24
|
+
const navigate = useNavigate()
|
|
25
|
+
|
|
26
|
+
async function handleLogout() {
|
|
27
|
+
await logout()
|
|
28
|
+
navigate('/login')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Box sx={{ minHeight: '100vh' }}>
|
|
33
|
+
<AppBar position="static">
|
|
34
|
+
<Toolbar sx={{ gap: 2 }}>
|
|
35
|
+
<Typography variant="h6" component="h1" sx={{ flexGrow: 1 }}>
|
|
36
|
+
BYOE Notes
|
|
37
|
+
</Typography>
|
|
38
|
+
<SyncStatusChip />
|
|
39
|
+
{AUTH_MODE === 'wallet' && (
|
|
40
|
+
<Tooltip title="Log out">
|
|
41
|
+
<IconButton
|
|
42
|
+
color="inherit"
|
|
43
|
+
aria-label="log out"
|
|
44
|
+
onClick={() => void handleLogout()}
|
|
45
|
+
>
|
|
46
|
+
<LogoutIcon />
|
|
47
|
+
</IconButton>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
)}
|
|
50
|
+
</Toolbar>
|
|
51
|
+
</AppBar>
|
|
52
|
+
<ReconnectBanner />
|
|
53
|
+
<Container maxWidth="sm" sx={{ py: 4 }}>
|
|
54
|
+
<Outlet />
|
|
55
|
+
</Container>
|
|
56
|
+
</Box>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode router gate: kicks off `initApp` (local dev seed, no login) and
|
|
3
|
+
* waits for hydration before rendering the routed pages. The wallet-mode
|
|
4
|
+
* equivalent is the library's `ProtectedRoute`; the route table picks one by
|
|
5
|
+
* `AUTH_MODE`.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect } from 'react'
|
|
8
|
+
import { Outlet } from 'react-router'
|
|
9
|
+
import { Alert, Box, CircularProgress, Typography } from '@mui/material'
|
|
10
|
+
import { useAppReady } from '@interop/was-react'
|
|
11
|
+
import { initApp } from '@/dev/bootstrap'
|
|
12
|
+
|
|
13
|
+
export function DevGate() {
|
|
14
|
+
const ready = useAppReady(s => s.ready)
|
|
15
|
+
const error = useAppReady(s => s.error)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
void initApp()
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
if (error) {
|
|
22
|
+
return (
|
|
23
|
+
<Box sx={{ p: 4 }}>
|
|
24
|
+
<Alert severity="error" data-testid="bootstrap-error">
|
|
25
|
+
Failed to open local storage: {error}
|
|
26
|
+
</Alert>
|
|
27
|
+
</Box>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!ready) {
|
|
32
|
+
return (
|
|
33
|
+
<Box
|
|
34
|
+
sx={{
|
|
35
|
+
display: 'flex',
|
|
36
|
+
flexDirection: 'column',
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'center',
|
|
39
|
+
gap: 2,
|
|
40
|
+
minHeight: '60vh'
|
|
41
|
+
}}
|
|
42
|
+
data-testid="bootstrap-loading"
|
|
43
|
+
>
|
|
44
|
+
<CircularProgress />
|
|
45
|
+
<Typography color="text.secondary">Opening your storage...</Typography>
|
|
46
|
+
</Box>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return <Outlet />
|
|
51
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode bootstrap: opens the encrypted local store from the dev seed and
|
|
3
|
+
* hydrates the entity stores from it, then flips the library's `useAppReady`
|
|
4
|
+
* gate the router waits on. Idempotent -- concurrent callers share one
|
|
5
|
+
* in-flight promise.
|
|
6
|
+
*
|
|
7
|
+
* Wallet mode does not use `initApp`; there the library's auth store owns the
|
|
8
|
+
* open/hydrate ordering (seed from the wallet session) and drives the same
|
|
9
|
+
* `useAppReady` gate.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
hasStore,
|
|
13
|
+
hydrateAll,
|
|
14
|
+
LocalStore,
|
|
15
|
+
setLocalStore,
|
|
16
|
+
useAppReady
|
|
17
|
+
} from '@interop/was-react'
|
|
18
|
+
import { appConfig, WAS_DEV_SYNC } from '@/app.config'
|
|
19
|
+
import { DEV_SEED } from '@/dev/devSeed'
|
|
20
|
+
import { registry } from '@/stores/notes'
|
|
21
|
+
|
|
22
|
+
let inFlight: Promise<void> | null = null
|
|
23
|
+
|
|
24
|
+
/** Open the store (once) from the dev seed and hydrate every collection. */
|
|
25
|
+
export function initApp(): Promise<void> {
|
|
26
|
+
if (inFlight) {
|
|
27
|
+
return inFlight
|
|
28
|
+
}
|
|
29
|
+
inFlight = (async () => {
|
|
30
|
+
try {
|
|
31
|
+
if (!hasStore()) {
|
|
32
|
+
const store = await LocalStore.init({
|
|
33
|
+
seed: DEV_SEED,
|
|
34
|
+
collections: appConfig.collections
|
|
35
|
+
})
|
|
36
|
+
setLocalStore(store)
|
|
37
|
+
}
|
|
38
|
+
await hydrateAll(registry)
|
|
39
|
+
useAppReady.getState().setReady()
|
|
40
|
+
// Dev-sync: start WAS replication in the background AFTER the UI gate
|
|
41
|
+
// opens, so a missing/unreachable server never blocks first paint.
|
|
42
|
+
if (WAS_DEV_SYNC) {
|
|
43
|
+
void import('@/dev/devSync')
|
|
44
|
+
.then(({ startDevSync }) => startDevSync())
|
|
45
|
+
.catch(err => console.warn('Dev-sync failed to start:', err))
|
|
46
|
+
}
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
49
|
+
useAppReady.getState().setError(message)
|
|
50
|
+
throw cause
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
return inFlight
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed dev-mode master seed (32 bytes). Dev auth mode derives the app's
|
|
3
|
+
* identity and vault keys from this seed instead of a wallet-held one, so a
|
|
4
|
+
* dev session is stable across restarts and provisioning runs.
|
|
5
|
+
*
|
|
6
|
+
* NEVER use this seed (or dev auth mode) in production: it is public, so
|
|
7
|
+
* anything encrypted under it is readable by anyone.
|
|
8
|
+
*/
|
|
9
|
+
export const DEV_SEED = new Uint8Array([
|
|
10
|
+
0x62, 0x79, 0x6f, 0x65, 0x2d, 0x6e, 0x6f, 0x74, 0x65, 0x73, 0x2d, 0x64, 0x65,
|
|
11
|
+
0x76, 0x2d, 0x73, 0x65, 0x65, 0x64, 0x2d, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35,
|
|
12
|
+
0x36, 0x37, 0x38, 0x39, 0x41, 0x42
|
|
13
|
+
])
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-sync bootstrap (CHAPI bypassed): when dev-sync is on, the app fetches a
|
|
3
|
+
* locally provisioned grants file, rebuilds the delegated remote store from
|
|
4
|
+
* those zcaps and the dev seed's ZcapClient, and starts background
|
|
5
|
+
* replication. This stands in for the Login-With-Wallet flow: there the
|
|
6
|
+
* grants arrive over CHAPI; here they are minted by `pnpm run provision:dev`
|
|
7
|
+
* against a running was-teaching-server.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
createSyncController,
|
|
11
|
+
deriveIdentity,
|
|
12
|
+
parseGrants,
|
|
13
|
+
patchFromChange,
|
|
14
|
+
requireStore,
|
|
15
|
+
startWasSync,
|
|
16
|
+
type IZcap
|
|
17
|
+
} from '@interop/was-react'
|
|
18
|
+
import { appConfig, WAS_DEV_GRANTS_URL } from '@/app.config'
|
|
19
|
+
import { DEV_SEED } from '@/dev/devSeed'
|
|
20
|
+
import { registry } from '@/stores/notes'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetches and parses the dev grants file. Returns `null` (with a warning)
|
|
24
|
+
* when it is missing or malformed, so a not-yet-provisioned dev environment
|
|
25
|
+
* degrades to offline-only rather than erroring the whole bootstrap.
|
|
26
|
+
*/
|
|
27
|
+
async function loadDevGrants(): Promise<IZcap[] | null> {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(WAS_DEV_GRANTS_URL, { cache: 'no-store' })
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
console.warn(
|
|
32
|
+
`Dev grants not available at "${WAS_DEV_GRANTS_URL}" (status ${response.status}); running offline.`
|
|
33
|
+
)
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
const body = (await response.json()) as { grants?: IZcap[] } | IZcap[]
|
|
37
|
+
const grants = Array.isArray(body) ? body : body.grants
|
|
38
|
+
if (!grants || grants.length === 0) {
|
|
39
|
+
console.warn('Dev grants file has no grants; running offline.')
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
return grants
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn('Failed to load dev grants; running offline.', err)
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let started = false
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts dev-sync: loads grants, builds the remote store, and starts
|
|
53
|
+
* replication with reactive store patching. Idempotent and best-effort --
|
|
54
|
+
* any failure leaves the app in offline mode.
|
|
55
|
+
*/
|
|
56
|
+
export async function startDevSync(): Promise<void> {
|
|
57
|
+
if (started) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const grants = await loadDevGrants()
|
|
61
|
+
if (!grants) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
started = true
|
|
65
|
+
|
|
66
|
+
const parsed = parseGrants(grants)
|
|
67
|
+
const { zcapClient } = await deriveIdentity({ seed: DEV_SEED })
|
|
68
|
+
await startWasSync({
|
|
69
|
+
parsed,
|
|
70
|
+
zcapClient,
|
|
71
|
+
localStore: requireStore(),
|
|
72
|
+
syncController: createSyncController({
|
|
73
|
+
collections: appConfig.collections,
|
|
74
|
+
...(appConfig.sync && { sync: appConfig.sync })
|
|
75
|
+
}),
|
|
76
|
+
onRemoteChange: (key, event) => {
|
|
77
|
+
void patchFromChange(registry, key, event)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point: mounts the app inside `WasSessionProvider`, which builds the
|
|
3
|
+
* session/auth store once from the app config and store registry. The
|
|
4
|
+
* provider sits ABOVE the router so every route (including /login) can reach
|
|
5
|
+
* the session hooks.
|
|
6
|
+
*/
|
|
7
|
+
import { StrictMode } from 'react'
|
|
8
|
+
import { createRoot } from 'react-dom/client'
|
|
9
|
+
import { WasSessionProvider } from '@interop/was-react'
|
|
10
|
+
import { appConfig } from '@/app.config'
|
|
11
|
+
import { registry } from '@/stores/notes'
|
|
12
|
+
import { App } from '@/App'
|
|
13
|
+
|
|
14
|
+
const container = document.getElementById('root')
|
|
15
|
+
if (!container) {
|
|
16
|
+
throw new Error('Root container #root not found.')
|
|
17
|
+
}
|
|
18
|
+
createRoot(container).render(
|
|
19
|
+
<StrictMode>
|
|
20
|
+
<WasSessionProvider config={appConfig} registry={registry}>
|
|
21
|
+
<App />
|
|
22
|
+
</WasSessionProvider>
|
|
23
|
+
</StrictMode>
|
|
24
|
+
)
|