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.
Files changed (160) hide show
  1. package/.claude/worktrees/competent-burnell-8d1330/README.md +138 -0
  2. package/.claude/worktrees/competent-burnell-8d1330/cli/package.json +35 -0
  3. package/.claude/worktrees/competent-burnell-8d1330/cli/scripts/copy-web-assets.mjs +12 -0
  4. package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/logout.ts +12 -0
  5. package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/open.ts +19 -0
  6. package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/start.ts +97 -0
  7. package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/status.ts +23 -0
  8. package/.claude/worktrees/competent-burnell-8d1330/cli/src/commands/userinfo.ts +47 -0
  9. package/.claude/worktrees/competent-burnell-8d1330/cli/src/index.ts +23 -0
  10. package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/auth.ts +84 -0
  11. package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/browser.ts +37 -0
  12. package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/localState.ts +80 -0
  13. package/.claude/worktrees/competent-burnell-8d1330/cli/src/lib/runtime.ts +203 -0
  14. package/.claude/worktrees/competent-burnell-8d1330/cli/src/runtime/index.ts +36 -0
  15. package/.claude/worktrees/competent-burnell-8d1330/cli/src/types/inquirer.d.ts +9 -0
  16. package/.claude/worktrees/competent-burnell-8d1330/cli/tsconfig.json +19 -0
  17. package/.claude/worktrees/competent-burnell-8d1330/client/index.html +15 -0
  18. package/.claude/worktrees/competent-burnell-8d1330/client/package.json +27 -0
  19. package/.claude/worktrees/competent-burnell-8d1330/client/postcss.config.cjs +7 -0
  20. package/.claude/worktrees/competent-burnell-8d1330/client/src/App.tsx +57 -0
  21. package/.claude/worktrees/competent-burnell-8d1330/client/src/components/CliAuthPage.tsx +385 -0
  22. package/.claude/worktrees/competent-burnell-8d1330/client/src/components/ManifestoPage.tsx +946 -0
  23. package/.claude/worktrees/competent-burnell-8d1330/client/src/components/TrackCard.tsx +19 -0
  24. package/.claude/worktrees/competent-burnell-8d1330/client/src/lib/api.ts +58 -0
  25. package/.claude/worktrees/competent-burnell-8d1330/client/src/main.tsx +11 -0
  26. package/.claude/worktrees/competent-burnell-8d1330/client/src/styles/index.css +33 -0
  27. package/.claude/worktrees/competent-burnell-8d1330/client/src/styles/manifesto.css +1398 -0
  28. package/.claude/worktrees/competent-burnell-8d1330/client/tailwind.config.ts +36 -0
  29. package/.claude/worktrees/competent-burnell-8d1330/client/tsconfig.json +25 -0
  30. package/.claude/worktrees/competent-burnell-8d1330/client/vite.config.ts +20 -0
  31. package/.claude/worktrees/competent-burnell-8d1330/package-lock.json +5278 -0
  32. package/.claude/worktrees/competent-burnell-8d1330/package.json +24 -0
  33. package/.claude/worktrees/competent-burnell-8d1330/server/package.json +25 -0
  34. package/.claude/worktrees/competent-burnell-8d1330/server/src/app.ts +71 -0
  35. package/.claude/worktrees/competent-burnell-8d1330/server/src/config/env.ts +14 -0
  36. package/.claude/worktrees/competent-burnell-8d1330/server/src/features/auth/sessionStore.ts +74 -0
  37. package/.claude/worktrees/competent-burnell-8d1330/server/src/index.ts +8 -0
  38. package/.claude/worktrees/competent-burnell-8d1330/server/src/routes/auth.ts +112 -0
  39. package/.claude/worktrees/competent-burnell-8d1330/server/src/routes/health.ts +14 -0
  40. package/.claude/worktrees/competent-burnell-8d1330/server/tsconfig.json +19 -0
  41. package/.claude/worktrees/competent-burnell-8d1330/shared/package.json +24 -0
  42. package/.claude/worktrees/competent-burnell-8d1330/shared/src/index.ts +91 -0
  43. package/.claude/worktrees/competent-burnell-8d1330/shared/tsconfig.json +16 -0
  44. package/.claude/worktrees/competent-burnell-8d1330/tsconfig.base.json +12 -0
  45. package/.claude/worktrees/competent-burnell-8d1330/visionos-manifesto/index.html +392 -0
  46. package/.claude/worktrees/competent-burnell-8d1330/visionos-manifesto/script.js +146 -0
  47. package/.claude/worktrees/competent-burnell-8d1330/visionos-manifesto/styles.css +1082 -0
  48. package/.claude/worktrees/vigilant-napier-0de76f/README.md +138 -0
  49. package/.claude/worktrees/vigilant-napier-0de76f/cli/package.json +35 -0
  50. package/.claude/worktrees/vigilant-napier-0de76f/cli/scripts/copy-web-assets.mjs +12 -0
  51. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/logout.ts +12 -0
  52. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/open.ts +19 -0
  53. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/start.ts +97 -0
  54. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/status.ts +23 -0
  55. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/commands/userinfo.ts +47 -0
  56. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/index.ts +23 -0
  57. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/auth.ts +84 -0
  58. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/browser.ts +37 -0
  59. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/localState.ts +80 -0
  60. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/lib/runtime.ts +203 -0
  61. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/runtime/index.ts +36 -0
  62. package/.claude/worktrees/vigilant-napier-0de76f/cli/src/types/inquirer.d.ts +9 -0
  63. package/.claude/worktrees/vigilant-napier-0de76f/cli/tsconfig.json +19 -0
  64. package/.claude/worktrees/vigilant-napier-0de76f/client/index.html +15 -0
  65. package/.claude/worktrees/vigilant-napier-0de76f/client/package.json +27 -0
  66. package/.claude/worktrees/vigilant-napier-0de76f/client/postcss.config.cjs +7 -0
  67. package/.claude/worktrees/vigilant-napier-0de76f/client/src/App.tsx +57 -0
  68. package/.claude/worktrees/vigilant-napier-0de76f/client/src/components/CliAuthPage.tsx +385 -0
  69. package/.claude/worktrees/vigilant-napier-0de76f/client/src/components/ManifestoPage.tsx +946 -0
  70. package/.claude/worktrees/vigilant-napier-0de76f/client/src/components/TrackCard.tsx +19 -0
  71. package/.claude/worktrees/vigilant-napier-0de76f/client/src/lib/api.ts +58 -0
  72. package/.claude/worktrees/vigilant-napier-0de76f/client/src/main.tsx +11 -0
  73. package/.claude/worktrees/vigilant-napier-0de76f/client/src/styles/index.css +33 -0
  74. package/.claude/worktrees/vigilant-napier-0de76f/client/src/styles/manifesto.css +1398 -0
  75. package/.claude/worktrees/vigilant-napier-0de76f/client/tailwind.config.ts +36 -0
  76. package/.claude/worktrees/vigilant-napier-0de76f/client/tsconfig.json +25 -0
  77. package/.claude/worktrees/vigilant-napier-0de76f/client/vite.config.ts +20 -0
  78. package/.claude/worktrees/vigilant-napier-0de76f/package-lock.json +5278 -0
  79. package/.claude/worktrees/vigilant-napier-0de76f/package.json +24 -0
  80. package/.claude/worktrees/vigilant-napier-0de76f/server/package.json +25 -0
  81. package/.claude/worktrees/vigilant-napier-0de76f/server/src/app.ts +71 -0
  82. package/.claude/worktrees/vigilant-napier-0de76f/server/src/config/env.ts +14 -0
  83. package/.claude/worktrees/vigilant-napier-0de76f/server/src/features/auth/sessionStore.ts +74 -0
  84. package/.claude/worktrees/vigilant-napier-0de76f/server/src/index.ts +8 -0
  85. package/.claude/worktrees/vigilant-napier-0de76f/server/src/routes/auth.ts +112 -0
  86. package/.claude/worktrees/vigilant-napier-0de76f/server/src/routes/health.ts +14 -0
  87. package/.claude/worktrees/vigilant-napier-0de76f/server/tsconfig.json +19 -0
  88. package/.claude/worktrees/vigilant-napier-0de76f/shared/package.json +24 -0
  89. package/.claude/worktrees/vigilant-napier-0de76f/shared/src/index.ts +91 -0
  90. package/.claude/worktrees/vigilant-napier-0de76f/shared/tsconfig.json +16 -0
  91. package/.claude/worktrees/vigilant-napier-0de76f/tsconfig.base.json +12 -0
  92. package/.claude/worktrees/vigilant-napier-0de76f/visionos-manifesto/index.html +392 -0
  93. package/.claude/worktrees/vigilant-napier-0de76f/visionos-manifesto/script.js +146 -0
  94. package/.claude/worktrees/vigilant-napier-0de76f/visionos-manifesto/styles.css +1082 -0
  95. package/.github/workflows/publish.yml +30 -0
  96. package/README.md +175 -0
  97. package/cli/README.md +165 -0
  98. package/cli/package.json +36 -0
  99. package/cli/scripts/copy-web-assets.mjs +12 -0
  100. package/cli/src/commands/lessons.ts +68 -0
  101. package/cli/src/commands/login.ts +46 -0
  102. package/cli/src/commands/logout.ts +12 -0
  103. package/cli/src/commands/open.ts +29 -0
  104. package/cli/src/commands/start.ts +146 -0
  105. package/cli/src/commands/status.ts +28 -0
  106. package/cli/src/commands/userinfo.ts +59 -0
  107. package/cli/src/index.ts +109 -0
  108. package/cli/src/lib/auth.ts +84 -0
  109. package/cli/src/lib/browser.ts +37 -0
  110. package/cli/src/lib/content.ts +57 -0
  111. package/cli/src/lib/lessonPrinter.ts +38 -0
  112. package/cli/src/lib/lessonRunner.ts +381 -0
  113. package/cli/src/lib/localState.ts +114 -0
  114. package/cli/src/lib/loginFlow.ts +74 -0
  115. package/cli/src/lib/progress.ts +94 -0
  116. package/cli/src/lib/runtime.ts +220 -0
  117. package/cli/src/lib/validator.ts +401 -0
  118. package/cli/src/runtime/index.ts +108 -0
  119. package/cli/src/types/inquirer.d.ts +9 -0
  120. package/cli/tsconfig.json +19 -0
  121. package/client/index.html +15 -0
  122. package/client/package.json +27 -0
  123. package/client/postcss.config.cjs +7 -0
  124. package/client/src/App.tsx +102 -0
  125. package/client/src/components/AccountPage.tsx +79 -0
  126. package/client/src/components/AuthPanel.tsx +312 -0
  127. package/client/src/components/CliAuthPage.tsx +367 -0
  128. package/client/src/components/CreatorPortal.tsx +885 -0
  129. package/client/src/components/ErrorBoundary.tsx +92 -0
  130. package/client/src/components/ManifestoPage.tsx +1126 -0
  131. package/client/src/components/TrackCard.tsx +19 -0
  132. package/client/src/lib/api.ts +215 -0
  133. package/client/src/main.tsx +14 -0
  134. package/client/src/styles/index.css +33 -0
  135. package/client/src/styles/manifesto.css +1828 -0
  136. package/client/tailwind.config.ts +36 -0
  137. package/client/tsconfig.json +25 -0
  138. package/client/vercel.json +8 -0
  139. package/client/vite.config.ts +33 -0
  140. package/package.json +27 -0
  141. package/server/package.json +26 -0
  142. package/server/src/app.ts +132 -0
  143. package/server/src/config/env.ts +135 -0
  144. package/server/src/features/accounts/accountStore.ts +359 -0
  145. package/server/src/features/accounts/contentStore.ts +264 -0
  146. package/server/src/features/accounts/password.ts +26 -0
  147. package/server/src/features/auth/sessionStore.ts +79 -0
  148. package/server/src/index.ts +8 -0
  149. package/server/src/routes/auth.ts +328 -0
  150. package/server/src/routes/content.ts +174 -0
  151. package/server/src/routes/health.ts +14 -0
  152. package/server/src/routes/progress.ts +105 -0
  153. package/server/tsconfig.json +19 -0
  154. package/shared/package.json +24 -0
  155. package/shared/src/index.ts +455 -0
  156. package/shared/tsconfig.json +16 -0
  157. package/tsconfig.base.json +12 -0
  158. package/visionos-manifesto/index.html +392 -0
  159. package/visionos-manifesto/script.js +146 -0
  160. 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,8 @@
1
+ import { createApp } from "./app.js";
2
+ import { env } from "./config/env.js";
3
+
4
+ const app = createApp();
5
+
6
+ app.listen(env.port, env.host, () => {
7
+ console.log(`VisionOS server listening on http://${env.host}:${env.port}`);
8
+ });
@@ -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
+