@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.
@@ -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,9 @@
1
+ export default {
2
+ arrowParens: 'avoid',
3
+ bracketSameLine: false,
4
+ bracketSpacing: true,
5
+ proseWrap: 'always',
6
+ semi: false,
7
+ singleQuote: true,
8
+ trailingComma: 'none'
9
+ }
@@ -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
+ )