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,359 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
MongoClient,
|
|
4
|
+
ObjectId,
|
|
5
|
+
type Collection,
|
|
6
|
+
type Db,
|
|
7
|
+
type Document,
|
|
8
|
+
type WithId
|
|
9
|
+
} from "mongodb";
|
|
10
|
+
import {
|
|
11
|
+
type AccountAuthResponse,
|
|
12
|
+
type AccountUser,
|
|
13
|
+
type LearnerIdentity,
|
|
14
|
+
type LearningProgress,
|
|
15
|
+
type LearningTrackId,
|
|
16
|
+
type UpsertLearningProgressRequest
|
|
17
|
+
} from "../../../../shared/src/index.js";
|
|
18
|
+
import { hashPassword, verifyPassword } from "./password.js";
|
|
19
|
+
|
|
20
|
+
interface UserDocument extends Document {
|
|
21
|
+
name: string;
|
|
22
|
+
email: string;
|
|
23
|
+
passwordHash?: string;
|
|
24
|
+
passwordSalt?: string;
|
|
25
|
+
googleSubject?: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SessionDocument extends Document {
|
|
31
|
+
tokenHash: string;
|
|
32
|
+
userId: ObjectId;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ProgressDocument extends Document {
|
|
37
|
+
userId: ObjectId;
|
|
38
|
+
trackId: LearningTrackId;
|
|
39
|
+
completedLessonIds: string[];
|
|
40
|
+
completedStepIds: string[];
|
|
41
|
+
currentLessonId?: string;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GoogleProfile {
|
|
46
|
+
email: string;
|
|
47
|
+
name: string;
|
|
48
|
+
subject: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AccountStore {
|
|
52
|
+
register(input: { email: string; name: string; password: string }): Promise<AccountAuthResponse>;
|
|
53
|
+
login(input: { email: string; password: string }): Promise<AccountAuthResponse>;
|
|
54
|
+
loginWithGoogle(profile: GoogleProfile): Promise<AccountAuthResponse>;
|
|
55
|
+
getUserFromToken(token: string): Promise<AccountUser | null>;
|
|
56
|
+
getProgress(userId: string): Promise<LearningProgress[]>;
|
|
57
|
+
upsertProgress(userId: string, input: UpsertLearningProgressRequest): Promise<LearningProgress>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class AccountStoreError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
message: string,
|
|
63
|
+
readonly status = 400
|
|
64
|
+
) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "AccountStoreError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class MongoAccountStore implements AccountStore {
|
|
71
|
+
private readonly client: MongoClient;
|
|
72
|
+
private dbPromise: Promise<Db> | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
mongoUri: string,
|
|
76
|
+
private readonly dbName: string
|
|
77
|
+
) {
|
|
78
|
+
this.client = new MongoClient(mongoUri);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async register(input: { email: string; name: string; password: string }) {
|
|
82
|
+
const email = normalizeEmail(input.email);
|
|
83
|
+
const name = input.name.trim();
|
|
84
|
+
const password = input.password.trim();
|
|
85
|
+
|
|
86
|
+
if (!name) {
|
|
87
|
+
throw new AccountStoreError("Name is required to create an account.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!isValidEmail(email)) {
|
|
91
|
+
throw new AccountStoreError("A valid email address is required.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (password.length < 8) {
|
|
95
|
+
throw new AccountStoreError("Password must be at least 8 characters.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const users = await this.users();
|
|
99
|
+
const existing = await users.findOne({ email });
|
|
100
|
+
|
|
101
|
+
if (existing) {
|
|
102
|
+
throw new AccountStoreError("An account with this email already exists.", 409);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const now = new Date().toISOString();
|
|
106
|
+
const passwordRecord = await hashPassword(password);
|
|
107
|
+
const insertResult = await users.insertOne({
|
|
108
|
+
name,
|
|
109
|
+
email,
|
|
110
|
+
passwordHash: passwordRecord.hash,
|
|
111
|
+
passwordSalt: passwordRecord.salt,
|
|
112
|
+
createdAt: now,
|
|
113
|
+
updatedAt: now
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return this.createAuthResponse({
|
|
117
|
+
_id: insertResult.insertedId,
|
|
118
|
+
name,
|
|
119
|
+
email,
|
|
120
|
+
passwordHash: passwordRecord.hash,
|
|
121
|
+
passwordSalt: passwordRecord.salt,
|
|
122
|
+
createdAt: now,
|
|
123
|
+
updatedAt: now
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async login(input: { email: string; password: string }) {
|
|
128
|
+
const email = normalizeEmail(input.email);
|
|
129
|
+
const user = await (await this.users()).findOne({ email });
|
|
130
|
+
|
|
131
|
+
if (!user?.passwordHash || !user.passwordSalt) {
|
|
132
|
+
throw new AccountStoreError("Invalid email or password.", 401);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const passwordMatches = await verifyPassword(input.password, user.passwordSalt, user.passwordHash);
|
|
136
|
+
|
|
137
|
+
if (!passwordMatches) {
|
|
138
|
+
throw new AccountStoreError("Invalid email or password.", 401);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return this.createAuthResponse(user);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async loginWithGoogle(profile: GoogleProfile) {
|
|
145
|
+
const email = normalizeEmail(profile.email);
|
|
146
|
+
|
|
147
|
+
if (!isValidEmail(email)) {
|
|
148
|
+
throw new AccountStoreError("Google did not return a valid email address.", 401);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const users = await this.users();
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
const existing = await users.findOne({ email });
|
|
154
|
+
|
|
155
|
+
if (existing) {
|
|
156
|
+
await users.updateOne(
|
|
157
|
+
{ _id: existing._id },
|
|
158
|
+
{
|
|
159
|
+
$set: {
|
|
160
|
+
googleSubject: existing.googleSubject ?? profile.subject,
|
|
161
|
+
name: existing.name || profile.name,
|
|
162
|
+
updatedAt: now
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return this.createAuthResponse({
|
|
168
|
+
...existing,
|
|
169
|
+
googleSubject: existing.googleSubject ?? profile.subject,
|
|
170
|
+
name: existing.name || profile.name,
|
|
171
|
+
updatedAt: now
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const user: UserDocument = {
|
|
176
|
+
name: profile.name,
|
|
177
|
+
email,
|
|
178
|
+
googleSubject: profile.subject,
|
|
179
|
+
createdAt: now,
|
|
180
|
+
updatedAt: now
|
|
181
|
+
};
|
|
182
|
+
const insertResult = await users.insertOne(user);
|
|
183
|
+
|
|
184
|
+
return this.createAuthResponse({
|
|
185
|
+
...user,
|
|
186
|
+
_id: insertResult.insertedId
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async getUserFromToken(token: string) {
|
|
191
|
+
const tokenHash = hashToken(token);
|
|
192
|
+
const session = await (await this.sessions()).findOne({ tokenHash });
|
|
193
|
+
|
|
194
|
+
if (!session) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const user = await (await this.users()).findOne({ _id: session.userId });
|
|
199
|
+
|
|
200
|
+
return user ? serializeUser(user) : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async getProgress(userId: string) {
|
|
204
|
+
const records = await (await this.progress())
|
|
205
|
+
.find({ userId: toObjectId(userId) })
|
|
206
|
+
.sort({ updatedAt: -1 })
|
|
207
|
+
.toArray();
|
|
208
|
+
|
|
209
|
+
return records.map(serializeProgress);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async upsertProgress(userId: string, input: UpsertLearningProgressRequest) {
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
const progress = await this.progress();
|
|
215
|
+
|
|
216
|
+
// Fetch the existing record to merge completed lessons/steps and prevent data loss
|
|
217
|
+
const existing = await progress.findOne({
|
|
218
|
+
userId: toObjectId(userId),
|
|
219
|
+
trackId: input.trackId
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const completedLessonIds = Array.from(
|
|
223
|
+
new Set([...(existing?.completedLessonIds ?? []), ...(input.completedLessonIds ?? [])])
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const completedStepIds = Array.from(
|
|
227
|
+
new Set([...(existing?.completedStepIds ?? []), ...(input.completedStepIds ?? [])])
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const update = {
|
|
231
|
+
$set: {
|
|
232
|
+
completedLessonIds,
|
|
233
|
+
completedStepIds,
|
|
234
|
+
currentLessonId: input.currentLessonId ?? existing?.currentLessonId,
|
|
235
|
+
updatedAt: now
|
|
236
|
+
},
|
|
237
|
+
$setOnInsert: {
|
|
238
|
+
userId: toObjectId(userId),
|
|
239
|
+
trackId: input.trackId
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const result = await progress.findOneAndUpdate(
|
|
244
|
+
{
|
|
245
|
+
userId: toObjectId(userId),
|
|
246
|
+
trackId: input.trackId
|
|
247
|
+
},
|
|
248
|
+
update,
|
|
249
|
+
{
|
|
250
|
+
returnDocument: "after",
|
|
251
|
+
upsert: true
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (!result) {
|
|
256
|
+
throw new AccountStoreError("Unable to save learning progress.", 500);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return serializeProgress(result);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async createAuthResponse(user: WithId<UserDocument>): Promise<AccountAuthResponse> {
|
|
263
|
+
const token = randomBytes(32).toString("base64url");
|
|
264
|
+
await (await this.sessions()).insertOne({
|
|
265
|
+
tokenHash: hashToken(token),
|
|
266
|
+
userId: user._id,
|
|
267
|
+
createdAt: new Date().toISOString()
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
token,
|
|
272
|
+
user: serializeUser(user)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async db() {
|
|
277
|
+
if (!this.dbPromise) {
|
|
278
|
+
this.dbPromise = this.client.connect().then(async (client) => {
|
|
279
|
+
const db = client.db(this.dbName);
|
|
280
|
+
await Promise.all([
|
|
281
|
+
db.collection<UserDocument>("users").createIndex({ email: 1 }, { unique: true }),
|
|
282
|
+
db.collection<UserDocument>("users").createIndex({ googleSubject: 1 }, { sparse: true }),
|
|
283
|
+
db.collection<SessionDocument>("accountSessions").createIndex({ tokenHash: 1 }, { unique: true }),
|
|
284
|
+
db.collection<ProgressDocument>("progress").createIndex({ userId: 1, trackId: 1 }, { unique: true })
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
return db;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return this.dbPromise;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async users(): Promise<Collection<UserDocument>> {
|
|
295
|
+
return (await this.db()).collection<UserDocument>("users");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private async sessions(): Promise<Collection<SessionDocument>> {
|
|
299
|
+
return (await this.db()).collection<SessionDocument>("accountSessions");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async progress(): Promise<Collection<ProgressDocument>> {
|
|
303
|
+
return (await this.db()).collection<ProgressDocument>("progress");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function createAccountStore(mongoUri?: string, dbName = "visionos") {
|
|
308
|
+
return mongoUri ? new MongoAccountStore(mongoUri, dbName) : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function bearerTokenFromHeader(header: string | undefined) {
|
|
312
|
+
if (!header?.startsWith("Bearer ")) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const token = header.slice("Bearer ".length).trim();
|
|
317
|
+
|
|
318
|
+
return token || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeEmail(email: string) {
|
|
322
|
+
return email.trim().toLowerCase();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isValidEmail(email: string) {
|
|
326
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function hashToken(token: string) {
|
|
330
|
+
return createHash("sha256").update(token).digest("hex");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function serializeUser(user: WithId<UserDocument>): AccountUser {
|
|
334
|
+
return {
|
|
335
|
+
userId: user._id.toHexString(),
|
|
336
|
+
name: user.name,
|
|
337
|
+
email: user.email,
|
|
338
|
+
createdAt: user.createdAt
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function serializeProgress(progress: WithId<ProgressDocument>): LearningProgress {
|
|
343
|
+
return {
|
|
344
|
+
userId: progress.userId.toHexString(),
|
|
345
|
+
trackId: progress.trackId,
|
|
346
|
+
completedLessonIds: progress.completedLessonIds,
|
|
347
|
+
completedStepIds: progress.completedStepIds,
|
|
348
|
+
currentLessonId: progress.currentLessonId,
|
|
349
|
+
updatedAt: progress.updatedAt
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function toObjectId(value: string) {
|
|
354
|
+
if (!ObjectId.isValid(value)) {
|
|
355
|
+
throw new AccountStoreError("Invalid account identifier.", 400);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return new ObjectId(value);
|
|
359
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { MongoClient, ObjectId, type Collection, type Db, type Document, type WithId } from "mongodb";
|
|
2
|
+
import {
|
|
3
|
+
type LearningTrack,
|
|
4
|
+
type LearningLesson,
|
|
5
|
+
type LearningTrackId,
|
|
6
|
+
LEARNING_TRACKS,
|
|
7
|
+
BEGINNER_LESSONS
|
|
8
|
+
} from "../../../../shared/src/index.js";
|
|
9
|
+
|
|
10
|
+
interface TrackDocument extends Document {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
description: string;
|
|
14
|
+
creatorId: ObjectId;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface LessonDocument extends Document {
|
|
20
|
+
id: string;
|
|
21
|
+
trackId: string;
|
|
22
|
+
title: string;
|
|
23
|
+
difficulty: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
estimatedMinutes: number;
|
|
26
|
+
steps: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
objective: string;
|
|
30
|
+
command: string;
|
|
31
|
+
explanation: string;
|
|
32
|
+
validationType?: "syntax" | "file-exists" | "file-contains";
|
|
33
|
+
validationPath?: string;
|
|
34
|
+
validationContent?: string;
|
|
35
|
+
}>;
|
|
36
|
+
creatorId: ObjectId;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ContentStore {
|
|
42
|
+
listTracks(): Promise<LearningTrack[]>;
|
|
43
|
+
listLessons(trackId: string): Promise<LearningLesson[]>;
|
|
44
|
+
createTrack(creatorId: string, data: { id: string; label: string; description: string }): Promise<LearningTrack>;
|
|
45
|
+
updateTrack(creatorId: string, trackId: string, data: { label: string; description: string }): Promise<LearningTrack>;
|
|
46
|
+
createLesson(creatorId: string, data: Omit<LearningLesson, "creatorId" | "createdAt">): Promise<LearningLesson>;
|
|
47
|
+
updateLesson(creatorId: string, lessonId: string, data: Partial<Omit<LearningLesson, "creatorId" | "createdAt">>): Promise<LearningLesson>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class MongoContentStore implements ContentStore {
|
|
51
|
+
private readonly client: MongoClient;
|
|
52
|
+
private dbPromise: Promise<Db> | null = null;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
mongoUri: string,
|
|
56
|
+
private readonly dbName: string
|
|
57
|
+
) {
|
|
58
|
+
this.client = new MongoClient(mongoUri);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async listTracks(): Promise<LearningTrack[]> {
|
|
62
|
+
const tracksCollection = await this.tracks();
|
|
63
|
+
const dbTracks = await tracksCollection.find().toArray();
|
|
64
|
+
const serializedDbTracks = dbTracks.map(serializeTrack);
|
|
65
|
+
|
|
66
|
+
// Merge static and dynamic tracks
|
|
67
|
+
const allTracksMap = new Map<string, LearningTrack>();
|
|
68
|
+
for (const track of LEARNING_TRACKS) {
|
|
69
|
+
allTracksMap.set(track.id, track);
|
|
70
|
+
}
|
|
71
|
+
for (const track of serializedDbTracks) {
|
|
72
|
+
allTracksMap.set(track.id, track);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Array.from(allTracksMap.values());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async listLessons(trackId: string): Promise<LearningLesson[]> {
|
|
79
|
+
const lessonsCollection = await this.lessons();
|
|
80
|
+
const dbLessons = await lessonsCollection.find({ trackId }).toArray();
|
|
81
|
+
const serializedDbLessons = dbLessons.map(serializeLesson);
|
|
82
|
+
|
|
83
|
+
// Merge static and dynamic lessons
|
|
84
|
+
const allLessonsMap = new Map<string, LearningLesson>();
|
|
85
|
+
for (const lesson of BEGINNER_LESSONS) {
|
|
86
|
+
if (lesson.trackId === trackId) {
|
|
87
|
+
allLessonsMap.set(lesson.id, lesson);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const lesson of serializedDbLessons) {
|
|
91
|
+
allLessonsMap.set(lesson.id, lesson);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Array.from(allLessonsMap.values());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async createTrack(creatorId: string, data: { id: string; label: string; description: string }): Promise<LearningTrack> {
|
|
98
|
+
const tracksCollection = await this.tracks();
|
|
99
|
+
const existing = await tracksCollection.findOne({ id: data.id });
|
|
100
|
+
if (existing || LEARNING_TRACKS.some(t => t.id === data.id)) {
|
|
101
|
+
throw new Error(`Track with ID '${data.id}' already exists.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const now = new Date().toISOString();
|
|
105
|
+
const doc: TrackDocument = {
|
|
106
|
+
id: data.id,
|
|
107
|
+
label: data.label,
|
|
108
|
+
description: data.description,
|
|
109
|
+
creatorId: new ObjectId(creatorId),
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await tracksCollection.insertOne(doc);
|
|
115
|
+
return serializeTrack(doc as WithId<TrackDocument>);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async updateTrack(creatorId: string, trackId: string, data: { label: string; description: string }): Promise<LearningTrack> {
|
|
119
|
+
const tracksCollection = await this.tracks();
|
|
120
|
+
const track = await tracksCollection.findOne({ id: trackId });
|
|
121
|
+
|
|
122
|
+
if (!track) {
|
|
123
|
+
throw new Error(`Track not found or is static and cannot be modified.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (track.creatorId.toHexString() !== creatorId) {
|
|
127
|
+
throw new Error("You do not have permission to edit this track.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const now = new Date().toISOString();
|
|
131
|
+
const result = await tracksCollection.findOneAndUpdate(
|
|
132
|
+
{ id: trackId },
|
|
133
|
+
{
|
|
134
|
+
$set: {
|
|
135
|
+
label: data.label,
|
|
136
|
+
description: data.description,
|
|
137
|
+
updatedAt: now
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{ returnDocument: "after" }
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!result) {
|
|
144
|
+
throw new Error("Track not found during update.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return serializeTrack(result);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async createLesson(creatorId: string, data: Omit<LearningLesson, "creatorId" | "createdAt">): Promise<LearningLesson> {
|
|
151
|
+
const lessonsCollection = await this.lessons();
|
|
152
|
+
const existing = await lessonsCollection.findOne({ id: data.id });
|
|
153
|
+
if (existing || BEGINNER_LESSONS.some(l => l.id === data.id)) {
|
|
154
|
+
throw new Error(`Lesson with ID '${data.id}' already exists.`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Verify track ownership
|
|
158
|
+
const tracksCollection = await this.tracks();
|
|
159
|
+
const track = await tracksCollection.findOne({ id: data.trackId });
|
|
160
|
+
if (track && track.creatorId.toHexString() !== creatorId) {
|
|
161
|
+
throw new Error("You do not have permission to add lessons to this track.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const now = new Date().toISOString();
|
|
165
|
+
const doc: LessonDocument = {
|
|
166
|
+
id: data.id,
|
|
167
|
+
trackId: data.trackId,
|
|
168
|
+
title: data.title,
|
|
169
|
+
difficulty: data.difficulty,
|
|
170
|
+
summary: data.summary,
|
|
171
|
+
estimatedMinutes: data.estimatedMinutes,
|
|
172
|
+
steps: data.steps,
|
|
173
|
+
creatorId: new ObjectId(creatorId),
|
|
174
|
+
createdAt: now,
|
|
175
|
+
updatedAt: now
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await lessonsCollection.insertOne(doc);
|
|
179
|
+
return serializeLesson(doc as WithId<LessonDocument>);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async updateLesson(creatorId: string, lessonId: string, data: Partial<Omit<LearningLesson, "creatorId" | "createdAt">>): Promise<LearningLesson> {
|
|
183
|
+
const lessonsCollection = await this.lessons();
|
|
184
|
+
const lesson = await lessonsCollection.findOne({ id: lessonId });
|
|
185
|
+
|
|
186
|
+
if (!lesson) {
|
|
187
|
+
throw new Error(`Lesson not found or is static and cannot be modified.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (lesson.creatorId.toHexString() !== creatorId) {
|
|
191
|
+
throw new Error("You do not have permission to edit this lesson.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
const updatePayload: Partial<LessonDocument> = {
|
|
196
|
+
updatedAt: now
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (data.title !== undefined) updatePayload.title = data.title;
|
|
200
|
+
if (data.difficulty !== undefined) updatePayload.difficulty = data.difficulty;
|
|
201
|
+
if (data.summary !== undefined) updatePayload.summary = data.summary;
|
|
202
|
+
if (data.estimatedMinutes !== undefined) updatePayload.estimatedMinutes = data.estimatedMinutes;
|
|
203
|
+
if (data.steps !== undefined) updatePayload.steps = data.steps;
|
|
204
|
+
|
|
205
|
+
const result = await lessonsCollection.findOneAndUpdate(
|
|
206
|
+
{ id: lessonId },
|
|
207
|
+
{ $set: updatePayload },
|
|
208
|
+
{ returnDocument: "after" }
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (!result) {
|
|
212
|
+
throw new Error("Lesson not found during update.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return serializeLesson(result);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async db() {
|
|
219
|
+
if (!this.dbPromise) {
|
|
220
|
+
this.dbPromise = this.client.connect().then(async (client) => {
|
|
221
|
+
const db = client.db(this.dbName);
|
|
222
|
+
await Promise.all([
|
|
223
|
+
db.collection<TrackDocument>("tracks").createIndex({ id: 1 }, { unique: true }),
|
|
224
|
+
db.collection<LessonDocument>("lessons").createIndex({ id: 1 }, { unique: true }),
|
|
225
|
+
db.collection<LessonDocument>("lessons").createIndex({ trackId: 1 })
|
|
226
|
+
]);
|
|
227
|
+
return db;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return this.dbPromise;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async tracks(): Promise<Collection<TrackDocument>> {
|
|
234
|
+
return (await this.db()).collection<TrackDocument>("tracks");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async lessons(): Promise<Collection<LessonDocument>> {
|
|
238
|
+
return (await this.db()).collection<LessonDocument>("lessons");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function serializeTrack(doc: WithId<TrackDocument> | TrackDocument): LearningTrack {
|
|
243
|
+
return {
|
|
244
|
+
id: doc.id,
|
|
245
|
+
label: doc.label,
|
|
246
|
+
description: doc.description,
|
|
247
|
+
creatorId: doc.creatorId.toHexString(),
|
|
248
|
+
createdAt: doc.createdAt
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function serializeLesson(doc: WithId<LessonDocument> | LessonDocument): LearningLesson {
|
|
253
|
+
return {
|
|
254
|
+
id: doc.id,
|
|
255
|
+
trackId: doc.trackId,
|
|
256
|
+
title: doc.title,
|
|
257
|
+
difficulty: doc.difficulty as any,
|
|
258
|
+
summary: doc.summary,
|
|
259
|
+
estimatedMinutes: doc.estimatedMinutes,
|
|
260
|
+
steps: doc.steps,
|
|
261
|
+
creatorId: doc.creatorId.toHexString(),
|
|
262
|
+
createdAt: doc.createdAt
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const scrypt = promisify(scryptCallback);
|
|
5
|
+
const keyLength = 64;
|
|
6
|
+
|
|
7
|
+
export async function hashPassword(password: string) {
|
|
8
|
+
const salt = randomBytes(16).toString("hex");
|
|
9
|
+
const hash = (await scrypt(password, salt, keyLength)) as Buffer;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
hash: hash.toString("hex"),
|
|
13
|
+
salt
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function verifyPassword(password: string, salt: string, expectedHash: string) {
|
|
18
|
+
const hash = (await scrypt(password, salt, keyLength)) as Buffer;
|
|
19
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
20
|
+
|
|
21
|
+
if (hash.length !== expected.length) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return timingSafeEqual(hash, expected);
|
|
26
|
+
}
|