@wtfalch/desktop 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,258 @@
1
- # Temporary Holding Version
1
+ # @wtfalch/desktop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared Electron shell for Boule, Archon and Agora. An app keeps
4
+ its own packaging (electron-builder config, icons, signing) and calls one entry
5
+ point, `startShell(config)`. The shell finds the app's address, signs the person
6
+ in through the system browser, shows the app in a locked window, and updates
7
+ itself.
8
+
9
+ ```
10
+ pnpm install
11
+ pnpm test # node --test test/*.test.mjs
12
+ pnpm test:electron # real Electron against a local fixture app
13
+ pnpm lint
14
+ ```
15
+
16
+ ## Wiring an app
17
+
18
+ The app's `desktop/package.json` depends on `@wtfalch/desktop` (exact version)
19
+ and `electron`, and electron-builder packs it from `node_modules`. The app's
20
+ `main.mjs`:
21
+
22
+ ```js
23
+ import { cookieHandoff, startShell } from '@wtfalch/desktop';
24
+
25
+ await startShell({
26
+ appName: 'Boule', dataDirName: 'boule', partitionPrefix: 'persist:boule-',
27
+ origin: { kind: 'discover', wellKnownPath: '/.well-known/boule.json', hostPrefix: 'boule', sharedHost: { domain: 'oikos.host' } },
28
+ landingPath: '/dashboard', accessProbePath: null, feedUrl: null, updateChannel: null,
29
+ devOriginEnv: 'BOULE_URL', devFeedEnv: 'BOULE_UPDATE_FEED_URL',
30
+ permissions: { notifications: false, media: false }, navigationLinks: null,
31
+ signIn: cookieHandoff({ scheme: 'boule' }),
32
+ });
33
+ ```
34
+
35
+ A fixed-origin app (Archon, behind Cloudflare Access) differs in three values:
36
+ `origin: { kind: 'fixed', origin: 'https://console.example.com' }`,
37
+ `accessProbePath: '/api/health'` and its own names. The app's `check` script is
38
+ `await runCheck(config, process.argv[2])` (exported), which walks the start of
39
+ the sign-in against a deployed origin and names what is wrong.
40
+
41
+ The app's `package.json` sets `"main": "main.mjs"`, the electron-builder
42
+ `protocols` (scheme and bundle id are fixed once the first build ships) and
43
+ `files` including `node_modules/@wtfalch/desktop/**`. `welcomeDir` is the
44
+ absolute path of the welcome assets, for a packaging test that checks they ship.
45
+
46
+ ## The config
47
+
48
+ Every value is required. The package has no defaults, and `validateConfig`
49
+ refuses a missing or malformed one by name (`src/config.mjs`).
50
+
51
+ | Key | Type | Meaning |
52
+ | --- | --- | --- |
53
+ | `appName` | string | Window title, menu label, welcome page text, `app.setName`. |
54
+ | `dataDirName` | string | Folder under `appData` holding `company.json` and `window-state.json`. |
55
+ | `partitionPrefix` | string, starts `persist:` | The company's partition is this plus the hex of its origin. |
56
+ | `origin` | `OriginConfig` | How the address is found, below. |
57
+ | `landingPath` | string, starts `/` | Loaded after sign-in and on every launch. |
58
+ | `accessProbePath` | string or null | Behind Cloudflare Access: a path the app answers without a redirect (Archon: `/api/health`). The shell probes it to tell whether Access let the person in, and passes Access in the window first (below). `null`: not behind Access. |
59
+ | `feedUrl` | https string or null | Update feed set in code. Null uses the `app-update.yml` electron-builder bakes in. |
60
+ | `updateChannel` | string or null | electron-updater channel (`<channel>-mac.yml` on the same feed). Never assigns a falsy channel; `allowDowngrade` is always false. |
61
+ | `devOriginEnv` | string | Env var that overrides the origin. Unpackaged runs only. |
62
+ | `devFeedEnv` | string | Env var that overrides the feed. Unpackaged runs only. |
63
+ | `permissions` | `{ notifications, media }` | Granted to the company origin only; everything else is denied. `media` is camera and microphone. |
64
+ | `navigationLinks` | `{ pathPrefix }` or null | `<scheme>://<path>` and same-origin https links under `pathPrefix` open that page (queued when they arrive before the window). Null: such links are ignored. |
65
+ | `signIn` | `SignInStrategy` | How sign-in works, below. |
66
+
67
+ ```ts
68
+ type OriginConfig =
69
+ | { kind: 'discover'; wellKnownPath: string; hostPrefix: string;
70
+ sharedHost: { domain: string } | null;
71
+ validate?: (json: unknown, origin: string) => { name: string } | undefined }
72
+ | { kind: 'fixed'; origin: string };
73
+ ```
74
+
75
+ ### Origin: discover or fixed
76
+
77
+ `discover` is Boule's email discovery. The email's domain gives
78
+ `https://<hostPrefix>.<domain>`; an email on `<slug>.<sharedHost.domain>` gives
79
+ `https://<slug>-<hostPrefix>.<sharedHost.domain>`. The shell fetches
80
+ `<origin><wellKnownPath>` with `redirect: 'error'`, a timeout and a 16 KB cap.
81
+ By default it must be JSON `{ "name": "<1 to 100 characters>" }`; an app with a
82
+ richer file passes `validate` (it gets the parsed JSON and the origin, returns
83
+ `{ name }` or undefined, and may add fields). On "not found" the page asks for
84
+ the address by hand.
85
+
86
+ `fixed` has no email screen and no discovery: the app signs in straight away
87
+ against that one origin, and the company name is `appName`. `devOriginEnv`
88
+ may point an unpackaged run at a local origin.
89
+
90
+ Origins are https only. Plain http is accepted for `localhost` and `127.0.0.1`
91
+ in an unpackaged run. `company.json` stores `{ origin, name }`;
92
+ a corrupt file means no company.
93
+
94
+ ### Origin behind Cloudflare Access
95
+
96
+ Cloudflare Access answers a request without a `CF_Authorization` cookie with a
97
+ redirect to `*.cloudflareaccess.com`. The cookie lives in the company partition,
98
+ so:
99
+
100
+ 1. Every request the shell makes to the origin after the window exists (redeem,
101
+ logout, the Access probe) goes through that partition's `session.fetch` with
102
+ `credentials: 'include'` (`partitionFetcher`). Global `fetch` is never used
103
+ for them; `DesktopLogin` has no default fetcher.
104
+ 2. With `accessProbePath: '/api/health'`, before the system-browser sign-in starts (and
105
+ before a stored company loads) the shell asks the origin through the
106
+ partition with `redirect: 'manual'`, deciding from status and `Location`
107
+ only (Electron's `session.fetch` reports a wrong `response.url`, so it is never
108
+ read). A non-3xx answer, or a 3xx back to the origin itself, is a pass; a 3xx
109
+ to any other host (Access, or an identity provider) is not. A network failure
110
+ is not an Access answer: the window shows the "Could not reach" screen with
111
+ Try again. When Access wants a login, the window shows the origin so the person passes
112
+ Access there, and the shell polls every 1.5 s, up to 10 minutes, until a
113
+ request is answered by the origin itself. Then the handoff starts. Cancel
114
+ works during this wait. While it lasts the window may follow https redirects
115
+ anywhere (the identity provider); popups stay blocked.
116
+ 3. `redirect: 'error'` for the redeem is unchanged. The redeem is sent with
117
+ `credentials: 'include'` so `CF_Authorization` goes along; the server is
118
+ trusted for any `Set-Cookie` on its redeem answer. Cancelling the Access wait
119
+ clears the partition's storage and cache.
120
+
121
+ Signing out clears the partition, `CF_Authorization` included, so the next
122
+ sign-in passes Access again.
123
+
124
+ ## Sign-in strategies
125
+
126
+ `config.signIn` is the one place an app's sign-in lives. The package ships
127
+ `cookieHandoff({ scheme })`. Any object of this shape works:
128
+
129
+ ```ts
130
+ interface SignInStrategy {
131
+ scheme: string; // URL scheme registered with the OS
132
+ createLogin(ctx: Context): Login; // one per sign-in attempt
133
+ logout(ctx: Context): Promise<void>; // best effort; the shell clears the partition afterwards
134
+ check?(origin: string): Promise<{ what: string; fix: string }[]>; // for runCheck
135
+ preload?: string; // absolute path, preload for the company page
136
+ handlers?: Record<string, (ctx: Context, ...args) => unknown>; // ipcMain channels, company page top frame only
137
+ }
138
+ interface Context {
139
+ origin: string;
140
+ partition: Electron.Session;
141
+ fetcher: (url, init) => Promise<Response>; // the partition's session.fetch
142
+ openBrowser: (url: string) => Promise<void>;
143
+ userDataDir: string; // the app's data folder
144
+ safeStorage: Electron.SafeStorage;
145
+ }
146
+ interface Login {
147
+ start(opts: { email?: string; selectAccount: boolean }): Promise<void>; // resolves when signed in; rejects if cancelled, expired or refused
148
+ cancel(): void;
149
+ readonly active: boolean;
150
+ readonly code?: string; // pairing code the welcome page shows, if any
151
+ handleDeepLink(url: string): boolean; // true if the link was ours and consumed
152
+ }
153
+ ```
154
+
155
+ The shell owns the window, the partition (clearing it, the crash-safe
156
+ `pendingClear` marker), the company record and the welcome screen. The strategy
157
+ owns what is sent to the server and where a credential is kept.
158
+
159
+ ### Mapping Agora's desktop-auth onto it
160
+
161
+ Agora (`apps/desktop`, `client.mjs`, `vault.mjs`) signs in with a bearer token and
162
+ a vault instead of cookies. It plugs in without changes to the package:
163
+
164
+ | Strategy part | Agora's code |
165
+ | --- | --- |
166
+ | `scheme` | `'agora'` |
167
+ | `createLogin(ctx)` | builds `Vault(join(ctx.userDataDir, 'session-<hex origin>'), ctx.safeStorage)` and `DesktopSession(ctx.origin, ctx.fetcher, ctx.partition, vault)`, calls `held.restore()`, and returns an adapter over `DesktopLogin(ctx.origin, held, ctx.openBrowser)`: `start` calls `login.start()`, `cancel` calls `login.cancel()`, `active` is true while `start` runs, `handleDeepLink` is `() => false` (Agora polls `redeem`; no link ends its sign-in), no `code`. |
168
+ | `logout(ctx)` | `held.restore(); await held.logout()` (revokes the token, clears the vault). |
169
+ | `check(origin)` | the body of Agora's `check-origin.mjs`, returning its problem list. |
170
+ | `preload`, `handlers` | `preload.cjs` and the `agora:session`, `agora:sign-in`, `agora:sign-out`, `agora:version`, `agora:check-for-updates` channels (the page drives sign-in through `window.wtfalchDesktop`). The badge channel is app-specific and goes here too. |
171
+ | `config.origin.validate` | Agora's `parseConfig` plus the issuer and mail-host trust checks. |
172
+ | `config.permissions` | `{ notifications: true, media: true }` once the open camera and microphone work lands. |
173
+ | `config.navigationLinks` | `{ pathPrefix: '/h' }` |
174
+
175
+ Not covered, and left for Agora's migration: its separate welcome window with a
176
+ confirm step and `restartAs` relaunch (the package's welcome is in-window and
177
+ needs no relaunch), the dock-icon picker, and `app.setBadgeCount`. These need
178
+ either an app-supplied hook or an Agora-side wrapper, and are not generic enough
179
+ to decide here. `handlers` must be given a way to start a sign-in from the page
180
+ before Agora's flow fits exactly; `ctx` does not carry one yet.
181
+
182
+ ## The handoff contract (cookie handoff: what the server provides)
183
+
184
+ 1. The shell makes a 32-byte random verifier (base64url, 43 characters) and opens
185
+ `<origin>/auth/desktop/start?challenge=<base64url sha256(verifier)>` in the
186
+ system browser, with `&login_hint=<email>` when an email is known, and
187
+ `&prompt=select_account` for the first sign-in after a sign-out.
188
+ 2. The person signs in there and confirms. The browser ends on
189
+ `<scheme>://auth/done?id=<uuid v4>`.
190
+ 3. The shell accepts only that exact scheme, host and path with a uuid v4 `id`,
191
+ and only while a sign-in is pending. Cancelling, or five minutes passing,
192
+ forgets the verifier; a later link does nothing.
193
+ 4. The shell sends `POST <origin>/auth/desktop/redeem` with JSON
194
+ `{ "id": "<id>", "verifier": "<verifier>" }` and `redirect: 'error'`, through
195
+ the partition's `session.fetch`.
196
+ 5. Success is `200` with JSON
197
+ `{ "cookies": [ { "name", "value", "path", "httpOnly", "secure", "sameSite", "maxAge" } ] }`.
198
+ At most 4 cookies. Every cookie is checked: the name matches
199
+ `/^(__Host-)?[A-Za-z0-9_]+$/`, the value is at most 4096 bytes without control
200
+ characters; on an https origin the name starts with `__Host-`, `secure` is true
201
+ and `path` is `/`. Nothing is installed unless every cookie passes.
202
+ 6. The cookies go into the company's partition and the window loads
203
+ `<origin><landingPath>`. The page needs no IPC: the session is a cookie.
204
+
205
+ The pairing code (shown in the app and on the server's confirm page) is the first
206
+ 6 Crockford base32 characters of `sha256("boule-desktop-pair:" + challenge)`,
207
+ shown as `ABC-DEF`. The `boule-desktop-pair:` label is fixed for every app so one
208
+ server implementation serves all of them.
209
+
210
+ Sign out (native menu) does a best-effort `POST <origin>/auth/logout` through the
211
+ partition with an `Origin` header and `redirect: 'manual'` (the 303 to the issuer is
212
+ never followed), clears the partition's storage and cache, forgets the company and
213
+ shows the welcome screen. "Cancel sign-in" is in the same menu.
214
+
215
+ A link the server does not recognise (404, 403, 409 `not_approved`) is not ours,
216
+ since any app can open a `<scheme>://` link: the sign-in keeps waiting with the same
217
+ verifier. 409 `consumed`, 410 and 401 end it ("Sign-in expired. Start again.");
218
+ 429, 5xx and network errors retry the same link. At most 5 redeems per sign-in,
219
+ one at a time, at least 1.1 seconds apart.
220
+
221
+ If the server sends the company window to its own `/auth/` pages, the session has
222
+ ended: the shell signs out and shows "Your session ended. Sign in again." If the
223
+ company cannot be reached the shell keeps the company and its session and offers
224
+ "Try again". Nothing is downloaded, and every frame of the company page is held to
225
+ the same origin rule as the page.
226
+
227
+ A packaged app has no environment overrides and accepts only https origins. A
228
+ packaged app has no DevTools and no reload.
229
+
230
+ ## Behaving like a Mac app
231
+
232
+ Closing the window hides it; the app stays in the dock. Cmd+Q really quits and
233
+ the dock icon shows the window again. Window position and size persist in
234
+ `window-state.json`, clamped to a connected display. Navigation in the company
235
+ window is locked to its origin, and `/auth/` and `/callback` are never loaded
236
+ there (case, percent-encoding, repeated and back slashes are normalised first).
237
+
238
+ ## The welcome page
239
+
240
+ A local page (`welcome/welcome.html`, strict CSP, no remote content, no inline
241
+ script) in its own in-memory session. Its preload exposes `window.shellWelcome`:
242
+ `config` (appName, fixed, host, hostPrefix), `resolveEmail` and `resolveAddress`.
243
+ Main validates the input and accepts calls only from that window's own top frame.
244
+ For a fixed origin the page starts at "Sign in to <appName>" and its button asks
245
+ main to start the sign-in.
246
+
247
+ ## Updating itself
248
+
249
+ electron-updater's generic provider: a static host serving `latest-mac.yml` (or
250
+ `<channel>-mac.yml`) and the zip it names. A packaged app checks once after its
251
+ window first shows; a failed check never blocks startup and is logged. Checksums
252
+ are never turned off.
253
+
254
+ ## Releasing the package
255
+
256
+ `.github/workflows/release.yml` publishes on a `v*` tag over OIDC with no stored
257
+ token, as the estate's package template does. The first version is published by
258
+ hand; set the npm trusted publisher for this repo before the first tag.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@wtfalch/desktop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "The shared Electron shell for wtfalch apps: sign-in handoff, welcome screen, locked company window, auto-update.",
7
+ "packageManager": "pnpm@10.0.0",
8
+ "engines": {
9
+ "node": ">=22.0.0"
10
+ },
11
+ "files": ["LICENSE", "README.md", "src", "welcome"],
12
+ "exports": {
13
+ ".": "./src/index.mjs",
14
+ "./welcome": "./welcome/welcome.html",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "sideEffects": false,
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test test/*.test.mjs",
23
+ "test:electron": "node test/electron/run.mjs",
24
+ "lint": "biome check .",
25
+ "check": "pnpm lint && pnpm test"
26
+ },
27
+ "dependencies": {
28
+ "electron-updater": "6.8.9"
29
+ },
30
+ "peerDependencies": {
31
+ "electron": ">=44"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "1.9.4",
35
+ "electron": "44.4.5"
36
+ },
37
+ "pnpm": {
38
+ "onlyBuiltDependencies": ["electron"]
39
+ }
40
+ }
package/src/access.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // Cloudflare Access support. An origin behind Access answers a request with no
2
+ // CF_Authorization cookie by redirecting to <team>.cloudflareaccess.com. The
3
+ // cookie lives in the company partition, so every request the shell makes to
4
+ // the origin goes through that partition's session.fetch, and the person
5
+ // passes Access in the app window before the system-browser sign-in starts.
6
+ // Everything here is pure or takes its effects as arguments.
7
+
8
+ export const isAccessHost = (hostname) =>
9
+ hostname === 'cloudflareaccess.com' || hostname.endsWith('.cloudflareaccess.com');
10
+
11
+ const hostOf = (value, base) => {
12
+ try {
13
+ return new URL(value, base).hostname;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ };
18
+
19
+ // Decides from status and Location only. Electron's session.fetch returns an
20
+ // incorrect `.url` (electron.d.ts), so `response.url` is never read. With
21
+ // `redirect: 'manual'` the shell sees the first answer itself: a non-3xx answer
22
+ // is the origin talking, so Access let the request through. A 3xx whose Location
23
+ // resolves to the origin itself is the app's own redirect (also past). A 3xx to
24
+ // any other host is Access or an identity provider (instant auth): not yet.
25
+ export function passedAccess(response, origin) {
26
+ if (!(response.status >= 300 && response.status < 400)) return true;
27
+ const location = response.headers?.get?.('location');
28
+ if (!location) return false;
29
+ try {
30
+ return new URL(location, `${origin}/`).origin === origin;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ // While the person logs in through Access, the window follows its redirects,
37
+ // including to the identity provider, so any https page is allowed, plus the
38
+ // origin itself (http only for a local development origin). Nothing else.
39
+ export function accessNavigation(value, origin) {
40
+ try {
41
+ const url = new URL(value);
42
+ if (url.username || url.password) return false;
43
+ return url.origin === origin || url.protocol === 'https:';
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ // One request through the company partition's session. `credentials: 'include'`
50
+ // makes the partition's cookies (CF_Authorization, the app session) go along.
51
+ export const partitionFetcher = (partition) => (url, init) =>
52
+ partition.fetch(url, { ...init, credentials: 'include' });
53
+
54
+ // Asks the origin, through the partition, whether Access lets this person in:
55
+ // 'passed', 'access' (still being sent to log in) or 'offline' (the request
56
+ // itself failed, so Access cannot be judged).
57
+ export async function probeAccess(fetcher, origin, path) {
58
+ let response;
59
+ try {
60
+ response = await fetcher(`${origin}${path}`, {
61
+ method: 'GET',
62
+ redirect: 'manual',
63
+ signal: AbortSignal.timeout(8000),
64
+ });
65
+ } catch {
66
+ return 'offline';
67
+ }
68
+ const passed = passedAccess(response, origin);
69
+ await response.body?.cancel().catch(() => {});
70
+ return passed ? 'passed' : 'access';
71
+ }
72
+
73
+ // Polls `probe` until it says 'passed'. Rejects when cancelled or out of time.
74
+ // `sleep` and `now` are injected so tests need no clock.
75
+ export async function waitForAccess({
76
+ probe,
77
+ isCancelled,
78
+ timeoutMs = 10 * 60 * 1000,
79
+ intervalMs = 1500,
80
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
81
+ now = Date.now,
82
+ }) {
83
+ const end = now() + timeoutMs;
84
+ for (;;) {
85
+ if (isCancelled()) throw new Error('Access cancelled');
86
+ if ((await probe()) === 'passed') return;
87
+ if (now() >= end) throw new Error('Access timed out');
88
+ await sleep(intervalMs);
89
+ }
90
+ }
@@ -0,0 +1,42 @@
1
+ import { validateConfig } from './config.mjs';
2
+ // Preflight for one deployed origin. An app's check script calls
3
+ // runCheck(config, process.argv[2]). It signs nobody in.
4
+ import { discover, originForAddress } from './resolve.mjs';
5
+
6
+ export async function runCheck(config, typed) {
7
+ validateConfig(config);
8
+ const fixed = config.origin.kind === 'fixed';
9
+ const input =
10
+ typed ?? process.env[config.devOriginEnv] ?? (fixed ? config.origin.origin : undefined);
11
+ if (!input) {
12
+ console.error(`Usage: <check script> <${config.appName} address>`);
13
+ return 2;
14
+ }
15
+ const origin = originForAddress(input, { allowLocal: true });
16
+ const problems = [];
17
+ if (!fixed) {
18
+ const { wellKnownPath, validate } = config.origin;
19
+ try {
20
+ await discover(origin, fetch, { wellKnownPath, validate, allowLocal: true });
21
+ } catch {
22
+ problems.push({
23
+ what: `${origin}${wellKnownPath} is missing or malformed`,
24
+ fix: `Deploy a ${config.appName} release that serves it.`,
25
+ });
26
+ }
27
+ }
28
+ if (!problems.length) {
29
+ if (config.accessProbePath !== null)
30
+ console.log(
31
+ 'Note: this origin is behind Cloudflare Access; the check cannot see past it without a service token.',
32
+ );
33
+ if (typeof config.signIn.check === 'function')
34
+ problems.push(...(await config.signIn.check(origin)));
35
+ }
36
+ if (!problems.length) {
37
+ console.log(`${origin} is ready for the desktop shell.`);
38
+ return 0;
39
+ }
40
+ for (const { what, fix } of problems) console.error(`${what}\n ${fix}`);
41
+ return 1;
42
+ }
@@ -0,0 +1,51 @@
1
+ // Main process only. The one company (or fixed origin) this app holds, as JSON in userData.
2
+ // A missing, corrupt or invalid file means no company. While a partition may
3
+ // hold a session that is not (or no longer) a stored company's, the file holds
4
+ // { pendingClear: <origin> } instead, so a crash cannot leave that session behind.
5
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { originForAddress, validName } from './resolve.mjs';
7
+
8
+ const originOk = (value, options) => {
9
+ try {
10
+ return typeof value === 'string' && originForAddress(value, options) === value;
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+ const read = (path) => {
16
+ try {
17
+ return JSON.parse(readFileSync(path, 'utf8'));
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ };
22
+ const write = (path, value) => writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
23
+
24
+ export function loadCompany(path, options) {
25
+ const value = read(path);
26
+ if (!value || !originOk(value.origin, options) || !validName(value.name)) return undefined;
27
+ return {
28
+ origin: value.origin,
29
+ name: value.name,
30
+ };
31
+ }
32
+
33
+ export function saveCompany(path, company, options) {
34
+ if (!originOk(company?.origin, options) || !validName(company.name))
35
+ throw new Error('Invalid company');
36
+ write(path, {
37
+ origin: company.origin,
38
+ name: company.name,
39
+ });
40
+ }
41
+
42
+ export const markPendingClear = (path, origin) => write(path, { pendingClear: origin });
43
+
44
+ export function loadPendingClear(path, options) {
45
+ const origin = read(path)?.pendingClear;
46
+ return originOk(origin, options) ? origin : undefined;
47
+ }
48
+
49
+ export function forgetCompany(path) {
50
+ rmSync(path, { force: true });
51
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // The one config an app passes to startShell(). Every value is required: the
2
+ // package holds no defaults, so an app's identity is visible in its own main.mjs.
3
+ //
4
+ // @typedef {{ kind: 'discover', wellKnownPath: string, hostPrefix: string,
5
+ // sharedHost: { domain: string } | null,
6
+ // validate?: (json: unknown, origin: string) => { name: string } | undefined }
7
+ // | { kind: 'fixed', origin: string }} OriginConfig
8
+ //
9
+ // @typedef {object} ShellConfig
10
+ // @property {string} appName Window title, menu label, welcome page text.
11
+ // @property {string} dataDirName Folder under appData for company.json and window-state.json.
12
+ // @property {string} partitionPrefix e.g. 'persist:boule-'; the origin's hex is appended.
13
+ // @property {OriginConfig} origin
14
+ // @property {string} landingPath e.g. '/dashboard'; loaded after sign-in.
15
+ // @property {string|null} accessProbePath Behind Cloudflare Access: a path the app answers without a redirect
16
+ // (e.g. '/api/health'), probed to tell whether Access let the person in. null: not behind Access.
17
+ // @property {string|null} feedUrl https feed baked into code, or null to use app-update.yml.
18
+ // @property {string|null} updateChannel electron-updater channel, or null.
19
+ // @property {string} devOriginEnv Unpackaged-only origin override, e.g. 'BOULE_URL'.
20
+ // @property {string} devFeedEnv Unpackaged-only feed override, e.g. 'BOULE_UPDATE_FEED_URL'.
21
+ // @property {{ notifications: boolean, media: boolean }} permissions Granted to the company origin only.
22
+ // @property {{ pathPrefix: string } | null} navigationLinks <scheme>://<path> links that open a page.
23
+ // @property {SignInStrategy} signIn See README.md, "Sign-in strategies".
24
+ import { originForAddress } from './resolve.mjs';
25
+
26
+ const text = (value, name) => {
27
+ if (typeof value !== 'string' || !value.trim())
28
+ throw new Error(`config.${name} must be a non-empty string`);
29
+ };
30
+ const flag = (value, name) => {
31
+ if (typeof value !== 'boolean') throw new Error(`config.${name} must be a boolean`);
32
+ };
33
+
34
+ export function validateConfig(config) {
35
+ if (!config || typeof config !== 'object') throw new Error('startShell needs a config object');
36
+ for (const key of [
37
+ 'appName',
38
+ 'dataDirName',
39
+ 'partitionPrefix',
40
+ 'landingPath',
41
+ 'devOriginEnv',
42
+ 'devFeedEnv',
43
+ ])
44
+ text(config[key], key);
45
+ if (!/^[a-z0-9-]+$/i.test(config.dataDirName))
46
+ throw new Error('config.dataDirName must be a plain folder name');
47
+ if (!config.partitionPrefix.startsWith('persist:'))
48
+ throw new Error("config.partitionPrefix must start with 'persist:'");
49
+ if (!config.landingPath.startsWith('/'))
50
+ throw new Error("config.landingPath must start with '/'");
51
+ if (config.accessProbePath !== null && !/^\/[^/\\]/.test(String(config.accessProbePath)))
52
+ throw new Error("config.accessProbePath must be null or a path starting with '/'");
53
+ for (const key of ['feedUrl', 'updateChannel'])
54
+ if (config[key] !== null && typeof config[key] !== 'string')
55
+ throw new Error(`config.${key} must be a string or null`);
56
+ if (config.feedUrl !== null && !config.feedUrl.startsWith('https://'))
57
+ throw new Error('config.feedUrl must be https');
58
+ const p = config.permissions;
59
+ if (!p || typeof p.notifications !== 'boolean' || typeof p.media !== 'boolean')
60
+ throw new Error('config.permissions needs boolean notifications and media');
61
+ const nav = config.navigationLinks;
62
+ if (
63
+ nav !== null &&
64
+ (!nav || typeof nav.pathPrefix !== 'string' || !nav.pathPrefix.startsWith('/'))
65
+ )
66
+ throw new Error('config.navigationLinks must be null or { pathPrefix: "/..." }');
67
+ const signIn = config.signIn;
68
+ if (
69
+ !signIn ||
70
+ typeof signIn.createLogin !== 'function' ||
71
+ typeof signIn.logout !== 'function' ||
72
+ typeof signIn.scheme !== 'string'
73
+ )
74
+ throw new Error('config.signIn must be a sign-in strategy: { scheme, createLogin, logout }');
75
+ const origin = config.origin;
76
+ if (origin?.kind === 'fixed') {
77
+ let normal;
78
+ try {
79
+ normal = originForAddress(origin.origin);
80
+ } catch {
81
+ normal = undefined;
82
+ }
83
+ if (normal !== origin.origin)
84
+ throw new Error('config.origin.origin must be a plain https origin');
85
+ } else if (origin?.kind === 'discover') {
86
+ text(origin.wellKnownPath, 'origin.wellKnownPath');
87
+ if (!origin.wellKnownPath.startsWith('/'))
88
+ throw new Error("config.origin.wellKnownPath must start with '/'");
89
+ text(origin.hostPrefix, 'origin.hostPrefix');
90
+ if (origin.sharedHost !== null && typeof origin.sharedHost?.domain !== 'string')
91
+ throw new Error('config.origin.sharedHost must be null or { domain }');
92
+ if (origin.validate !== undefined && typeof origin.validate !== 'function')
93
+ throw new Error('config.origin.validate must be a function');
94
+ } else throw new Error("config.origin.kind must be 'discover' or 'fixed'");
95
+ return config;
96
+ }