visionos-monorepo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/worktrees/competent-burnell-8d1330/README.md +138 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/package.json +35 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/scripts/copy-web-assets.mjs +12 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/logout.ts +12 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/open.ts +19 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/start.ts +97 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/status.ts +23 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/userinfo.ts +47 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/index.ts +23 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/auth.ts +84 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/browser.ts +37 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/localState.ts +80 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/runtime.ts +203 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/runtime/index.ts +36 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/src/types/inquirer.d.ts +9 -0
- package/.claude/worktrees/competent-burnell-8d1330/cli/tsconfig.json +19 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/index.html +15 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/package.json +27 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/postcss.config.cjs +7 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/App.tsx +57 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/components/CliAuthPage.tsx +385 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/components/ManifestoPage.tsx +946 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/components/TrackCard.tsx +19 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/lib/api.ts +58 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/main.tsx +11 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/styles/index.css +33 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/src/styles/manifesto.css +1398 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/tailwind.config.ts +36 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/tsconfig.json +25 -0
- package/.claude/worktrees/competent-burnell-8d1330/client/vite.config.ts +20 -0
- package/.claude/worktrees/competent-burnell-8d1330/package-lock.json +5278 -0
- package/.claude/worktrees/competent-burnell-8d1330/package.json +24 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/package.json +25 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/src/app.ts +71 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/src/config/env.ts +14 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/src/features/auth/sessionStore.ts +74 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/src/index.ts +8 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/src/routes/auth.ts +112 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/src/routes/health.ts +14 -0
- package/.claude/worktrees/competent-burnell-8d1330/server/tsconfig.json +19 -0
- package/.claude/worktrees/competent-burnell-8d1330/shared/package.json +24 -0
- package/.claude/worktrees/competent-burnell-8d1330/shared/src/index.ts +91 -0
- package/.claude/worktrees/competent-burnell-8d1330/shared/tsconfig.json +16 -0
- package/.claude/worktrees/competent-burnell-8d1330/tsconfig.base.json +12 -0
- package/.claude/worktrees/competent-burnell-8d1330/visionos-manifesto/index.html +392 -0
- package/.claude/worktrees/competent-burnell-8d1330/visionos-manifesto/script.js +146 -0
- package/.claude/worktrees/competent-burnell-8d1330/visionos-manifesto/styles.css +1082 -0
- package/.claude/worktrees/vigilant-napier-0de76f/README.md +138 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/package.json +35 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/scripts/copy-web-assets.mjs +12 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/logout.ts +12 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/open.ts +19 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/start.ts +97 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/status.ts +23 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/userinfo.ts +47 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/index.ts +23 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/auth.ts +84 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/browser.ts +37 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/localState.ts +80 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/runtime.ts +203 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/runtime/index.ts +36 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/src/types/inquirer.d.ts +9 -0
- package/.claude/worktrees/vigilant-napier-0de76f/cli/tsconfig.json +19 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/index.html +15 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/package.json +27 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/postcss.config.cjs +7 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/App.tsx +57 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/components/CliAuthPage.tsx +385 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/components/ManifestoPage.tsx +946 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/components/TrackCard.tsx +19 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/lib/api.ts +58 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/main.tsx +11 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/styles/index.css +33 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/src/styles/manifesto.css +1398 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/tailwind.config.ts +36 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/tsconfig.json +25 -0
- package/.claude/worktrees/vigilant-napier-0de76f/client/vite.config.ts +20 -0
- package/.claude/worktrees/vigilant-napier-0de76f/package-lock.json +5278 -0
- package/.claude/worktrees/vigilant-napier-0de76f/package.json +24 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/package.json +25 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/src/app.ts +71 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/src/config/env.ts +14 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/src/features/auth/sessionStore.ts +74 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/src/index.ts +8 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/src/routes/auth.ts +112 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/src/routes/health.ts +14 -0
- package/.claude/worktrees/vigilant-napier-0de76f/server/tsconfig.json +19 -0
- package/.claude/worktrees/vigilant-napier-0de76f/shared/package.json +24 -0
- package/.claude/worktrees/vigilant-napier-0de76f/shared/src/index.ts +91 -0
- package/.claude/worktrees/vigilant-napier-0de76f/shared/tsconfig.json +16 -0
- package/.claude/worktrees/vigilant-napier-0de76f/tsconfig.base.json +12 -0
- package/.claude/worktrees/vigilant-napier-0de76f/visionos-manifesto/index.html +392 -0
- package/.claude/worktrees/vigilant-napier-0de76f/visionos-manifesto/script.js +146 -0
- package/.claude/worktrees/vigilant-napier-0de76f/visionos-manifesto/styles.css +1082 -0
- package/.github/workflows/publish.yml +30 -0
- package/README.md +175 -0
- package/cli/README.md +165 -0
- package/cli/package.json +36 -0
- package/cli/scripts/copy-web-assets.mjs +12 -0
- package/cli/src/commands/lessons.ts +68 -0
- package/cli/src/commands/login.ts +46 -0
- package/cli/src/commands/logout.ts +12 -0
- package/cli/src/commands/open.ts +29 -0
- package/cli/src/commands/start.ts +146 -0
- package/cli/src/commands/status.ts +28 -0
- package/cli/src/commands/userinfo.ts +59 -0
- package/cli/src/index.ts +109 -0
- package/cli/src/lib/auth.ts +84 -0
- package/cli/src/lib/browser.ts +37 -0
- package/cli/src/lib/content.ts +57 -0
- package/cli/src/lib/lessonPrinter.ts +38 -0
- package/cli/src/lib/lessonRunner.ts +381 -0
- package/cli/src/lib/localState.ts +114 -0
- package/cli/src/lib/loginFlow.ts +74 -0
- package/cli/src/lib/progress.ts +94 -0
- package/cli/src/lib/runtime.ts +220 -0
- package/cli/src/lib/validator.ts +401 -0
- package/cli/src/runtime/index.ts +108 -0
- package/cli/src/types/inquirer.d.ts +9 -0
- package/cli/tsconfig.json +19 -0
- package/client/index.html +15 -0
- package/client/package.json +27 -0
- package/client/postcss.config.cjs +7 -0
- package/client/src/App.tsx +102 -0
- package/client/src/components/AccountPage.tsx +79 -0
- package/client/src/components/AuthPanel.tsx +312 -0
- package/client/src/components/CliAuthPage.tsx +367 -0
- package/client/src/components/CreatorPortal.tsx +885 -0
- package/client/src/components/ErrorBoundary.tsx +92 -0
- package/client/src/components/ManifestoPage.tsx +1126 -0
- package/client/src/components/TrackCard.tsx +19 -0
- package/client/src/lib/api.ts +215 -0
- package/client/src/main.tsx +14 -0
- package/client/src/styles/index.css +33 -0
- package/client/src/styles/manifesto.css +1828 -0
- package/client/tailwind.config.ts +36 -0
- package/client/tsconfig.json +25 -0
- package/client/vercel.json +8 -0
- package/client/vite.config.ts +33 -0
- package/package.json +27 -0
- package/server/package.json +26 -0
- package/server/src/app.ts +132 -0
- package/server/src/config/env.ts +135 -0
- package/server/src/features/accounts/accountStore.ts +359 -0
- package/server/src/features/accounts/contentStore.ts +264 -0
- package/server/src/features/accounts/password.ts +26 -0
- package/server/src/features/auth/sessionStore.ts +79 -0
- package/server/src/index.ts +8 -0
- package/server/src/routes/auth.ts +328 -0
- package/server/src/routes/content.ts +174 -0
- package/server/src/routes/health.ts +14 -0
- package/server/src/routes/progress.ts +105 -0
- package/server/tsconfig.json +19 -0
- package/shared/package.json +24 -0
- package/shared/src/index.ts +455 -0
- package/shared/tsconfig.json +16 -0
- package/tsconfig.base.json +12 -0
- package/visionos-manifesto/index.html +392 -0
- package/visionos-manifesto/script.js +146 -0
- package/visionos-manifesto/styles.css +1082 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
CLI_AUTH_POLL_INTERVAL_MS,
|
|
4
|
+
CLI_AUTH_SESSION_TTL_MS,
|
|
5
|
+
type CliAuthSession,
|
|
6
|
+
type CreateCliAuthSessionResponse,
|
|
7
|
+
type LearnerIdentity,
|
|
8
|
+
type LearningTrackId
|
|
9
|
+
} from "../../../../shared/src/index.js";
|
|
10
|
+
|
|
11
|
+
export class InMemoryCliAuthSessionStore {
|
|
12
|
+
private readonly sessions = new Map<string, CliAuthSession>();
|
|
13
|
+
|
|
14
|
+
constructor(private readonly clientOrigin: string) {}
|
|
15
|
+
|
|
16
|
+
createSession(trackId: LearningTrackId): CreateCliAuthSessionResponse {
|
|
17
|
+
const createdAt = new Date();
|
|
18
|
+
const session: CliAuthSession = {
|
|
19
|
+
sessionId: randomUUID(),
|
|
20
|
+
trackId,
|
|
21
|
+
status: "pending",
|
|
22
|
+
createdAt: createdAt.toISOString(),
|
|
23
|
+
expiresAt: new Date(createdAt.getTime() + CLI_AUTH_SESSION_TTL_MS).toISOString()
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
this.sessions.set(session.sessionId, session);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
session,
|
|
30
|
+
browserUrl: new URL(`/?cliAuthSession=${session.sessionId}`, this.clientOrigin).toString(),
|
|
31
|
+
pollIntervalMs: CLI_AUTH_POLL_INTERVAL_MS
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getSession(sessionId: string): CliAuthSession | undefined {
|
|
36
|
+
const session = this.sessions.get(sessionId);
|
|
37
|
+
|
|
38
|
+
if (!session) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (session.status === "pending" && Date.now() >= Date.parse(session.expiresAt)) {
|
|
43
|
+
const expiredSession: CliAuthSession = {
|
|
44
|
+
...session,
|
|
45
|
+
status: "expired"
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
this.sessions.set(sessionId, expiredSession);
|
|
49
|
+
|
|
50
|
+
return expiredSession;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return session;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
completeSession(
|
|
57
|
+
sessionId: string,
|
|
58
|
+
learner: LearnerIdentity,
|
|
59
|
+
accountToken?: string
|
|
60
|
+
): CliAuthSession | undefined {
|
|
61
|
+
const session = this.getSession(sessionId);
|
|
62
|
+
|
|
63
|
+
if (!session || session.status !== "pending") {
|
|
64
|
+
return session;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const authenticatedSession: CliAuthSession = {
|
|
68
|
+
...session,
|
|
69
|
+
status: "authenticated",
|
|
70
|
+
authenticatedAt: new Date().toISOString(),
|
|
71
|
+
accountToken,
|
|
72
|
+
learner
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.sessions.set(sessionId, authenticatedSession);
|
|
76
|
+
|
|
77
|
+
return authenticatedSession;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import {
|
|
3
|
+
type AccountAuthRequest,
|
|
4
|
+
type CliAuthSession,
|
|
5
|
+
type CompleteCliAuthSessionRequest,
|
|
6
|
+
type CreateCliAuthSessionRequest,
|
|
7
|
+
type GoogleAuthRequest,
|
|
8
|
+
LEARNING_TRACKS,
|
|
9
|
+
type LearnerIdentity,
|
|
10
|
+
type LearningTrackId
|
|
11
|
+
} from "../../../shared/src/index.js";
|
|
12
|
+
import { env } from "../config/env.js";
|
|
13
|
+
import {
|
|
14
|
+
AccountStoreError,
|
|
15
|
+
bearerTokenFromHeader,
|
|
16
|
+
type AccountStore
|
|
17
|
+
} from "../features/accounts/accountStore.js";
|
|
18
|
+
import { InMemoryCliAuthSessionStore } from "../features/auth/sessionStore.js";
|
|
19
|
+
|
|
20
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
21
|
+
|
|
22
|
+
function isLearningTrackId(value: unknown): value is LearningTrackId {
|
|
23
|
+
return typeof value === "string" && LEARNING_TRACKS.some((track) => track.id === value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CreateAuthRouterOptions {
|
|
27
|
+
accountStore?: AccountStore | null;
|
|
28
|
+
clientOrigin?: string;
|
|
29
|
+
googleClientId?: string;
|
|
30
|
+
getCurrentUser?: () => Promise<LearnerIdentity | null> | LearnerIdentity | null;
|
|
31
|
+
onAuthenticated?: (session: CliAuthSession) => Promise<void> | void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createAuthRouter(options: CreateAuthRouterOptions = {}) {
|
|
35
|
+
const router = Router();
|
|
36
|
+
const cliAuthSessionStore = new InMemoryCliAuthSessionStore(options.clientOrigin ?? env.clientOrigin);
|
|
37
|
+
|
|
38
|
+
router.get("/me", async (req, res, next) => {
|
|
39
|
+
try {
|
|
40
|
+
const bearerToken = bearerTokenFromHeader(req.header("authorization"));
|
|
41
|
+
const accountUser = bearerToken && options.accountStore
|
|
42
|
+
? await options.accountStore.getUserFromToken(bearerToken)
|
|
43
|
+
: null;
|
|
44
|
+
const user = accountUser ?? (await options.getCurrentUser?.()) ?? null;
|
|
45
|
+
|
|
46
|
+
return res.status(200).json({ user });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return next(error);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.post("/register", async (req, res, next) => {
|
|
53
|
+
try {
|
|
54
|
+
const accountStore = requireAccountStore(options.accountStore);
|
|
55
|
+
const body = req.body as Partial<AccountAuthRequest>;
|
|
56
|
+
const auth = await accountStore.register({
|
|
57
|
+
email: body.email ?? "",
|
|
58
|
+
name: body.name ?? "",
|
|
59
|
+
password: body.password ?? ""
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return res.status(201).json(auth);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return next(error);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
router.post("/login", async (req, res, next) => {
|
|
69
|
+
try {
|
|
70
|
+
const accountStore = requireAccountStore(options.accountStore);
|
|
71
|
+
const body = req.body as Partial<AccountAuthRequest>;
|
|
72
|
+
const auth = await accountStore.login({
|
|
73
|
+
email: body.email ?? "",
|
|
74
|
+
password: body.password ?? ""
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return res.status(200).json(auth);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return next(error);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
router.post("/google", async (req, res, next) => {
|
|
84
|
+
try {
|
|
85
|
+
const accountStore = requireAccountStore(options.accountStore);
|
|
86
|
+
const body = req.body as Partial<GoogleAuthRequest>;
|
|
87
|
+
|
|
88
|
+
if (!body.credential) {
|
|
89
|
+
return res.status(400).json({
|
|
90
|
+
message: "Google credential is required."
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const profile = await verifyGoogleCredential(body.credential, options.googleClientId ?? env.googleClientId);
|
|
95
|
+
const auth = await accountStore.loginWithGoogle(profile);
|
|
96
|
+
|
|
97
|
+
return res.status(200).json(auth);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return next(error);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
router.post("/cli/sessions", (req, res) => {
|
|
104
|
+
const body = req.body as Partial<CreateCliAuthSessionRequest>;
|
|
105
|
+
|
|
106
|
+
if (!isLearningTrackId(body.trackId)) {
|
|
107
|
+
return res.status(400).json({
|
|
108
|
+
message: "A valid learning track is required to start browser authentication."
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return res.status(201).json(cliAuthSessionStore.createSession(body.trackId));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.get("/cli/sessions/:sessionId", (req, res) => {
|
|
116
|
+
const session = cliAuthSessionStore.getSession(req.params.sessionId);
|
|
117
|
+
|
|
118
|
+
if (!session) {
|
|
119
|
+
return res.status(404).json({
|
|
120
|
+
message: "Auth session not found."
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return res.status(200).json({ session });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
router.post("/cli/sessions/:sessionId/complete", async (req, res, next) => {
|
|
128
|
+
try {
|
|
129
|
+
const session = cliAuthSessionStore.getSession(req.params.sessionId);
|
|
130
|
+
|
|
131
|
+
if (!session) {
|
|
132
|
+
return res.status(404).json({
|
|
133
|
+
message: "Auth session not found."
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (session.status === "expired") {
|
|
138
|
+
return res.status(410).json({
|
|
139
|
+
message: "Auth session expired.",
|
|
140
|
+
session
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (session.status === "authenticated") {
|
|
145
|
+
return res.status(200).json({ session });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const body = req.body as Partial<CompleteCliAuthSessionRequest>;
|
|
149
|
+
const learnerEmail = body.learnerEmail?.trim().toLowerCase();
|
|
150
|
+
const password = body.password?.trim() ?? "";
|
|
151
|
+
|
|
152
|
+
if (!body.mode || !["login", "register"].includes(body.mode)) {
|
|
153
|
+
return res.status(400).json({
|
|
154
|
+
message: "Choose whether to create a new account or log in."
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!learnerEmail || !emailPattern.test(learnerEmail) || !password) {
|
|
159
|
+
return res.status(400).json({
|
|
160
|
+
message: "A valid email address and password are required to complete sign-in."
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const accountStore = requireAccountStore(options.accountStore);
|
|
165
|
+
const auth = body.mode === "register"
|
|
166
|
+
? await accountStore.register({
|
|
167
|
+
email: learnerEmail,
|
|
168
|
+
name: body.learnerName ?? "",
|
|
169
|
+
password
|
|
170
|
+
})
|
|
171
|
+
: await accountStore.login({
|
|
172
|
+
email: learnerEmail,
|
|
173
|
+
password
|
|
174
|
+
});
|
|
175
|
+
const authenticatedSession = cliAuthSessionStore.completeSession(req.params.sessionId, {
|
|
176
|
+
userId: auth.user.userId,
|
|
177
|
+
name: auth.user.name,
|
|
178
|
+
email: auth.user.email
|
|
179
|
+
}, auth.token);
|
|
180
|
+
|
|
181
|
+
if (authenticatedSession) {
|
|
182
|
+
await options.onAuthenticated?.(authenticatedSession);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return res.status(200).json({ session: authenticatedSession, auth });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return next(error);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
router.post("/cli/sessions/:sessionId/confirm", async (req, res, next) => {
|
|
192
|
+
try {
|
|
193
|
+
const session = cliAuthSessionStore.getSession(req.params.sessionId);
|
|
194
|
+
|
|
195
|
+
if (!session) {
|
|
196
|
+
return res.status(404).json({
|
|
197
|
+
message: "Auth session not found."
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (session.status === "expired") {
|
|
202
|
+
return res.status(410).json({
|
|
203
|
+
message: "Auth session expired.",
|
|
204
|
+
session
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (session.status === "authenticated") {
|
|
209
|
+
return res.status(200).json({ session });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const bearerToken = bearerTokenFromHeader(req.header("authorization"));
|
|
213
|
+
if (!bearerToken || !options.accountStore) {
|
|
214
|
+
return res.status(401).json({
|
|
215
|
+
message: "Authentication required to confirm CLI sign-in."
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const accountUser = await options.accountStore.getUserFromToken(bearerToken);
|
|
220
|
+
if (!accountUser) {
|
|
221
|
+
return res.status(401).json({
|
|
222
|
+
message: "Invalid token or user not found."
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const authenticatedSession = cliAuthSessionStore.completeSession(req.params.sessionId, {
|
|
227
|
+
userId: accountUser.userId,
|
|
228
|
+
name: accountUser.name,
|
|
229
|
+
email: accountUser.email
|
|
230
|
+
}, bearerToken);
|
|
231
|
+
|
|
232
|
+
if (authenticatedSession) {
|
|
233
|
+
await options.onAuthenticated?.(authenticatedSession);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return res.status(200).json({ session: authenticatedSession });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return next(error);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
router.post("/cli/sessions/:sessionId/google", async (req, res, next) => {
|
|
243
|
+
try {
|
|
244
|
+
const session = cliAuthSessionStore.getSession(req.params.sessionId);
|
|
245
|
+
|
|
246
|
+
if (!session) {
|
|
247
|
+
return res.status(404).json({
|
|
248
|
+
message: "Auth session not found."
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (session.status === "expired") {
|
|
253
|
+
return res.status(410).json({
|
|
254
|
+
message: "Auth session expired.",
|
|
255
|
+
session
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (session.status === "authenticated") {
|
|
260
|
+
return res.status(200).json({ session });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const accountStore = requireAccountStore(options.accountStore);
|
|
264
|
+
const body = req.body as Partial<GoogleAuthRequest>;
|
|
265
|
+
|
|
266
|
+
if (!body.credential) {
|
|
267
|
+
return res.status(400).json({
|
|
268
|
+
message: "Google credential is required."
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const profile = await verifyGoogleCredential(body.credential, options.googleClientId ?? env.googleClientId);
|
|
273
|
+
const auth = await accountStore.loginWithGoogle(profile);
|
|
274
|
+
const authenticatedSession = cliAuthSessionStore.completeSession(req.params.sessionId, {
|
|
275
|
+
userId: auth.user.userId,
|
|
276
|
+
name: auth.user.name,
|
|
277
|
+
email: auth.user.email
|
|
278
|
+
}, auth.token);
|
|
279
|
+
|
|
280
|
+
if (authenticatedSession) {
|
|
281
|
+
await options.onAuthenticated?.(authenticatedSession);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return res.status(200).json({ session: authenticatedSession, auth });
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return next(error);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return router;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const authRouter = createAuthRouter();
|
|
294
|
+
|
|
295
|
+
function requireAccountStore(accountStore: AccountStore | null | undefined) {
|
|
296
|
+
if (!accountStore) {
|
|
297
|
+
throw new AccountStoreError("MongoDB account storage is not configured. Set MONGODB_URI first.", 503);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return accountStore;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function verifyGoogleCredential(credential: string, googleClientId?: string) {
|
|
304
|
+
if (!googleClientId) {
|
|
305
|
+
throw new AccountStoreError("Google login is not configured. Set GOOGLE_CLIENT_ID first.", 503);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const url = new URL("https://oauth2.googleapis.com/tokeninfo");
|
|
309
|
+
url.searchParams.set("id_token", credential);
|
|
310
|
+
|
|
311
|
+
const response = await fetch(url);
|
|
312
|
+
const payload = (await response.json().catch(() => null)) as {
|
|
313
|
+
aud?: string;
|
|
314
|
+
email?: string;
|
|
315
|
+
name?: string;
|
|
316
|
+
sub?: string;
|
|
317
|
+
} | null;
|
|
318
|
+
|
|
319
|
+
if (!response.ok || !payload?.email || !payload.sub || payload.aud !== googleClientId) {
|
|
320
|
+
throw new AccountStoreError("Google sign-in could not be verified.", 401);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
email: payload.email,
|
|
325
|
+
name: payload.name ?? payload.email,
|
|
326
|
+
subject: payload.sub
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import {
|
|
3
|
+
type LearningTrack,
|
|
4
|
+
type LearningLesson
|
|
5
|
+
} from "../../../shared/src/index.js";
|
|
6
|
+
import {
|
|
7
|
+
AccountStoreError,
|
|
8
|
+
bearerTokenFromHeader,
|
|
9
|
+
type AccountStore
|
|
10
|
+
} from "../features/accounts/accountStore.js";
|
|
11
|
+
import { type ContentStore } from "../features/accounts/contentStore.js";
|
|
12
|
+
|
|
13
|
+
export function createContentRouter(
|
|
14
|
+
accountStore: AccountStore | null,
|
|
15
|
+
contentStore: ContentStore | null
|
|
16
|
+
) {
|
|
17
|
+
const router = Router();
|
|
18
|
+
|
|
19
|
+
function requireContentStore() {
|
|
20
|
+
if (!contentStore) {
|
|
21
|
+
throw new Error("MongoDB content storage is not configured.");
|
|
22
|
+
}
|
|
23
|
+
return contentStore;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolveUser(authorization: string | undefined) {
|
|
27
|
+
const token = bearerTokenFromHeader(authorization);
|
|
28
|
+
|
|
29
|
+
if (token && accountStore) {
|
|
30
|
+
const user = await accountStore.getUserFromToken(token);
|
|
31
|
+
if (user) {
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
throw new AccountStoreError("Your session is invalid or expired. Sign in again.", 401);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
throw new AccountStoreError("Sign in to modify content.", 401);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 1. GET /tracks - Retrieve all tracks (public)
|
|
41
|
+
router.get("/tracks", async (req, res, next) => {
|
|
42
|
+
try {
|
|
43
|
+
const store = requireContentStore();
|
|
44
|
+
const tracks = await store.listTracks();
|
|
45
|
+
return res.status(200).json({ tracks });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return next(error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 2. GET /tracks/:trackId/lessons - Retrieve lessons in track (public)
|
|
52
|
+
router.get("/tracks/:trackId/lessons", async (req, res, next) => {
|
|
53
|
+
try {
|
|
54
|
+
const store = requireContentStore();
|
|
55
|
+
const lessons = await store.listLessons(req.params.trackId);
|
|
56
|
+
return res.status(200).json({ lessons });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return next(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 3. POST /tracks - Create track (authenticated)
|
|
63
|
+
router.post("/tracks", async (req, res, next) => {
|
|
64
|
+
try {
|
|
65
|
+
const user = await resolveUser(req.header("authorization"));
|
|
66
|
+
const store = requireContentStore();
|
|
67
|
+
const body = req.body as { id: string; label: string; description: string };
|
|
68
|
+
|
|
69
|
+
if (!body.id || !body.label || !body.description) {
|
|
70
|
+
return res.status(400).json({ message: "Track ID, Label, and Description are required." });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const track = await store.createTrack(user.userId, {
|
|
74
|
+
id: body.id.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""),
|
|
75
|
+
label: body.label.trim(),
|
|
76
|
+
description: body.description.trim()
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return res.status(201).json({ track });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return next(error);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 4. PUT /tracks/:trackId - Update track (authenticated, creator-only)
|
|
86
|
+
router.put("/tracks/:trackId", async (req, res, next) => {
|
|
87
|
+
try {
|
|
88
|
+
const user = await resolveUser(req.header("authorization"));
|
|
89
|
+
const store = requireContentStore();
|
|
90
|
+
const body = req.body as { label: string; description: string };
|
|
91
|
+
|
|
92
|
+
if (!body.label || !body.description) {
|
|
93
|
+
return res.status(400).json({ message: "Label and Description are required." });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const track = await store.updateTrack(user.userId, req.params.trackId, {
|
|
97
|
+
label: body.label.trim(),
|
|
98
|
+
description: body.description.trim()
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return res.status(200).json({ track });
|
|
102
|
+
} catch (error: any) {
|
|
103
|
+
return res.status(error.status ?? 400).json({ message: error.message });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 5. POST /tracks/:trackId/lessons - Create lesson (authenticated, creator-only)
|
|
108
|
+
router.post("/tracks/:trackId/lessons", async (req, res, next) => {
|
|
109
|
+
try {
|
|
110
|
+
const user = await resolveUser(req.header("authorization"));
|
|
111
|
+
const store = requireContentStore();
|
|
112
|
+
const body = req.body as Omit<LearningLesson, "creatorId" | "createdAt">;
|
|
113
|
+
|
|
114
|
+
if (!body.id || !body.title || !body.summary || !body.steps || body.steps.length === 0) {
|
|
115
|
+
return res.status(400).json({ message: "Lesson ID, Title, Summary, and at least one step are required." });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lesson = await store.createLesson(user.userId, {
|
|
119
|
+
id: body.id.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""),
|
|
120
|
+
trackId: req.params.trackId,
|
|
121
|
+
title: body.title.trim(),
|
|
122
|
+
difficulty: body.difficulty || "beginner",
|
|
123
|
+
summary: body.summary.trim(),
|
|
124
|
+
estimatedMinutes: body.estimatedMinutes || 10,
|
|
125
|
+
steps: body.steps.map(s => ({
|
|
126
|
+
id: s.id.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""),
|
|
127
|
+
title: s.title.trim(),
|
|
128
|
+
objective: s.objective.trim(),
|
|
129
|
+
command: s.command.trim(),
|
|
130
|
+
explanation: s.explanation.trim(),
|
|
131
|
+
validationType: s.validationType || "syntax",
|
|
132
|
+
validationPath: s.validationPath ? s.validationPath.trim() : undefined,
|
|
133
|
+
validationContent: s.validationContent ? s.validationContent.trim() : undefined
|
|
134
|
+
}))
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return res.status(201).json({ lesson });
|
|
138
|
+
} catch (error: any) {
|
|
139
|
+
return res.status(error.status ?? 400).json({ message: error.message });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// 6. PUT /lessons/:lessonId - Update lesson (authenticated, creator-only)
|
|
144
|
+
router.put("/lessons/:lessonId", async (req, res, next) => {
|
|
145
|
+
try {
|
|
146
|
+
const user = await resolveUser(req.header("authorization"));
|
|
147
|
+
const store = requireContentStore();
|
|
148
|
+
const body = req.body as Partial<Omit<LearningLesson, "creatorId" | "createdAt">>;
|
|
149
|
+
|
|
150
|
+
const lesson = await store.updateLesson(user.userId, req.params.lessonId, {
|
|
151
|
+
title: body.title ? body.title.trim() : undefined,
|
|
152
|
+
difficulty: body.difficulty,
|
|
153
|
+
summary: body.summary ? body.summary.trim() : undefined,
|
|
154
|
+
estimatedMinutes: body.estimatedMinutes,
|
|
155
|
+
steps: body.steps ? body.steps.map(s => ({
|
|
156
|
+
id: s.id.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""),
|
|
157
|
+
title: s.title.trim(),
|
|
158
|
+
objective: s.objective.trim(),
|
|
159
|
+
command: s.command.trim(),
|
|
160
|
+
explanation: s.explanation.trim(),
|
|
161
|
+
validationType: s.validationType || "syntax",
|
|
162
|
+
validationPath: s.validationPath ? s.validationPath.trim() : undefined,
|
|
163
|
+
validationContent: s.validationContent ? s.validationContent.trim() : undefined
|
|
164
|
+
})) : undefined
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return res.status(200).json({ lesson });
|
|
168
|
+
} catch (error: any) {
|
|
169
|
+
return res.status(error.status ?? 400).json({ message: error.message });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return router;
|
|
174
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { APP_NAME, APP_VERSION } from "../../../shared/src/index.js";
|
|
3
|
+
|
|
4
|
+
export const healthRouter = Router();
|
|
5
|
+
|
|
6
|
+
healthRouter.get("/", (_req, res) => {
|
|
7
|
+
res.status(200).json({
|
|
8
|
+
status: "ok",
|
|
9
|
+
service: APP_NAME,
|
|
10
|
+
version: APP_VERSION,
|
|
11
|
+
timestamp: new Date().toISOString()
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|