@yielded/auth-electron 0.1.0-beta.17

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 Daniel van der Merwe
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 ADDED
@@ -0,0 +1,51 @@
1
+ # @yielded/auth-electron
2
+
3
+ Open hosted sign-in in the system browser and keep the native session in
4
+ Electron's main process. The renderer receives public session data, never credentials.
5
+
6
+ Install alongside Electron and Effect:
7
+
8
+ ```sh
9
+ bun add @yielded/auth-electron@beta @yielded/auth@beta effect
10
+ ```
11
+
12
+ Import `BrowserLogin` from `@yielded/auth-electron`. The
13
+ [Electron example](../../examples/browser-login-electron) shows complete setup.
14
+
15
+ - Hold Electron's single-instance lock, then acquire
16
+ `makeBrowser({ hostedUrl, returnUrl })` synchronously before `app.whenReady()`.
17
+ Keep it in the application's `Scope` so callbacks can arrive during startup.
18
+ - After readiness, provide `layerVault({ path })` with Effect `FileSystem` and
19
+ `Path`. Use one writer and a private absolute path per app, backend, and client.
20
+ - Provide both services to core `BrowserLogin.makeClient`, sharing the vault with
21
+ the native `OperationHttpClient`. Keep credential-bearing operations in main.
22
+
23
+ The hosted page requires HTTPS, except for loopback HTTP development. Register
24
+ the exact return URL with the server. Electron does not support `ephemeral: true`;
25
+ it cannot promise a private system-browser session or close the tab on cancellation.
26
+
27
+ ## Callback setup
28
+
29
+ For custom schemes, macOS requires a packaged app declaring the scheme in
30
+ `Info.plist`. Linux requires a packaged app with an installed `.desktop` handler
31
+ selected as default. Windows supports packaged apps and development registration
32
+ with the executable and application paths. See the example for platform steps.
33
+
34
+ HTTPS callbacks require a signed, packaged macOS app with an Associated Domains
35
+ entitlement and a matching website association. Follow the
36
+ [claimed HTTPS callback setup](../../docs/src/content/docs/reference/browser-login.md#apple-association)
37
+ to compose and host your app's association file. Browser and user
38
+ preferences may still require **Open in app**. Windows and Linux support only
39
+ custom schemes through this adapter.
40
+
41
+ ## Storage and recovery
42
+
43
+ OS encryption is required. Linux needs GNOME Keyring or KWallet; there is no
44
+ plaintext fallback. Keep the signing identity stable for macOS Keychain access.
45
+ Windows encryption does not isolate the vault from other apps running as the same user.
46
+
47
+ Pending login state survives restarts; use `resume` while the attempt is valid.
48
+ Never delete an unreadable vault or reset an uncertain exchange just to restart login:
49
+ a native session may already exist. Follow
50
+ [session recovery](../../docs/src/content/docs/reference/browser-login.md#recovery)
51
+ before retiring the attempt or resetting storage.
@@ -0,0 +1,8 @@
1
+ import { layerBrowser, makeBrowser } from "./internal/browser.mjs";
2
+ import { layerVault } from "./internal/vault.mjs";
3
+ declare namespace BrowserLogin_d_exports {
4
+ export { layerBrowser, layerVault, makeBrowser };
5
+ }
6
+ //#endregion
7
+ export { BrowserLogin_d_exports, layerBrowser, layerVault, makeBrowser };
8
+ //# sourceMappingURL=BrowserLogin.d.mts.map
@@ -0,0 +1,13 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
2
+ import { layerBrowser, makeBrowser } from "./internal/browser.mjs";
3
+ import { layerVault } from "./internal/vault.mjs";
4
+ //#region src/BrowserLogin.ts
5
+ var BrowserLogin_exports = /* @__PURE__ */ __exportAll({
6
+ layerBrowser: () => layerBrowser,
7
+ layerVault: () => layerVault,
8
+ makeBrowser: () => makeBrowser
9
+ });
10
+ //#endregion
11
+ export { BrowserLogin_exports, layerBrowser, layerVault, makeBrowser };
12
+
13
+ //# sourceMappingURL=BrowserLogin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrowserLogin.mjs","names":[],"sources":["../src/BrowserLogin.ts"],"sourcesContent":["export { makeBrowser, layerBrowser } from \"./internal/browser\";\nexport { layerVault } from \"./internal/vault\";\n"],"mappings":""}
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll };
@@ -0,0 +1,2 @@
1
+ import { BrowserLogin_d_exports } from "./BrowserLogin.mjs";
2
+ export { BrowserLogin_d_exports as BrowserLogin };
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { BrowserLogin_exports } from "./BrowserLogin.mjs";
2
+ export { BrowserLogin_exports as BrowserLogin };
@@ -0,0 +1,30 @@
1
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
2
+ import { Effect, Layer, Schema } from "effect";
3
+ //#region src/internal/browser.d.ts
4
+ declare const optionsSchema: Schema.Struct<{
5
+ readonly hostedUrl: Schema.String;
6
+ readonly returnUrl: Schema.String;
7
+ }>;
8
+ /** Acquire synchronously in the main process BEFORE app.whenReady(), under the
9
+ * application's Scope. The host must already hold app.requestSingleInstanceLock().
10
+ * No import-time listeners, protocol registration, or external browser launch.
11
+ * HTTPS callbacks require packaged macOS with signed associated domains and a
12
+ * matching website association. Only OS-delivered Universal Links are accepted;
13
+ * this adapter cannot preflight the OS association. Custom schemes require a
14
+ * packaged Info.plist on macOS or installed .desktop handler on Linux. Windows
15
+ * also supports the default Electron executable in development.
16
+ */
17
+ export declare const makeBrowser: (options: {
18
+ readonly hostedUrl: string;
19
+ readonly returnUrl: string;
20
+ }) => Effect.Effect<{
21
+ readonly open: (input: {
22
+ readonly url: string;
23
+ readonly returnUrl: string;
24
+ readonly ephemeral: boolean;
25
+ }) => Effect.Effect<string, BrowserLogin.PlatformError>;
26
+ }, BrowserLogin.PlatformError, import("effect/Scope").Scope>;
27
+ /** Build before app readiness, or use makeBrowser for synchronous startup capture. */
28
+ export declare const layerBrowser: (options: typeof optionsSchema.Type) => Layer.Layer<BrowserLogin.Browser, BrowserLogin.PlatformError, never>;
29
+ //#endregion
30
+ //# sourceMappingURL=browser.d.mts.map
@@ -0,0 +1,142 @@
1
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
2
+ import { Effect, Layer, Schema } from "effect";
3
+ import { app, shell } from "electron";
4
+ //#region src/internal/browser.ts
5
+ const unavailable = () => BrowserLogin.PlatformError.make({ reason: "unavailable" });
6
+ const optionsSchema = Schema.Struct({
7
+ hostedUrl: BrowserLogin.HostedUrl,
8
+ returnUrl: BrowserLogin.ReturnUrl
9
+ });
10
+ /** Acquire synchronously in the main process BEFORE app.whenReady(), under the
11
+ * application's Scope. The host must already hold app.requestSingleInstanceLock().
12
+ * No import-time listeners, protocol registration, or external browser launch.
13
+ * HTTPS callbacks require packaged macOS with signed associated domains and a
14
+ * matching website association. Only OS-delivered Universal Links are accepted;
15
+ * this adapter cannot preflight the OS association. Custom schemes require a
16
+ * packaged Info.plist on macOS or installed .desktop handler on Linux. Windows
17
+ * also supports the default Electron executable in development.
18
+ */
19
+ const makeBrowser = Effect.fnUntraced(function* (options) {
20
+ if (process.type !== "browser" || !app.hasSingleInstanceLock()) return yield* unavailable();
21
+ const config = yield* Schema.decodeEffect(optionsSchema)(options).pipe(Effect.mapError(unavailable));
22
+ const target = new URL(config.returnUrl);
23
+ const scheme = target.protocol.slice(0, -1);
24
+ const universalLink = scheme === "https";
25
+ if (universalLink && (process.platform !== "darwin" || !app.isPackaged || target.port !== "")) return yield* unavailable();
26
+ const callbackSchema = Schema.String.check(Schema.isMaxLength(2304), Schema.makeFilter((text) => {
27
+ try {
28
+ const url = new URL(text);
29
+ const keys = [...url.searchParams.keys()];
30
+ if (url.href !== text || url.username || url.password || url.hash || keys.length !== 2 || !keys.includes("code") || !keys.includes("state") || !Schema.is(BrowserLogin.Random)(url.searchParams.get("code")) || !Schema.is(BrowserLogin.Random)(url.searchParams.get("state"))) return false;
31
+ url.search = "";
32
+ return url.href === config.returnUrl;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }));
37
+ const openSchema = Schema.Struct({
38
+ returnUrl: Schema.Literal(config.returnUrl),
39
+ ephemeral: Schema.Literal(false),
40
+ url: Schema.String.check(Schema.isMaxLength(config.hostedUrl.length + 9 + 43), Schema.makeFilter((text) => {
41
+ try {
42
+ const url = new URL(text);
43
+ if (url.href !== text || url.hash || [...url.searchParams.keys()].join() !== "attempt" || !Schema.is(BrowserLogin.Random)(url.searchParams.get("attempt"))) return false;
44
+ url.search = "";
45
+ return url.href === config.hostedUrl;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }))
50
+ });
51
+ const queued = [];
52
+ let receive;
53
+ let close;
54
+ let disposed = false;
55
+ const capture = (url) => {
56
+ if (disposed || !Schema.is(callbackSchema)(url)) return;
57
+ if (receive !== void 0) receive(url);
58
+ else if (!queued.includes(url) && queued.length < 8) queued.push(url);
59
+ };
60
+ const openUrl = (event, url) => {
61
+ if (!Schema.is(callbackSchema)(url)) return;
62
+ event.preventDefault();
63
+ capture(url);
64
+ };
65
+ const secondInstance = (_event, argv) => {
66
+ for (const arg of argv) capture(arg);
67
+ };
68
+ const activitySchema = Schema.Struct({ webpageURL: callbackSchema });
69
+ const continueActivity = (event, type, _userInfo, details) => {
70
+ if (type !== "NSUserActivityTypeBrowsingWeb" || !Schema.is(activitySchema)(details)) return;
71
+ event.preventDefault();
72
+ capture(details.webpageURL);
73
+ };
74
+ yield* Effect.acquireRelease(Effect.sync(() => {
75
+ if (universalLink) app.on("continue-activity", continueActivity);
76
+ else {
77
+ app.on("open-url", openUrl);
78
+ app.on("second-instance", secondInstance);
79
+ for (const arg of process.argv) capture(arg);
80
+ }
81
+ }), () => Effect.sync(() => {
82
+ disposed = true;
83
+ if (universalLink) app.removeListener("continue-activity", continueActivity);
84
+ else {
85
+ app.removeListener("open-url", openUrl);
86
+ app.removeListener("second-instance", secondInstance);
87
+ }
88
+ queued.length = 0;
89
+ close?.();
90
+ }));
91
+ const prepare = Effect.try({
92
+ try: () => {
93
+ if (!app.isReady() || disposed) return false;
94
+ if (universalLink) return process.platform === "darwin" && app.isPackaged;
95
+ if (process.platform === "linux") return app.isPackaged && app.isDefaultProtocolClient(scheme);
96
+ if (process.platform === "darwin") return app.isPackaged && app.setAsDefaultProtocolClient(scheme) && app.isDefaultProtocolClient(scheme);
97
+ if (process.platform !== "win32") return false;
98
+ const args = process.defaultApp ? [app.getAppPath()] : [];
99
+ return app.setAsDefaultProtocolClient(scheme, process.execPath, args) && app.isDefaultProtocolClient(scheme, process.execPath, args);
100
+ },
101
+ catch: unavailable
102
+ }).pipe(Effect.flatMap((available) => available ? Effect.void : Effect.fail(unavailable())));
103
+ return BrowserLogin.Browser.of({ open: Effect.fnUntraced(function* (input) {
104
+ const request = yield* Schema.decodeUnknownEffect(openSchema)(input).pipe(Effect.mapError(unavailable));
105
+ yield* prepare;
106
+ return yield* Effect.callback((resume) => {
107
+ if (receive !== void 0) {
108
+ resume(Effect.fail(BrowserLogin.PlatformError.make({ reason: "busy" })));
109
+ return;
110
+ }
111
+ let active = true;
112
+ const cleanup = () => {
113
+ if (!active) return;
114
+ active = false;
115
+ receive = void 0;
116
+ close = void 0;
117
+ };
118
+ const finish = (result) => {
119
+ if (active) {
120
+ cleanup();
121
+ resume(result);
122
+ }
123
+ };
124
+ receive = (url) => finish(Effect.succeed(url));
125
+ close = () => finish(Effect.fail(unavailable()));
126
+ const pending = queued.shift();
127
+ if (pending !== void 0) receive(pending);
128
+ else try {
129
+ shell.openExternal(request.url).catch(() => finish(Effect.fail(unavailable())));
130
+ } catch {
131
+ finish(Effect.fail(unavailable()));
132
+ }
133
+ return Effect.sync(cleanup);
134
+ }).pipe(Effect.timeout("10 minutes"), Effect.catchTag("TimeoutError", () => Effect.fail(BrowserLogin.PlatformError.make({ reason: "expired" }))));
135
+ }) });
136
+ });
137
+ /** Build before app readiness, or use makeBrowser for synchronous startup capture. */
138
+ const layerBrowser = (options) => Layer.effect(BrowserLogin.Browser, makeBrowser(options));
139
+ //#endregion
140
+ export { layerBrowser, makeBrowser };
141
+
142
+ //# sourceMappingURL=browser.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.mjs","names":[],"sources":["../../src/internal/browser.ts"],"sourcesContent":["import * as BrowserLogin from \"@yielded/auth/BrowserLogin\";\nimport { Effect, Layer, Schema } from \"effect\";\nimport { app, shell, type Event } from \"electron\";\n\nconst unavailable = () => BrowserLogin.PlatformError.make({ reason: \"unavailable\" });\n\nconst optionsSchema = Schema.Struct({\n hostedUrl: BrowserLogin.HostedUrl,\n returnUrl: BrowserLogin.ReturnUrl,\n});\n\n/** Acquire synchronously in the main process BEFORE app.whenReady(), under the\n * application's Scope. The host must already hold app.requestSingleInstanceLock().\n * No import-time listeners, protocol registration, or external browser launch.\n * HTTPS callbacks require packaged macOS with signed associated domains and a\n * matching website association. Only OS-delivered Universal Links are accepted;\n * this adapter cannot preflight the OS association. Custom schemes require a\n * packaged Info.plist on macOS or installed .desktop handler on Linux. Windows\n * also supports the default Electron executable in development.\n */\nexport const makeBrowser = Effect.fnUntraced(function* (options: typeof optionsSchema.Type) {\n if (process.type !== \"browser\" || !app.hasSingleInstanceLock()) return yield* unavailable();\n\n const config = yield* Schema.decodeEffect(optionsSchema)(options).pipe(\n Effect.mapError(unavailable),\n );\n\n const target = new URL(config.returnUrl);\n const scheme = target.protocol.slice(0, -1);\n const universalLink = scheme === \"https\";\n\n if (universalLink && (process.platform !== \"darwin\" || !app.isPackaged || target.port !== \"\"))\n return yield* unavailable();\n\n const callbackSchema = Schema.String.check(\n Schema.isMaxLength(2304),\n Schema.makeFilter((text) => {\n try {\n const url = new URL(text);\n const keys = [...url.searchParams.keys()];\n\n if (\n url.href !== text ||\n url.username ||\n url.password ||\n url.hash ||\n keys.length !== 2 ||\n !keys.includes(\"code\") ||\n !keys.includes(\"state\") ||\n !Schema.is(BrowserLogin.Random)(url.searchParams.get(\"code\")) ||\n !Schema.is(BrowserLogin.Random)(url.searchParams.get(\"state\"))\n )\n return false;\n url.search = \"\";\n\n return url.href === config.returnUrl;\n } catch {\n return false;\n }\n }),\n );\n\n const openSchema = Schema.Struct({\n returnUrl: Schema.Literal(config.returnUrl),\n ephemeral: Schema.Literal(false),\n url: Schema.String.check(\n Schema.isMaxLength(config.hostedUrl.length + \"?attempt=\".length + 43),\n Schema.makeFilter((text) => {\n try {\n const url = new URL(text);\n\n if (\n url.href !== text ||\n url.hash ||\n [...url.searchParams.keys()].join() !== \"attempt\" ||\n !Schema.is(BrowserLogin.Random)(url.searchParams.get(\"attempt\"))\n )\n return false;\n url.search = \"\";\n\n return url.href === config.hostedUrl;\n } catch {\n return false;\n }\n }),\n ),\n });\n\n const queued: string[] = [];\n let receive: ((url: string) => void) | undefined;\n let close: (() => void) | undefined;\n let disposed = false;\n\n const capture = (url: string) => {\n if (disposed || !Schema.is(callbackSchema)(url)) return;\n if (receive !== undefined) receive(url);\n else if (!queued.includes(url) && queued.length < 8) queued.push(url);\n };\n\n const openUrl = (event: Event, url: string) => {\n if (!Schema.is(callbackSchema)(url)) return;\n event.preventDefault();\n capture(url);\n };\n\n const secondInstance = (_event: Event, argv: string[]) => {\n for (const arg of argv) capture(arg);\n };\n\n const activitySchema = Schema.Struct({ webpageURL: callbackSchema });\n\n const continueActivity = (event: Event, type: string, _userInfo: unknown, details: unknown) => {\n if (type !== \"NSUserActivityTypeBrowsingWeb\" || !Schema.is(activitySchema)(details)) return;\n event.preventDefault();\n capture(details.webpageURL);\n };\n\n yield* Effect.acquireRelease(\n Effect.sync(() => {\n if (universalLink) app.on(\"continue-activity\", continueActivity);\n else {\n app.on(\"open-url\", openUrl);\n app.on(\"second-instance\", secondInstance);\n for (const arg of process.argv) capture(arg);\n }\n }),\n () =>\n Effect.sync(() => {\n disposed = true;\n if (universalLink) app.removeListener(\"continue-activity\", continueActivity);\n else {\n app.removeListener(\"open-url\", openUrl);\n app.removeListener(\"second-instance\", secondInstance);\n }\n queued.length = 0;\n close?.();\n }),\n );\n\n const prepare = Effect.try({\n try: () => {\n if (!app.isReady() || disposed) return false;\n // Associated domains are verified by macOS when routing the callback.\n // Registering as the default HTTPS handler would not establish ownership.\n if (universalLink) return process.platform === \"darwin\" && app.isPackaged;\n if (process.platform === \"linux\")\n return app.isPackaged && app.isDefaultProtocolClient(scheme);\n if (process.platform === \"darwin\")\n return (\n app.isPackaged &&\n app.setAsDefaultProtocolClient(scheme) &&\n app.isDefaultProtocolClient(scheme)\n );\n if (process.platform !== \"win32\") return false;\n const args = process.defaultApp ? [app.getAppPath()] : [];\n\n return (\n app.setAsDefaultProtocolClient(scheme, process.execPath, args) &&\n app.isDefaultProtocolClient(scheme, process.execPath, args)\n );\n },\n catch: unavailable,\n }).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(unavailable()))));\n\n return BrowserLogin.Browser.of({\n open: Effect.fnUntraced(function* (input) {\n const request = yield* Schema.decodeUnknownEffect(openSchema)(input).pipe(\n Effect.mapError(unavailable),\n );\n\n yield* prepare;\n\n return yield* Effect.callback<string, BrowserLogin.PlatformError>((resume) => {\n if (receive !== undefined) {\n resume(Effect.fail(BrowserLogin.PlatformError.make({ reason: \"busy\" })));\n\n return;\n }\n let active = true;\n\n const cleanup = () => {\n if (!active) return;\n active = false;\n receive = undefined;\n close = undefined;\n };\n\n const finish = (result: Effect.Effect<string, BrowserLogin.PlatformError>) => {\n if (active) {\n cleanup();\n resume(result);\n }\n };\n\n receive = (url) => finish(Effect.succeed(url));\n close = () => finish(Effect.fail(unavailable()));\n const pending = queued.shift();\n\n if (pending !== undefined) receive(pending);\n else {\n try {\n void shell.openExternal(request.url).catch(() => finish(Effect.fail(unavailable())));\n } catch {\n finish(Effect.fail(unavailable()));\n }\n }\n\n return Effect.sync(cleanup);\n }).pipe(\n Effect.timeout(\"10 minutes\"),\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(BrowserLogin.PlatformError.make({ reason: \"expired\" })),\n ),\n );\n }),\n });\n});\n\n/** Build before app readiness, or use makeBrowser for synchronous startup capture. */\nexport const layerBrowser = (options: typeof optionsSchema.Type) =>\n Layer.effect(BrowserLogin.Browser, makeBrowser(options));\n"],"mappings":";;;;AAIA,MAAM,oBAAoB,aAAa,cAAc,KAAK,EAAE,QAAQ,cAAc,CAAC;AAEnF,MAAM,gBAAgB,OAAO,OAAO;CAClC,WAAW,aAAa;CACxB,WAAW,aAAa;AAC1B,CAAC;;;;;;;;;;AAWD,MAAa,cAAc,OAAO,WAAW,WAAW,SAAoC;CAC1F,IAAI,QAAQ,SAAS,aAAa,CAAC,IAAI,sBAAsB,GAAG,OAAO,OAAO,YAAY;CAE1F,MAAM,SAAS,OAAO,OAAO,aAAa,aAAa,CAAC,CAAC,OAAO,CAAC,CAAC,KAChE,OAAO,SAAS,WAAW,CAC7B;CAEA,MAAM,SAAS,IAAI,IAAI,OAAO,SAAS;CACvC,MAAM,SAAS,OAAO,SAAS,MAAM,GAAG,EAAE;CAC1C,MAAM,gBAAgB,WAAW;CAEjC,IAAI,kBAAkB,QAAQ,aAAa,YAAY,CAAC,IAAI,cAAc,OAAO,SAAS,KACxF,OAAO,OAAO,YAAY;CAE5B,MAAM,iBAAiB,OAAO,OAAO,MACnC,OAAO,YAAY,IAAI,GACvB,OAAO,YAAY,SAAS;EAC1B,IAAI;GACF,MAAM,MAAM,IAAI,IAAI,IAAI;GACxB,MAAM,OAAO,CAAC,GAAG,IAAI,aAAa,KAAK,CAAC;GAExC,IACE,IAAI,SAAS,QACb,IAAI,YACJ,IAAI,YACJ,IAAI,QACJ,KAAK,WAAW,KAChB,CAAC,KAAK,SAAS,MAAM,KACrB,CAAC,KAAK,SAAS,OAAO,KACtB,CAAC,OAAO,GAAG,aAAa,MAAM,CAAC,CAAC,IAAI,aAAa,IAAI,MAAM,CAAC,KAC5D,CAAC,OAAO,GAAG,aAAa,MAAM,CAAC,CAAC,IAAI,aAAa,IAAI,OAAO,CAAC,GAE7D,OAAO;GACT,IAAI,SAAS;GAEb,OAAO,IAAI,SAAS,OAAO;EAC7B,QAAQ;GACN,OAAO;EACT;CACF,CAAC,CACH;CAEA,MAAM,aAAa,OAAO,OAAO;EAC/B,WAAW,OAAO,QAAQ,OAAO,SAAS;EAC1C,WAAW,OAAO,QAAQ,KAAK;EAC/B,KAAK,OAAO,OAAO,MACjB,OAAO,YAAY,OAAO,UAAU,SAAS,IAAqB,EAAE,GACpE,OAAO,YAAY,SAAS;GAC1B,IAAI;IACF,MAAM,MAAM,IAAI,IAAI,IAAI;IAExB,IACE,IAAI,SAAS,QACb,IAAI,QACJ,CAAC,GAAG,IAAI,aAAa,KAAK,CAAC,CAAC,CAAC,KAAK,MAAM,aACxC,CAAC,OAAO,GAAG,aAAa,MAAM,CAAC,CAAC,IAAI,aAAa,IAAI,SAAS,CAAC,GAE/D,OAAO;IACT,IAAI,SAAS;IAEb,OAAO,IAAI,SAAS,OAAO;GAC7B,QAAQ;IACN,OAAO;GACT;EACF,CAAC,CACH;CACF,CAAC;CAED,MAAM,SAAmB,CAAC;CAC1B,IAAI;CACJ,IAAI;CACJ,IAAI,WAAW;CAEf,MAAM,WAAW,QAAgB;EAC/B,IAAI,YAAY,CAAC,OAAO,GAAG,cAAc,CAAC,CAAC,GAAG,GAAG;EACjD,IAAI,YAAY,KAAA,GAAW,QAAQ,GAAG;OACjC,IAAI,CAAC,OAAO,SAAS,GAAG,KAAK,OAAO,SAAS,GAAG,OAAO,KAAK,GAAG;CACtE;CAEA,MAAM,WAAW,OAAc,QAAgB;EAC7C,IAAI,CAAC,OAAO,GAAG,cAAc,CAAC,CAAC,GAAG,GAAG;EACrC,MAAM,eAAe;EACrB,QAAQ,GAAG;CACb;CAEA,MAAM,kBAAkB,QAAe,SAAmB;EACxD,KAAK,MAAM,OAAO,MAAM,QAAQ,GAAG;CACrC;CAEA,MAAM,iBAAiB,OAAO,OAAO,EAAE,YAAY,eAAe,CAAC;CAEnE,MAAM,oBAAoB,OAAc,MAAc,WAAoB,YAAqB;EAC7F,IAAI,SAAS,mCAAmC,CAAC,OAAO,GAAG,cAAc,CAAC,CAAC,OAAO,GAAG;EACrF,MAAM,eAAe;EACrB,QAAQ,QAAQ,UAAU;CAC5B;CAEA,OAAO,OAAO,eACZ,OAAO,WAAW;EAChB,IAAI,eAAe,IAAI,GAAG,qBAAqB,gBAAgB;OAC1D;GACH,IAAI,GAAG,YAAY,OAAO;GAC1B,IAAI,GAAG,mBAAmB,cAAc;GACxC,KAAK,MAAM,OAAO,QAAQ,MAAM,QAAQ,GAAG;EAC7C;CACF,CAAC,SAEC,OAAO,WAAW;EAChB,WAAW;EACX,IAAI,eAAe,IAAI,eAAe,qBAAqB,gBAAgB;OACtE;GACH,IAAI,eAAe,YAAY,OAAO;GACtC,IAAI,eAAe,mBAAmB,cAAc;EACtD;EACA,OAAO,SAAS;EAChB,QAAQ;CACV,CAAC,CACL;CAEA,MAAM,UAAU,OAAO,IAAI;EACzB,WAAW;GACT,IAAI,CAAC,IAAI,QAAQ,KAAK,UAAU,OAAO;GAGvC,IAAI,eAAe,OAAO,QAAQ,aAAa,YAAY,IAAI;GAC/D,IAAI,QAAQ,aAAa,SACvB,OAAO,IAAI,cAAc,IAAI,wBAAwB,MAAM;GAC7D,IAAI,QAAQ,aAAa,UACvB,OACE,IAAI,cACJ,IAAI,2BAA2B,MAAM,KACrC,IAAI,wBAAwB,MAAM;GAEtC,IAAI,QAAQ,aAAa,SAAS,OAAO;GACzC,MAAM,OAAO,QAAQ,aAAa,CAAC,IAAI,WAAW,CAAC,IAAI,CAAC;GAExD,OACE,IAAI,2BAA2B,QAAQ,QAAQ,UAAU,IAAI,KAC7D,IAAI,wBAAwB,QAAQ,QAAQ,UAAU,IAAI;EAE9D;EACA,OAAO;CACT,CAAC,CAAC,CAAC,KAAK,OAAO,SAAS,cAAe,YAAY,OAAO,OAAO,OAAO,KAAK,YAAY,CAAC,CAAE,CAAC;CAE7F,OAAO,aAAa,QAAQ,GAAG,EAC7B,MAAM,OAAO,WAAW,WAAW,OAAO;EACxC,MAAM,UAAU,OAAO,OAAO,oBAAoB,UAAU,CAAC,CAAC,KAAK,CAAC,CAAC,KACnE,OAAO,SAAS,WAAW,CAC7B;EAEA,OAAO;EAEP,OAAO,OAAO,OAAO,UAA8C,WAAW;GAC5E,IAAI,YAAY,KAAA,GAAW;IACzB,OAAO,OAAO,KAAK,aAAa,cAAc,KAAK,EAAE,QAAQ,OAAO,CAAC,CAAC,CAAC;IAEvE;GACF;GACA,IAAI,SAAS;GAEb,MAAM,gBAAgB;IACpB,IAAI,CAAC,QAAQ;IACb,SAAS;IACT,UAAU,KAAA;IACV,QAAQ,KAAA;GACV;GAEA,MAAM,UAAU,WAA8D;IAC5E,IAAI,QAAQ;KACV,QAAQ;KACR,OAAO,MAAM;IACf;GACF;GAEA,WAAW,QAAQ,OAAO,OAAO,QAAQ,GAAG,CAAC;GAC7C,cAAc,OAAO,OAAO,KAAK,YAAY,CAAC,CAAC;GAC/C,MAAM,UAAU,OAAO,MAAM;GAE7B,IAAI,YAAY,KAAA,GAAW,QAAQ,OAAO;QAExC,IAAI;IACF,MAAW,aAAa,QAAQ,GAAG,CAAC,CAAC,YAAY,OAAO,OAAO,KAAK,YAAY,CAAC,CAAC,CAAC;GACrF,QAAQ;IACN,OAAO,OAAO,KAAK,YAAY,CAAC,CAAC;GACnC;GAGF,OAAO,OAAO,KAAK,OAAO;EAC5B,CAAC,CAAC,CAAC,KACD,OAAO,QAAQ,YAAY,GAC3B,OAAO,SAAS,sBACd,OAAO,KAAK,aAAa,cAAc,KAAK,EAAE,QAAQ,UAAU,CAAC,CAAC,CACpE,CACF;CACF,CAAC,EACH,CAAC;AACH,CAAC;;AAGD,MAAa,gBAAgB,YAC3B,MAAM,OAAO,aAAa,SAAS,YAAY,OAAO,CAAC"}
@@ -0,0 +1,13 @@
1
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
2
+ import { FileSystem, Layer, Path } from "effect";
3
+ //#region src/internal/vault.d.ts
4
+ /** One writer per private application/environment path, protected by the host's
5
+ * single-instance lock. Never reset an unreadable store: it may contain the
6
+ * Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,
7
+ * then sync the parent on POSIX (the replaced file on Windows).
8
+ */
9
+ export declare const layerVault: (options: {
10
+ readonly path: string;
11
+ }) => Layer.Layer<BrowserLogin.Vault, BrowserLogin.PlatformError, FileSystem.FileSystem | Path.Path>;
12
+ //#endregion
13
+ //# sourceMappingURL=vault.d.mts.map
@@ -0,0 +1,113 @@
1
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
2
+ import { DateTime, Effect, FileSystem, Layer, Path, Schema, Semaphore } from "effect";
3
+ import { app, safeStorage } from "electron";
4
+ import { Buffer } from "node:buffer";
5
+ import { OperationHttpError } from "@yielded/auth/OperationHttp";
6
+ //#region src/internal/vault.ts
7
+ const recordSchema = BrowserLogin.VaultRecord;
8
+ const slots = recordSchema.fields.credentials.key.literals;
9
+ const recordJson = Schema.fromJsonString(recordSchema);
10
+ const failure = () => BrowserLogin.PlatformError.make({ reason: "storage" });
11
+ /** One writer per private application/environment path, protected by the host's
12
+ * single-instance lock. Never reset an unreadable store: it may contain the
13
+ * Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,
14
+ * then sync the parent on POSIX (the replaced file on Windows).
15
+ */
16
+ const layerVault = (options) => Layer.effect(BrowserLogin.Vault, Effect.gen(function* () {
17
+ const fs = yield* FileSystem.FileSystem;
18
+ const path = yield* Path.Path;
19
+ const checkEncryption = Effect.try({
20
+ try: () => process.type === "browser" && app.isReady() && app.hasSingleInstanceLock() && safeStorage.isEncryptionAvailable() && (process.platform !== "linux" || [
21
+ "gnome_libsecret",
22
+ "kwallet",
23
+ "kwallet5",
24
+ "kwallet6"
25
+ ].includes(safeStorage.getSelectedStorageBackend())),
26
+ catch: failure
27
+ }).pipe(Effect.flatMap((available) => available ? Effect.void : Effect.fail(failure())));
28
+ yield* checkEncryption;
29
+ const destination = yield* Schema.decodeEffect(Schema.NonEmptyString.check(Schema.makeFilter((value) => path.isAbsolute(value))))(options.path).pipe(Effect.mapError(failure));
30
+ const directory = path.dirname(destination);
31
+ yield* fs.makeDirectory(directory, {
32
+ recursive: true,
33
+ mode: 448
34
+ }).pipe(Effect.mapError(failure));
35
+ const gate = yield* Semaphore.make(1);
36
+ const readRecord = Effect.gen(function* () {
37
+ yield* checkEncryption;
38
+ const stat = yield* fs.stat(destination).pipe(Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error)));
39
+ if (stat === void 0) return { credentials: {} };
40
+ if (stat.type !== "File" || stat.size > 1048576) return yield* failure();
41
+ const bytes = yield* fs.readFile(destination);
42
+ const json = yield* Effect.try({
43
+ try: () => safeStorage.decryptString(Buffer.from(bytes)),
44
+ catch: failure
45
+ });
46
+ return yield* Schema.decodeEffect(recordJson)(json, { onExcessProperty: "error" });
47
+ }).pipe(Effect.mapError(failure));
48
+ const writeRecord = Effect.fnUntraced(function* (record) {
49
+ yield* checkEncryption;
50
+ const json = yield* Schema.encodeEffect(recordJson)(record);
51
+ const bytes = yield* Effect.try({
52
+ try: () => safeStorage.encryptString(json),
53
+ catch: failure
54
+ });
55
+ yield* Effect.scoped(Effect.gen(function* () {
56
+ const temporary = yield* fs.makeTempFileScoped({
57
+ directory,
58
+ prefix: ".auth-"
59
+ });
60
+ yield* fs.chmod(temporary, 384);
61
+ yield* Effect.scoped(Effect.gen(function* () {
62
+ const file = yield* fs.open(temporary, {
63
+ flag: "w",
64
+ mode: 384
65
+ });
66
+ yield* file.writeAll(bytes);
67
+ yield* file.sync;
68
+ }));
69
+ yield* fs.rename(temporary, destination);
70
+ yield* (yield* fs.open(process.platform === "win32" ? destination : directory, { flag: process.platform === "win32" ? "r+" : "r" })).sync;
71
+ }));
72
+ }, Effect.mapError(failure));
73
+ const locked = (effect) => gate.withPermits(1)(Effect.uninterruptible(effect));
74
+ const read = locked(Effect.gen(function* () {
75
+ const record = yield* readRecord;
76
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
77
+ const credentials = {};
78
+ for (const slot of slots) {
79
+ const value = record.credentials[slot];
80
+ if (value !== void 0 && value.expiresAtMillis > now) credentials[slot] = value.credential;
81
+ }
82
+ return credentials;
83
+ })).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
84
+ const accept = (commands) => locked(Effect.gen(function* () {
85
+ const record = yield* readRecord;
86
+ const credentials = { ...record.credentials };
87
+ for (const command of commands) if (command._tag === "Clear") delete credentials[command.slot];
88
+ else credentials[command.slot] = {
89
+ credential: command.credential,
90
+ expiresAtMillis: command.expiresAtMillis
91
+ };
92
+ yield* writeRecord({
93
+ ...record,
94
+ credentials
95
+ });
96
+ })).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
97
+ return BrowserLogin.Vault.of({
98
+ read,
99
+ accept,
100
+ loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),
101
+ saveAttempt: (attempt) => locked(Effect.gen(function* () {
102
+ const record = yield* readRecord;
103
+ yield* writeRecord(attempt === void 0 ? { credentials: record.credentials } : {
104
+ ...record,
105
+ attempt
106
+ });
107
+ }))
108
+ });
109
+ }));
110
+ //#endregion
111
+ export { layerVault };
112
+
113
+ //# sourceMappingURL=vault.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vault.mjs","names":[],"sources":["../../src/internal/vault.ts"],"sourcesContent":["import { Buffer } from \"node:buffer\";\n\nimport * as BrowserLogin from \"@yielded/auth/BrowserLogin\";\nimport { OperationHttpError } from \"@yielded/auth/OperationHttp\";\nimport type { NativeCredentials } from \"@yielded/auth/OperationHttpClient\";\nimport type { CredentialSlot } from \"@yielded/auth/Operations\";\nimport {\n DateTime,\n Effect,\n FileSystem,\n Layer,\n Path,\n type Redacted,\n Schema,\n Semaphore,\n} from \"effect\";\nimport { app, safeStorage } from \"electron\";\n\nconst recordSchema = BrowserLogin.VaultRecord;\nconst slots = recordSchema.fields.credentials.key.literals;\n\nconst recordJson = Schema.fromJsonString(recordSchema);\nconst failure = () => BrowserLogin.PlatformError.make({ reason: \"storage\" });\n\n/** One writer per private application/environment path, protected by the host's\n * single-instance lock. Never reset an unreadable store: it may contain the\n * Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,\n * then sync the parent on POSIX (the replaced file on Windows).\n */\nexport const layerVault = (options: { readonly path: string }) =>\n Layer.effect(\n BrowserLogin.Vault,\n Effect.gen(function* () {\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n const checkEncryption = Effect.try({\n try: () =>\n process.type === \"browser\" &&\n app.isReady() &&\n app.hasSingleInstanceLock() &&\n safeStorage.isEncryptionAvailable() &&\n (process.platform !== \"linux\" ||\n [\"gnome_libsecret\", \"kwallet\", \"kwallet5\", \"kwallet6\"].includes(\n safeStorage.getSelectedStorageBackend(),\n )),\n catch: failure,\n }).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(failure()))));\n\n yield* checkEncryption;\n\n const destination = yield* Schema.decodeEffect(\n Schema.NonEmptyString.check(Schema.makeFilter((value) => path.isAbsolute(value))),\n )(options.path).pipe(Effect.mapError(failure));\n\n const directory = path.dirname(destination);\n\n yield* fs\n .makeDirectory(directory, { recursive: true, mode: 0o700 })\n .pipe(Effect.mapError(failure));\n const gate = yield* Semaphore.make(1);\n\n const readRecord = Effect.gen(function* () {\n yield* checkEncryption;\n\n const stat = yield* fs\n .stat(destination)\n .pipe(\n Effect.catchTag(\"PlatformError\", (error) =>\n error.reason._tag === \"NotFound\" ? Effect.void : Effect.fail(error),\n ),\n );\n\n if (stat === undefined) return { credentials: {} } satisfies typeof recordSchema.Type;\n if (stat.type !== \"File\" || stat.size > 1048576) return yield* failure();\n const bytes = yield* fs.readFile(destination);\n\n const json = yield* Effect.try({\n try: () => safeStorage.decryptString(Buffer.from(bytes)),\n catch: failure,\n });\n\n return yield* Schema.decodeEffect(recordJson)(json, { onExcessProperty: \"error\" });\n }).pipe(Effect.mapError(failure));\n\n const writeRecord = Effect.fnUntraced(function* (record: typeof recordSchema.Type) {\n yield* checkEncryption;\n const json = yield* Schema.encodeEffect(recordJson)(record);\n\n const bytes = yield* Effect.try({\n try: () => safeStorage.encryptString(json),\n catch: failure,\n });\n\n yield* Effect.scoped(\n Effect.gen(function* () {\n const temporary = yield* fs.makeTempFileScoped({ directory, prefix: \".auth-\" });\n\n yield* fs.chmod(temporary, 0o600);\n yield* Effect.scoped(\n Effect.gen(function* () {\n const file = yield* fs.open(temporary, { flag: \"w\", mode: 0o600 });\n\n yield* file.writeAll(bytes);\n yield* file.sync;\n }),\n );\n yield* fs.rename(temporary, destination);\n\n const committed = yield* fs.open(\n process.platform === \"win32\" ? destination : directory,\n { flag: process.platform === \"win32\" ? \"r+\" : \"r\" },\n );\n\n yield* committed.sync;\n }),\n );\n }, Effect.mapError(failure));\n\n const locked = <A, E>(effect: Effect.Effect<A, E>) =>\n gate.withPermits(1)(Effect.uninterruptible(effect));\n\n const read: NativeCredentials[\"read\"] = locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n const now = DateTime.toEpochMillis(yield* DateTime.now);\n const credentials: Partial<Record<CredentialSlot, Redacted.Redacted<string>>> = {};\n\n for (const slot of slots) {\n const value = record.credentials[slot];\n\n if (value !== undefined && value.expiresAtMillis > now)\n credentials[slot] = value.credential;\n }\n\n return credentials;\n }),\n ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: \"credentials\" })));\n\n const accept: NativeCredentials[\"accept\"] = (commands) =>\n locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n const credentials = { ...record.credentials };\n\n for (const command of commands) {\n if (command._tag === \"Clear\") delete credentials[command.slot];\n else\n credentials[command.slot] = {\n credential: command.credential,\n expiresAtMillis: command.expiresAtMillis,\n };\n }\n yield* writeRecord({ ...record, credentials });\n }),\n ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: \"credentials\" })));\n\n return BrowserLogin.Vault.of({\n read,\n accept,\n loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),\n saveAttempt: (attempt) =>\n locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n\n yield* writeRecord(\n attempt === undefined\n ? { credentials: record.credentials }\n : { ...record, attempt },\n );\n }),\n ),\n });\n }),\n );\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1 @@
1
+ {"name":"@yielded/auth-electron","version":"0.1.0-beta.17","dependencies":{"@yielded/auth":"0.1.0-beta.17"},"devDependencies":{"@types/node":"26.1.2","effect":"4.0.0","electron":"44.5.1","typescript":"7.0.2","vite-plus":"0.3.3"},"peerDependencies":{"effect":"^4.0.0","electron":"^44.5.1"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./BrowserLogin":{"types":"./dist/BrowserLogin.d.mts","default":"./dist/BrowserLogin.mjs"}},"description":"Main-process Electron browser login and encrypted credential custody for Yielded Auth.","homepage":"https://yielded.dev/auth/","license":"MIT","repository":{"type":"git","url":"git+https://github.com/yielded-dev/auth.git","directory":"packages/auth-electron"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"}}
@@ -0,0 +1,2 @@
1
+ export { makeBrowser, layerBrowser } from "./internal/browser";
2
+ export { layerVault } from "./internal/vault";
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * as BrowserLogin from "./BrowserLogin.ts";
@@ -0,0 +1,221 @@
1
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
2
+ import { Effect, Layer, Schema } from "effect";
3
+ import { app, shell, type Event } from "electron";
4
+
5
+ const unavailable = () => BrowserLogin.PlatformError.make({ reason: "unavailable" });
6
+
7
+ const optionsSchema = Schema.Struct({
8
+ hostedUrl: BrowserLogin.HostedUrl,
9
+ returnUrl: BrowserLogin.ReturnUrl,
10
+ });
11
+
12
+ /** Acquire synchronously in the main process BEFORE app.whenReady(), under the
13
+ * application's Scope. The host must already hold app.requestSingleInstanceLock().
14
+ * No import-time listeners, protocol registration, or external browser launch.
15
+ * HTTPS callbacks require packaged macOS with signed associated domains and a
16
+ * matching website association. Only OS-delivered Universal Links are accepted;
17
+ * this adapter cannot preflight the OS association. Custom schemes require a
18
+ * packaged Info.plist on macOS or installed .desktop handler on Linux. Windows
19
+ * also supports the default Electron executable in development.
20
+ */
21
+ export const makeBrowser = Effect.fnUntraced(function* (options: typeof optionsSchema.Type) {
22
+ if (process.type !== "browser" || !app.hasSingleInstanceLock()) return yield* unavailable();
23
+
24
+ const config = yield* Schema.decodeEffect(optionsSchema)(options).pipe(
25
+ Effect.mapError(unavailable),
26
+ );
27
+
28
+ const target = new URL(config.returnUrl);
29
+ const scheme = target.protocol.slice(0, -1);
30
+ const universalLink = scheme === "https";
31
+
32
+ if (universalLink && (process.platform !== "darwin" || !app.isPackaged || target.port !== ""))
33
+ return yield* unavailable();
34
+
35
+ const callbackSchema = Schema.String.check(
36
+ Schema.isMaxLength(2304),
37
+ Schema.makeFilter((text) => {
38
+ try {
39
+ const url = new URL(text);
40
+ const keys = [...url.searchParams.keys()];
41
+
42
+ if (
43
+ url.href !== text ||
44
+ url.username ||
45
+ url.password ||
46
+ url.hash ||
47
+ keys.length !== 2 ||
48
+ !keys.includes("code") ||
49
+ !keys.includes("state") ||
50
+ !Schema.is(BrowserLogin.Random)(url.searchParams.get("code")) ||
51
+ !Schema.is(BrowserLogin.Random)(url.searchParams.get("state"))
52
+ )
53
+ return false;
54
+ url.search = "";
55
+
56
+ return url.href === config.returnUrl;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }),
61
+ );
62
+
63
+ const openSchema = Schema.Struct({
64
+ returnUrl: Schema.Literal(config.returnUrl),
65
+ ephemeral: Schema.Literal(false),
66
+ url: Schema.String.check(
67
+ Schema.isMaxLength(config.hostedUrl.length + "?attempt=".length + 43),
68
+ Schema.makeFilter((text) => {
69
+ try {
70
+ const url = new URL(text);
71
+
72
+ if (
73
+ url.href !== text ||
74
+ url.hash ||
75
+ [...url.searchParams.keys()].join() !== "attempt" ||
76
+ !Schema.is(BrowserLogin.Random)(url.searchParams.get("attempt"))
77
+ )
78
+ return false;
79
+ url.search = "";
80
+
81
+ return url.href === config.hostedUrl;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }),
86
+ ),
87
+ });
88
+
89
+ const queued: string[] = [];
90
+ let receive: ((url: string) => void) | undefined;
91
+ let close: (() => void) | undefined;
92
+ let disposed = false;
93
+
94
+ const capture = (url: string) => {
95
+ if (disposed || !Schema.is(callbackSchema)(url)) return;
96
+ if (receive !== undefined) receive(url);
97
+ else if (!queued.includes(url) && queued.length < 8) queued.push(url);
98
+ };
99
+
100
+ const openUrl = (event: Event, url: string) => {
101
+ if (!Schema.is(callbackSchema)(url)) return;
102
+ event.preventDefault();
103
+ capture(url);
104
+ };
105
+
106
+ const secondInstance = (_event: Event, argv: string[]) => {
107
+ for (const arg of argv) capture(arg);
108
+ };
109
+
110
+ const activitySchema = Schema.Struct({ webpageURL: callbackSchema });
111
+
112
+ const continueActivity = (event: Event, type: string, _userInfo: unknown, details: unknown) => {
113
+ if (type !== "NSUserActivityTypeBrowsingWeb" || !Schema.is(activitySchema)(details)) return;
114
+ event.preventDefault();
115
+ capture(details.webpageURL);
116
+ };
117
+
118
+ yield* Effect.acquireRelease(
119
+ Effect.sync(() => {
120
+ if (universalLink) app.on("continue-activity", continueActivity);
121
+ else {
122
+ app.on("open-url", openUrl);
123
+ app.on("second-instance", secondInstance);
124
+ for (const arg of process.argv) capture(arg);
125
+ }
126
+ }),
127
+ () =>
128
+ Effect.sync(() => {
129
+ disposed = true;
130
+ if (universalLink) app.removeListener("continue-activity", continueActivity);
131
+ else {
132
+ app.removeListener("open-url", openUrl);
133
+ app.removeListener("second-instance", secondInstance);
134
+ }
135
+ queued.length = 0;
136
+ close?.();
137
+ }),
138
+ );
139
+
140
+ const prepare = Effect.try({
141
+ try: () => {
142
+ if (!app.isReady() || disposed) return false;
143
+ // Associated domains are verified by macOS when routing the callback.
144
+ // Registering as the default HTTPS handler would not establish ownership.
145
+ if (universalLink) return process.platform === "darwin" && app.isPackaged;
146
+ if (process.platform === "linux")
147
+ return app.isPackaged && app.isDefaultProtocolClient(scheme);
148
+ if (process.platform === "darwin")
149
+ return (
150
+ app.isPackaged &&
151
+ app.setAsDefaultProtocolClient(scheme) &&
152
+ app.isDefaultProtocolClient(scheme)
153
+ );
154
+ if (process.platform !== "win32") return false;
155
+ const args = process.defaultApp ? [app.getAppPath()] : [];
156
+
157
+ return (
158
+ app.setAsDefaultProtocolClient(scheme, process.execPath, args) &&
159
+ app.isDefaultProtocolClient(scheme, process.execPath, args)
160
+ );
161
+ },
162
+ catch: unavailable,
163
+ }).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(unavailable()))));
164
+
165
+ return BrowserLogin.Browser.of({
166
+ open: Effect.fnUntraced(function* (input) {
167
+ const request = yield* Schema.decodeUnknownEffect(openSchema)(input).pipe(
168
+ Effect.mapError(unavailable),
169
+ );
170
+
171
+ yield* prepare;
172
+
173
+ return yield* Effect.callback<string, BrowserLogin.PlatformError>((resume) => {
174
+ if (receive !== undefined) {
175
+ resume(Effect.fail(BrowserLogin.PlatformError.make({ reason: "busy" })));
176
+
177
+ return;
178
+ }
179
+ let active = true;
180
+
181
+ const cleanup = () => {
182
+ if (!active) return;
183
+ active = false;
184
+ receive = undefined;
185
+ close = undefined;
186
+ };
187
+
188
+ const finish = (result: Effect.Effect<string, BrowserLogin.PlatformError>) => {
189
+ if (active) {
190
+ cleanup();
191
+ resume(result);
192
+ }
193
+ };
194
+
195
+ receive = (url) => finish(Effect.succeed(url));
196
+ close = () => finish(Effect.fail(unavailable()));
197
+ const pending = queued.shift();
198
+
199
+ if (pending !== undefined) receive(pending);
200
+ else {
201
+ try {
202
+ void shell.openExternal(request.url).catch(() => finish(Effect.fail(unavailable())));
203
+ } catch {
204
+ finish(Effect.fail(unavailable()));
205
+ }
206
+ }
207
+
208
+ return Effect.sync(cleanup);
209
+ }).pipe(
210
+ Effect.timeout("10 minutes"),
211
+ Effect.catchTag("TimeoutError", () =>
212
+ Effect.fail(BrowserLogin.PlatformError.make({ reason: "expired" })),
213
+ ),
214
+ );
215
+ }),
216
+ });
217
+ });
218
+
219
+ /** Build before app readiness, or use makeBrowser for synchronous startup capture. */
220
+ export const layerBrowser = (options: typeof optionsSchema.Type) =>
221
+ Layer.effect(BrowserLogin.Browser, makeBrowser(options));
@@ -0,0 +1,176 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
4
+ import { OperationHttpError } from "@yielded/auth/OperationHttp";
5
+ import type { NativeCredentials } from "@yielded/auth/OperationHttpClient";
6
+ import type { CredentialSlot } from "@yielded/auth/Operations";
7
+ import {
8
+ DateTime,
9
+ Effect,
10
+ FileSystem,
11
+ Layer,
12
+ Path,
13
+ type Redacted,
14
+ Schema,
15
+ Semaphore,
16
+ } from "effect";
17
+ import { app, safeStorage } from "electron";
18
+
19
+ const recordSchema = BrowserLogin.VaultRecord;
20
+ const slots = recordSchema.fields.credentials.key.literals;
21
+
22
+ const recordJson = Schema.fromJsonString(recordSchema);
23
+ const failure = () => BrowserLogin.PlatformError.make({ reason: "storage" });
24
+
25
+ /** One writer per private application/environment path, protected by the host's
26
+ * single-instance lock. Never reset an unreadable store: it may contain the
27
+ * Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,
28
+ * then sync the parent on POSIX (the replaced file on Windows).
29
+ */
30
+ export const layerVault = (options: { readonly path: string }) =>
31
+ Layer.effect(
32
+ BrowserLogin.Vault,
33
+ Effect.gen(function* () {
34
+ const fs = yield* FileSystem.FileSystem;
35
+ const path = yield* Path.Path;
36
+
37
+ const checkEncryption = Effect.try({
38
+ try: () =>
39
+ process.type === "browser" &&
40
+ app.isReady() &&
41
+ app.hasSingleInstanceLock() &&
42
+ safeStorage.isEncryptionAvailable() &&
43
+ (process.platform !== "linux" ||
44
+ ["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"].includes(
45
+ safeStorage.getSelectedStorageBackend(),
46
+ )),
47
+ catch: failure,
48
+ }).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(failure()))));
49
+
50
+ yield* checkEncryption;
51
+
52
+ const destination = yield* Schema.decodeEffect(
53
+ Schema.NonEmptyString.check(Schema.makeFilter((value) => path.isAbsolute(value))),
54
+ )(options.path).pipe(Effect.mapError(failure));
55
+
56
+ const directory = path.dirname(destination);
57
+
58
+ yield* fs
59
+ .makeDirectory(directory, { recursive: true, mode: 0o700 })
60
+ .pipe(Effect.mapError(failure));
61
+ const gate = yield* Semaphore.make(1);
62
+
63
+ const readRecord = Effect.gen(function* () {
64
+ yield* checkEncryption;
65
+
66
+ const stat = yield* fs
67
+ .stat(destination)
68
+ .pipe(
69
+ Effect.catchTag("PlatformError", (error) =>
70
+ error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
71
+ ),
72
+ );
73
+
74
+ if (stat === undefined) return { credentials: {} } satisfies typeof recordSchema.Type;
75
+ if (stat.type !== "File" || stat.size > 1048576) return yield* failure();
76
+ const bytes = yield* fs.readFile(destination);
77
+
78
+ const json = yield* Effect.try({
79
+ try: () => safeStorage.decryptString(Buffer.from(bytes)),
80
+ catch: failure,
81
+ });
82
+
83
+ return yield* Schema.decodeEffect(recordJson)(json, { onExcessProperty: "error" });
84
+ }).pipe(Effect.mapError(failure));
85
+
86
+ const writeRecord = Effect.fnUntraced(function* (record: typeof recordSchema.Type) {
87
+ yield* checkEncryption;
88
+ const json = yield* Schema.encodeEffect(recordJson)(record);
89
+
90
+ const bytes = yield* Effect.try({
91
+ try: () => safeStorage.encryptString(json),
92
+ catch: failure,
93
+ });
94
+
95
+ yield* Effect.scoped(
96
+ Effect.gen(function* () {
97
+ const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".auth-" });
98
+
99
+ yield* fs.chmod(temporary, 0o600);
100
+ yield* Effect.scoped(
101
+ Effect.gen(function* () {
102
+ const file = yield* fs.open(temporary, { flag: "w", mode: 0o600 });
103
+
104
+ yield* file.writeAll(bytes);
105
+ yield* file.sync;
106
+ }),
107
+ );
108
+ yield* fs.rename(temporary, destination);
109
+
110
+ const committed = yield* fs.open(
111
+ process.platform === "win32" ? destination : directory,
112
+ { flag: process.platform === "win32" ? "r+" : "r" },
113
+ );
114
+
115
+ yield* committed.sync;
116
+ }),
117
+ );
118
+ }, Effect.mapError(failure));
119
+
120
+ const locked = <A, E>(effect: Effect.Effect<A, E>) =>
121
+ gate.withPermits(1)(Effect.uninterruptible(effect));
122
+
123
+ const read: NativeCredentials["read"] = locked(
124
+ Effect.gen(function* () {
125
+ const record = yield* readRecord;
126
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
127
+ const credentials: Partial<Record<CredentialSlot, Redacted.Redacted<string>>> = {};
128
+
129
+ for (const slot of slots) {
130
+ const value = record.credentials[slot];
131
+
132
+ if (value !== undefined && value.expiresAtMillis > now)
133
+ credentials[slot] = value.credential;
134
+ }
135
+
136
+ return credentials;
137
+ }),
138
+ ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
139
+
140
+ const accept: NativeCredentials["accept"] = (commands) =>
141
+ locked(
142
+ Effect.gen(function* () {
143
+ const record = yield* readRecord;
144
+ const credentials = { ...record.credentials };
145
+
146
+ for (const command of commands) {
147
+ if (command._tag === "Clear") delete credentials[command.slot];
148
+ else
149
+ credentials[command.slot] = {
150
+ credential: command.credential,
151
+ expiresAtMillis: command.expiresAtMillis,
152
+ };
153
+ }
154
+ yield* writeRecord({ ...record, credentials });
155
+ }),
156
+ ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
157
+
158
+ return BrowserLogin.Vault.of({
159
+ read,
160
+ accept,
161
+ loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),
162
+ saveAttempt: (attempt) =>
163
+ locked(
164
+ Effect.gen(function* () {
165
+ const record = yield* readRecord;
166
+
167
+ yield* writeRecord(
168
+ attempt === undefined
169
+ ? { credentials: record.credentials }
170
+ : { ...record, attempt },
171
+ );
172
+ }),
173
+ ),
174
+ });
175
+ }),
176
+ );