@frockbot/plugin-auth 0.0.0 → 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/frockbot.json +21 -0
- package/package.json +37 -6
- package/src/client/AuthGate.vue +174 -0
- package/src/client/browser.ts +70 -0
- package/src/client/development-login.ts +24 -0
- package/src/client/hosted-session.test.ts +185 -0
- package/src/client/hosted-session.ts +157 -0
- package/src/client/index.ts +15 -0
- package/src/client/session.test.ts +123 -0
- package/src/client/session.ts +67 -0
- package/src/client/styles.css +171 -0
- package/src/client.test.ts +12 -0
- package/src/desktop.test.ts +40 -0
- package/src/desktop.ts +21 -0
- package/src/development-login.test.ts +37 -0
- package/src/env.d.ts +43 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/shared.ts +122 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { decodeAuthSessionProjectionV1 } from "../shared.js";
|
|
3
|
+
import { createAuthSessionClient } from "./session.js";
|
|
4
|
+
|
|
5
|
+
const authenticated = {
|
|
6
|
+
schemaVersion: 1,
|
|
7
|
+
status: "authenticated",
|
|
8
|
+
mode: "better-auth",
|
|
9
|
+
user: {
|
|
10
|
+
id: "alice",
|
|
11
|
+
name: "Alice",
|
|
12
|
+
email: "alice@example.com",
|
|
13
|
+
isAdmin: false,
|
|
14
|
+
},
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
describe("auth session projection", () => {
|
|
18
|
+
test("strictly decodes exact session modes", () => {
|
|
19
|
+
expect(decodeAuthSessionProjectionV1(authenticated)).toEqual(authenticated);
|
|
20
|
+
expect(
|
|
21
|
+
decodeAuthSessionProjectionV1({ schemaVersion: 1, status: "anonymous" }),
|
|
22
|
+
).toEqual({ schemaVersion: 1, status: "anonymous" });
|
|
23
|
+
|
|
24
|
+
for (const invalid of [
|
|
25
|
+
{ ...authenticated, extra: true },
|
|
26
|
+
{ ...authenticated, user: { ...authenticated.user, extra: true } },
|
|
27
|
+
{ ...authenticated, mode: "cookie" },
|
|
28
|
+
]) {
|
|
29
|
+
expect(() => decodeAuthSessionProjectionV1(invalid)).toThrow();
|
|
30
|
+
}
|
|
31
|
+
const hidden = { ...authenticated } as Record<PropertyKey, unknown>;
|
|
32
|
+
Object.defineProperty(hidden, "hidden", { value: true });
|
|
33
|
+
expect(() => decodeAuthSessionProjectionV1(hidden)).toThrow();
|
|
34
|
+
expect(() =>
|
|
35
|
+
decodeAuthSessionProjectionV1({
|
|
36
|
+
...authenticated,
|
|
37
|
+
[Symbol("hidden")]: true,
|
|
38
|
+
}),
|
|
39
|
+
).toThrow();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("auth-owned sign-out", () => {
|
|
44
|
+
test("serializes double-click, refreshes authority, and is safely repeatable", async () => {
|
|
45
|
+
let current: unknown = authenticated;
|
|
46
|
+
let calls = 0;
|
|
47
|
+
let release: (() => void) | undefined;
|
|
48
|
+
const pending = new Promise<void>((resolve) => {
|
|
49
|
+
release = resolve;
|
|
50
|
+
});
|
|
51
|
+
const session = createAuthSessionClient({
|
|
52
|
+
read: () => Promise.resolve(current),
|
|
53
|
+
signOut: async () => {
|
|
54
|
+
calls += 1;
|
|
55
|
+
await pending;
|
|
56
|
+
current = { schemaVersion: 1, status: "anonymous" };
|
|
57
|
+
return current;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
await session.refresh();
|
|
61
|
+
|
|
62
|
+
const first = session.signOut();
|
|
63
|
+
const second = session.signOut();
|
|
64
|
+
expect(session.signingOut.value).toBe(true);
|
|
65
|
+
expect(calls).toBe(1);
|
|
66
|
+
release?.();
|
|
67
|
+
await Promise.all([first, second]);
|
|
68
|
+
|
|
69
|
+
expect(session.projection.value).toEqual({
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
status: "anonymous",
|
|
72
|
+
});
|
|
73
|
+
expect(session.signingOut.value).toBe(false);
|
|
74
|
+
await session.signOut();
|
|
75
|
+
expect(calls).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("keeps the authenticated projection when sign-out fails", async () => {
|
|
79
|
+
const session = createAuthSessionClient({
|
|
80
|
+
read: () => Promise.resolve(authenticated),
|
|
81
|
+
signOut: () => Promise.reject(new Error("network unavailable")),
|
|
82
|
+
});
|
|
83
|
+
await session.refresh();
|
|
84
|
+
|
|
85
|
+
await expect(session.signOut()).rejects.toThrow("network unavailable");
|
|
86
|
+
expect(session.projection.value).toEqual(authenticated);
|
|
87
|
+
expect(session.signingOut.value).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("makes development identity sign-out explicitly unavailable", async () => {
|
|
91
|
+
let calls = 0;
|
|
92
|
+
const session = createAuthSessionClient({
|
|
93
|
+
read: () =>
|
|
94
|
+
Promise.resolve({
|
|
95
|
+
...authenticated,
|
|
96
|
+
mode: "development",
|
|
97
|
+
}),
|
|
98
|
+
signOut: () => {
|
|
99
|
+
calls += 1;
|
|
100
|
+
return Promise.resolve({ schemaVersion: 1, status: "anonymous" });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
await session.refresh();
|
|
104
|
+
|
|
105
|
+
await expect(session.signOut()).rejects.toThrow(
|
|
106
|
+
"Development identity is selected by the local development login",
|
|
107
|
+
);
|
|
108
|
+
expect(calls).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("requires authoritative anonymous state after successful sign-out", async () => {
|
|
112
|
+
const session = createAuthSessionClient({
|
|
113
|
+
read: () => Promise.resolve(authenticated),
|
|
114
|
+
signOut: () => Promise.resolve(authenticated),
|
|
115
|
+
});
|
|
116
|
+
await session.refresh();
|
|
117
|
+
|
|
118
|
+
await expect(session.signOut()).rejects.toThrow(
|
|
119
|
+
"Sign-out did not clear the authenticated session",
|
|
120
|
+
);
|
|
121
|
+
expect(session.projection.value).toEqual(authenticated);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readonly, ref } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
decodeAuthSessionProjectionV1,
|
|
4
|
+
type AuthSessionClient,
|
|
5
|
+
type AuthSessionProjectionV1,
|
|
6
|
+
} from "../shared.js";
|
|
7
|
+
|
|
8
|
+
export interface AuthSessionAdapter {
|
|
9
|
+
read(): Promise<unknown>;
|
|
10
|
+
/** Returns the authoritative post-command auth projection. */
|
|
11
|
+
signOut(): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createAuthSessionClient(
|
|
15
|
+
adapter: AuthSessionAdapter,
|
|
16
|
+
): AuthSessionClient {
|
|
17
|
+
const projection = ref<AuthSessionProjectionV1>({
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
status: "loading",
|
|
20
|
+
});
|
|
21
|
+
const signingOut = ref(false);
|
|
22
|
+
let pendingSignOut: Promise<void> | undefined;
|
|
23
|
+
|
|
24
|
+
const refresh = async (): Promise<void> => {
|
|
25
|
+
projection.value = decodeAuthSessionProjectionV1(await adapter.read());
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const signOut = (): Promise<void> => {
|
|
29
|
+
if (pendingSignOut) return pendingSignOut;
|
|
30
|
+
const current = projection.value;
|
|
31
|
+
if (current.status === "anonymous") return Promise.resolve();
|
|
32
|
+
if (current.status === "loading") {
|
|
33
|
+
return Promise.reject(new Error("Authentication is still loading"));
|
|
34
|
+
}
|
|
35
|
+
if (current.mode === "development") {
|
|
36
|
+
return Promise.reject(
|
|
37
|
+
new Error(
|
|
38
|
+
"Development identity is selected by the local development login and cannot be signed out",
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
signingOut.value = true;
|
|
44
|
+
pendingSignOut = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
const authoritative = decodeAuthSessionProjectionV1(
|
|
47
|
+
await adapter.signOut(),
|
|
48
|
+
);
|
|
49
|
+
if (authoritative.status !== "anonymous") {
|
|
50
|
+
throw new Error("Sign-out did not clear the authenticated session");
|
|
51
|
+
}
|
|
52
|
+
projection.value = authoritative;
|
|
53
|
+
} finally {
|
|
54
|
+
signingOut.value = false;
|
|
55
|
+
pendingSignOut = undefined;
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
return pendingSignOut;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
projection: readonly(projection),
|
|
63
|
+
signingOut: readonly(signingOut),
|
|
64
|
+
refresh,
|
|
65
|
+
signOut,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
.auth-screen {
|
|
2
|
+
display: grid;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
place-items: center;
|
|
6
|
+
padding: 32px;
|
|
7
|
+
color: var(--frock-text);
|
|
8
|
+
background: var(--frock-auth-background), var(--frock-surface-window);
|
|
9
|
+
font-family:
|
|
10
|
+
Manrope,
|
|
11
|
+
ui-sans-serif,
|
|
12
|
+
-apple-system,
|
|
13
|
+
BlinkMacSystemFont,
|
|
14
|
+
"Segoe UI",
|
|
15
|
+
sans-serif;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.auth-card {
|
|
19
|
+
width: min(100%, 410px);
|
|
20
|
+
padding: 44px;
|
|
21
|
+
border: 1px solid var(--frock-border);
|
|
22
|
+
border-radius: 24px;
|
|
23
|
+
background: var(--frock-surface-raised);
|
|
24
|
+
box-shadow: var(--frock-shadow-auth);
|
|
25
|
+
text-align: center;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.auth-mark {
|
|
29
|
+
display: grid;
|
|
30
|
+
width: 56px;
|
|
31
|
+
height: 56px;
|
|
32
|
+
place-items: center;
|
|
33
|
+
margin: 0 auto 22px;
|
|
34
|
+
border-radius: 18px;
|
|
35
|
+
color: var(--frock-action-primary-hover);
|
|
36
|
+
background: var(--frock-surface-accent);
|
|
37
|
+
font-size: var(--frock-text-display);
|
|
38
|
+
font-weight: 700;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.auth-eyebrow {
|
|
42
|
+
margin: 0 0 9px;
|
|
43
|
+
color: var(--frock-action-primary-hover);
|
|
44
|
+
font-size: var(--frock-text-sm);
|
|
45
|
+
font-weight: 800;
|
|
46
|
+
letter-spacing: 0.16em;
|
|
47
|
+
text-transform: uppercase;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.auth-card h1 {
|
|
51
|
+
margin: 0;
|
|
52
|
+
color: var(--frock-text);
|
|
53
|
+
font-family: "Archivo Black", Manrope, sans-serif;
|
|
54
|
+
font-size: var(--frock-text-display);
|
|
55
|
+
font-weight: 400;
|
|
56
|
+
letter-spacing: -0.05em;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.auth-copy {
|
|
60
|
+
margin: 13px 0 30px;
|
|
61
|
+
color: var(--frock-text-muted);
|
|
62
|
+
font-size: var(--frock-text-md);
|
|
63
|
+
line-height: 1.6;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.auth-actions {
|
|
67
|
+
display: grid;
|
|
68
|
+
gap: 11px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.google-button,
|
|
72
|
+
.dev-button {
|
|
73
|
+
display: flex;
|
|
74
|
+
width: 100%;
|
|
75
|
+
min-height: 50px;
|
|
76
|
+
align-items: center;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
gap: 11px;
|
|
79
|
+
border: 1px solid var(--frock-border);
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
color: var(--frock-text);
|
|
82
|
+
background: var(--frock-surface-subtle);
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
transition:
|
|
86
|
+
transform 180ms ease,
|
|
87
|
+
background 180ms ease,
|
|
88
|
+
border-color 180ms ease;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.dev-button {
|
|
92
|
+
border-color: var(--frock-action-primary);
|
|
93
|
+
color: var(--frock-on-accent);
|
|
94
|
+
background: var(--frock-action-primary);
|
|
95
|
+
box-shadow: var(--frock-shadow-accent);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.google-button:hover:not(:disabled) {
|
|
99
|
+
border-color: var(--frock-border-strong);
|
|
100
|
+
background: var(--frock-surface-subtle);
|
|
101
|
+
transform: translateY(-1px);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.dev-button:hover:not(:disabled) {
|
|
105
|
+
border-color: var(--frock-action-primary-hover);
|
|
106
|
+
background: var(--frock-action-primary-hover);
|
|
107
|
+
transform: translateY(-1px);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.google-button:focus-visible,
|
|
111
|
+
.dev-button:focus-visible {
|
|
112
|
+
outline: 3px solid var(--frock-focus-ring);
|
|
113
|
+
outline-offset: 3px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.google-button:disabled,
|
|
117
|
+
.dev-button:disabled {
|
|
118
|
+
box-shadow: none;
|
|
119
|
+
cursor: wait;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.google-button:disabled {
|
|
123
|
+
color: var(--frock-text-subtle);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.dev-button:disabled {
|
|
127
|
+
color: var(--frock-on-accent);
|
|
128
|
+
opacity: 0.62;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.google-g {
|
|
132
|
+
display: grid;
|
|
133
|
+
width: 23px;
|
|
134
|
+
height: 23px;
|
|
135
|
+
place-items: center;
|
|
136
|
+
border: 1px solid var(--frock-border);
|
|
137
|
+
border-radius: 50%;
|
|
138
|
+
color: var(--frock-provider-google);
|
|
139
|
+
background: var(--frock-surface-subtle);
|
|
140
|
+
font-family: Arial, sans-serif;
|
|
141
|
+
font-size: var(--frock-text-md);
|
|
142
|
+
font-weight: 700;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.auth-loading,
|
|
146
|
+
.auth-hint,
|
|
147
|
+
.auth-error {
|
|
148
|
+
color: var(--frock-text-muted);
|
|
149
|
+
font-size: var(--frock-text-base);
|
|
150
|
+
line-height: 1.5;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.auth-loading {
|
|
154
|
+
min-height: 46px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.auth-hint,
|
|
158
|
+
.auth-error {
|
|
159
|
+
margin: 16px 0 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.auth-error {
|
|
163
|
+
color: var(--frock-danger-text);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@media (prefers-reduced-motion: reduce) {
|
|
167
|
+
.google-button,
|
|
168
|
+
.dev-button {
|
|
169
|
+
transition: none;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { verifyPluginPackage } from "@frockbot/plugin-testkit";
|
|
4
|
+
import manifest from "../frockbot.json" with { type: "json" };
|
|
5
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
test("auth satisfies plugin package conventions", () => {
|
|
8
|
+
expect(verifyPluginPackage({ packageJson, manifest })).toMatchObject({
|
|
9
|
+
name: "@frockbot/plugin-auth",
|
|
10
|
+
contributionKinds: ["client", "desktop"],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createPluginHarness } from "@frockbot/plugin-testkit";
|
|
3
|
+
import manifest from "../frockbot.json" with { type: "json" };
|
|
4
|
+
import desktopAuthPlugin, { DesktopAuthCapability } from "./desktop.js";
|
|
5
|
+
|
|
6
|
+
class FakeDesktopAuthCapability extends DesktopAuthCapability {
|
|
7
|
+
starts = 0;
|
|
8
|
+
stops = 0;
|
|
9
|
+
|
|
10
|
+
start(): () => void {
|
|
11
|
+
this.starts += 1;
|
|
12
|
+
return () => {
|
|
13
|
+
this.stops += 1;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("desktop authentication Contribution", () => {
|
|
19
|
+
test("declares one trusted main-process Contribution", () => {
|
|
20
|
+
expect(manifest.schemaVersion).toBe(3);
|
|
21
|
+
expect(manifest.contributions.desktop).toEqual({
|
|
22
|
+
entry: "./desktop",
|
|
23
|
+
execution: "trusted-main",
|
|
24
|
+
commands: [],
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("owns setup and cleanup for its mounted lifetime", async () => {
|
|
29
|
+
const harness = await createPluginHarness([FakeDesktopAuthCapability]);
|
|
30
|
+
const capability = harness.root
|
|
31
|
+
.desktopAuthCapability as FakeDesktopAuthCapability;
|
|
32
|
+
|
|
33
|
+
const mounted = await harness.mount(desktopAuthPlugin);
|
|
34
|
+
expect(capability.starts).toBe(1);
|
|
35
|
+
expect(capability.stops).toBe(0);
|
|
36
|
+
|
|
37
|
+
await mounted.dispose();
|
|
38
|
+
expect(capability.stops).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/desktop.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Context, type Plugin, Service } from "cordis";
|
|
2
|
+
|
|
3
|
+
export abstract class DesktopAuthCapability extends Service {
|
|
4
|
+
constructor(ctx: Context) {
|
|
5
|
+
super(ctx, "desktopAuthCapability");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
abstract start(): () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "cordis" {
|
|
12
|
+
interface Context {
|
|
13
|
+
desktopAuthCapability: DesktopAuthCapability;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const desktopAuthPlugin: Plugin.Function = (ctx) =>
|
|
18
|
+
ctx.desktopAuthCapability.start();
|
|
19
|
+
desktopAuthPlugin.inject = ["desktopAuthCapability"];
|
|
20
|
+
|
|
21
|
+
export default desktopAuthPlugin;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
developmentLoginUrl,
|
|
4
|
+
developmentUserFromUrl,
|
|
5
|
+
isLoopbackHost,
|
|
6
|
+
} from "./client/development-login";
|
|
7
|
+
|
|
8
|
+
describe("development login", () => {
|
|
9
|
+
test("is available only on loopback hosts", () => {
|
|
10
|
+
expect(isLoopbackHost("localhost")).toBe(true);
|
|
11
|
+
expect(isLoopbackHost("127.0.0.1")).toBe(true);
|
|
12
|
+
expect(isLoopbackHost("::1")).toBe(true);
|
|
13
|
+
expect(isLoopbackHost("frockbot.example.com")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("recognizes only the fixed development identity", () => {
|
|
17
|
+
expect(
|
|
18
|
+
developmentUserFromUrl(
|
|
19
|
+
new URL("http://localhost:5173/?as_user=development"),
|
|
20
|
+
),
|
|
21
|
+
).toBe("development");
|
|
22
|
+
expect(
|
|
23
|
+
developmentUserFromUrl(new URL("http://localhost:5173/?as_user=alice")),
|
|
24
|
+
).toBeUndefined();
|
|
25
|
+
expect(
|
|
26
|
+
developmentUserFromUrl(
|
|
27
|
+
new URL("https://frockbot.example.com/?as_user=development"),
|
|
28
|
+
),
|
|
29
|
+
).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("builds a login URL without dropping existing state", () => {
|
|
33
|
+
expect(
|
|
34
|
+
developmentLoginUrl(new URL("http://localhost:5173/?bot=demo#chat")),
|
|
35
|
+
).toBe("http://localhost:5173/?bot=demo&as_user=development#chat");
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
interface DesktopAuthUser {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
email: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DesktopApiRequest {
|
|
10
|
+
path: string;
|
|
11
|
+
method: "GET" | "POST";
|
|
12
|
+
body?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface DesktopApiResponse {
|
|
16
|
+
status: number;
|
|
17
|
+
contentType: string | null;
|
|
18
|
+
body: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Window {
|
|
22
|
+
frockbotDesktop?: {
|
|
23
|
+
request(request: DesktopApiRequest): Promise<DesktopApiResponse>;
|
|
24
|
+
};
|
|
25
|
+
getUser(): Promise<DesktopAuthUser | null>;
|
|
26
|
+
requestAuth(options?: { provider?: string }): Promise<void>;
|
|
27
|
+
onAuthenticated(callback: (user: DesktopAuthUser) => unknown): () => void;
|
|
28
|
+
onUserUpdated(
|
|
29
|
+
callback: (user: DesktopAuthUser | null) => unknown,
|
|
30
|
+
): () => void;
|
|
31
|
+
onAuthError(callback: (context: { message?: string }) => unknown): () => void;
|
|
32
|
+
signOut(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare module "*.vue" {
|
|
36
|
+
import type { DefineComponent } from "vue";
|
|
37
|
+
const component: DefineComponent<
|
|
38
|
+
Record<string, never>,
|
|
39
|
+
Record<string, never>,
|
|
40
|
+
unknown
|
|
41
|
+
>;
|
|
42
|
+
export default component;
|
|
43
|
+
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
package/src/shared.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export interface AuthenticatedUserV1 {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
email: string;
|
|
7
|
+
isAdmin: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type AuthSessionProjectionV1 =
|
|
11
|
+
| { schemaVersion: 1; status: "loading" }
|
|
12
|
+
| { schemaVersion: 1; status: "anonymous" }
|
|
13
|
+
| {
|
|
14
|
+
schemaVersion: 1;
|
|
15
|
+
status: "authenticated";
|
|
16
|
+
mode: "better-auth" | "desktop" | "development";
|
|
17
|
+
user: AuthenticatedUserV1;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface AuthSessionClient {
|
|
21
|
+
readonly projection: Readonly<Ref<AuthSessionProjectionV1>>;
|
|
22
|
+
readonly signingOut: Readonly<Ref<boolean>>;
|
|
23
|
+
refresh(): Promise<void>;
|
|
24
|
+
signOut(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const authSessionClientKey: InjectionKey<AuthSessionClient> = Symbol(
|
|
28
|
+
"frockbot.auth-session-client",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
function record(value: unknown, label: string): Record<PropertyKey, unknown> {
|
|
32
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
33
|
+
throw new Error(`${label} must be an object`);
|
|
34
|
+
}
|
|
35
|
+
return value as Record<PropertyKey, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function exactKeys(
|
|
39
|
+
value: Record<PropertyKey, unknown>,
|
|
40
|
+
expected: readonly string[],
|
|
41
|
+
label: string,
|
|
42
|
+
): void {
|
|
43
|
+
const keys = Reflect.ownKeys(value);
|
|
44
|
+
if (
|
|
45
|
+
keys.length !== expected.length ||
|
|
46
|
+
!keys.every((key) => typeof key === "string" && expected.includes(key))
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`${label} has unknown fields`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stringField(
|
|
53
|
+
value: Record<PropertyKey, unknown>,
|
|
54
|
+
key: string,
|
|
55
|
+
label: string,
|
|
56
|
+
): string {
|
|
57
|
+
const field = value[key];
|
|
58
|
+
if (typeof field !== "string") throw new Error(`${label}.${key} is invalid`);
|
|
59
|
+
return field;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function booleanField(
|
|
63
|
+
value: Record<PropertyKey, unknown>,
|
|
64
|
+
key: string,
|
|
65
|
+
label: string,
|
|
66
|
+
): boolean {
|
|
67
|
+
const field = value[key];
|
|
68
|
+
if (typeof field !== "boolean") {
|
|
69
|
+
throw new Error(`${label}.${key} is invalid`);
|
|
70
|
+
}
|
|
71
|
+
return field;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Strictly decodes the auth-owned projection exposed to client Plugins. */
|
|
75
|
+
export function decodeAuthSessionProjectionV1(
|
|
76
|
+
input: unknown,
|
|
77
|
+
): Exclude<AuthSessionProjectionV1, { status: "loading" }> {
|
|
78
|
+
const projection = record(input, "auth session projection");
|
|
79
|
+
if (projection.schemaVersion !== 1) {
|
|
80
|
+
throw new Error("auth session projection.schemaVersion is invalid");
|
|
81
|
+
}
|
|
82
|
+
if (projection.status === "anonymous") {
|
|
83
|
+
exactKeys(
|
|
84
|
+
projection,
|
|
85
|
+
["schemaVersion", "status"],
|
|
86
|
+
"auth session projection",
|
|
87
|
+
);
|
|
88
|
+
return { schemaVersion: 1, status: "anonymous" };
|
|
89
|
+
}
|
|
90
|
+
if (projection.status !== "authenticated") {
|
|
91
|
+
throw new Error("auth session projection.status is invalid");
|
|
92
|
+
}
|
|
93
|
+
exactKeys(
|
|
94
|
+
projection,
|
|
95
|
+
["schemaVersion", "status", "mode", "user"],
|
|
96
|
+
"auth session projection",
|
|
97
|
+
);
|
|
98
|
+
if (
|
|
99
|
+
projection.mode !== "better-auth" &&
|
|
100
|
+
projection.mode !== "desktop" &&
|
|
101
|
+
projection.mode !== "development"
|
|
102
|
+
) {
|
|
103
|
+
throw new Error("auth session projection.mode is invalid");
|
|
104
|
+
}
|
|
105
|
+
const user = record(projection.user, "auth session projection.user");
|
|
106
|
+
exactKeys(
|
|
107
|
+
user,
|
|
108
|
+
["id", "name", "email", "isAdmin"],
|
|
109
|
+
"auth session projection.user",
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
status: "authenticated",
|
|
114
|
+
mode: projection.mode,
|
|
115
|
+
user: {
|
|
116
|
+
id: stringField(user, "id", "auth session projection.user"),
|
|
117
|
+
name: stringField(user, "name", "auth session projection.user"),
|
|
118
|
+
email: stringField(user, "email", "auth session projection.user"),
|
|
119
|
+
isAdmin: booleanField(user, "isAdmin", "auth session projection.user"),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
12
|
+
"types": ["bun", "node", "vite/client"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "src/**/*.vue"]
|
|
15
|
+
}
|
package/README.md
DELETED