ijihun-planner-studio 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.
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="light" />
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='10' fill='%231f1f23'/%3E%3Cpath d='M16 18h32M16 30h32M16 42h20' stroke='white' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E" />
8
+ <title>Planner Studio</title>
9
+ <script type="module" crossorigin src="/assets/index-D0WfhOqG.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BXkpRJGR.css">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ </body>
15
+ </html>
package/lib/auth.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import { createHash, createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+
4
+ const scrypt = promisify(scryptCallback);
5
+ const DEFAULT_SCRYPT = {
6
+ N: 32768,
7
+ r: 8,
8
+ p: 1,
9
+ keyLength: 64,
10
+ maxmem: 64 * 1024 * 1024
11
+ };
12
+
13
+ export async function hashPassword(password, options = {}) {
14
+ if (typeof password !== "string" || password.length < 12) {
15
+ throw new Error("Password must be at least 12 characters.");
16
+ }
17
+ const params = { ...DEFAULT_SCRYPT, ...options };
18
+ const salt = randomBytes(24);
19
+ const key = await scrypt(password, salt, params.keyLength, {
20
+ N: params.N,
21
+ r: params.r,
22
+ p: params.p,
23
+ maxmem: params.maxmem
24
+ });
25
+ return [
26
+ "scrypt",
27
+ params.N,
28
+ params.r,
29
+ params.p,
30
+ salt.toString("base64url"),
31
+ Buffer.from(key).toString("base64url")
32
+ ].join("$");
33
+ }
34
+
35
+ export async function verifyPassword(password, encoded) {
36
+ try {
37
+ const [scheme, nValue, rValue, pValue, saltValue, keyValue] = String(encoded || "").split("$");
38
+ if (scheme !== "scrypt" || !nValue || !rValue || !pValue || !saltValue || !keyValue) return false;
39
+ const storedKey = Buffer.from(keyValue, "base64url");
40
+ const key = await scrypt(String(password || ""), Buffer.from(saltValue, "base64url"), storedKey.length, {
41
+ N: Number(nValue),
42
+ r: Number(rValue),
43
+ p: Number(pValue),
44
+ maxmem: DEFAULT_SCRYPT.maxmem
45
+ });
46
+ return storedKey.length === key.length && timingSafeEqual(storedKey, key);
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ export function generateSessionSecret() {
53
+ return randomBytes(48).toString("base64url");
54
+ }
55
+
56
+ export function signValue(value, secret) {
57
+ return createHmac("sha256", secret).update(String(value)).digest("base64url");
58
+ }
59
+
60
+ export function timingSafeStringEqual(a, b) {
61
+ const left = createHash("sha256").update(String(a)).digest();
62
+ const right = createHash("sha256").update(String(b)).digest();
63
+ return timingSafeEqual(left, right);
64
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "ijihun-planner-studio",
3
+ "version": "0.1.0",
4
+ "description": "A local owner-only timebox and Mandarart planner with Apple Reminders and Google Tasks bridge support.",
5
+ "type": "module",
6
+ "bin": {
7
+ "planner-studio": "bin/planner-studio.mjs"
8
+ },
9
+ "main": "./server.mjs",
10
+ "files": [
11
+ "bin/",
12
+ "dist/",
13
+ "lib/",
14
+ "scripts/create-owner-secret.mjs",
15
+ "server.mjs",
16
+ ".env.example",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "dev": "node scripts/dev.mjs",
21
+ "api": "node server.mjs --api-only --port 4178",
22
+ "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
23
+ "preview": "vite preview --host 127.0.0.1 --port 5179",
24
+ "start": "node server.mjs --port 4179",
25
+ "setup-owner": "node scripts/create-owner-secret.mjs --email leejihun04@gmail.com --env .env.local",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "keywords": [
32
+ "planner",
33
+ "timebox",
34
+ "mandarart",
35
+ "apple-reminders",
36
+ "google-tasks"
37
+ ],
38
+ "author": "ljihun",
39
+ "license": "UNLICENSED",
40
+ "dependencies": {
41
+ "@vitejs/plugin-react": "^4.3.4",
42
+ "lucide-react": "^0.468.0",
43
+ "react": "^18.3.1",
44
+ "react-dom": "^18.3.1",
45
+ "typescript": "^5.7.2",
46
+ "vite": "^6.0.5"
47
+ },
48
+ "devDependencies": {
49
+ "@types/react": "^19.2.17",
50
+ "@types/react-dom": "^19.2.3"
51
+ }
52
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { generateSessionSecret, hashPassword } from "../lib/auth.mjs";
5
+
6
+ const args = process.argv.slice(2);
7
+ const email = getArg("--email") || process.env.PLANNER_OWNER_EMAIL || "";
8
+ const envPath = resolve(getArg("--env") || ".env.local");
9
+ const bridgeRoot = getArg("--bridge-root") || process.env.PLANNER_REMINDERS_BRIDGE_ROOT || "/Users/ijihun/apps/icloud-reminders-google-sync";
10
+ const password = await readPassword();
11
+
12
+ if (!email || !email.includes("@")) {
13
+ console.error("Usage: create-owner-secret --email <owner-email> [--env .env.local] < password");
14
+ process.exit(1);
15
+ }
16
+ if (!password) {
17
+ console.error("Password must be provided on stdin or PLANNER_SETUP_PASSWORD.");
18
+ process.exit(1);
19
+ }
20
+
21
+ const passwordHash = await hashPassword(password);
22
+ const sessionSecret = generateSessionSecret();
23
+ const nextValues = new Map([
24
+ ["PLANNER_OWNER_EMAIL", email.trim().toLowerCase()],
25
+ ["PLANNER_PASSWORD_HASH", passwordHash],
26
+ ["PLANNER_SESSION_SECRET", sessionSecret],
27
+ ["PLANNER_COOKIE_SECURE", process.env.PLANNER_COOKIE_SECURE || "0"],
28
+ ["PLANNER_REMINDERS_BRIDGE_ROOT", bridgeRoot]
29
+ ]);
30
+
31
+ const existing = await readExistingEnv(envPath);
32
+ for (const [key, value] of nextValues) existing.set(key, value);
33
+
34
+ const lines = [
35
+ "# Planner Studio local owner authentication.",
36
+ "# Keep this file out of git. It contains password hash and session signing material.",
37
+ ...Array.from(existing, ([key, value]) => `${key}=${value}`)
38
+ ];
39
+
40
+ await mkdir(dirname(envPath), { recursive: true });
41
+ await writeFile(envPath, `${lines.join("\n")}\n`, { mode: 0o600 });
42
+ await chmod(envPath, 0o600);
43
+ console.log(`Owner auth config written to ${envPath}`);
44
+
45
+ function getArg(name) {
46
+ const index = args.indexOf(name);
47
+ return index >= 0 ? args[index + 1] : "";
48
+ }
49
+
50
+ async function readPassword() {
51
+ if (process.env.PLANNER_SETUP_PASSWORD) return process.env.PLANNER_SETUP_PASSWORD;
52
+ const chunks = [];
53
+ for await (const chunk of process.stdin) chunks.push(chunk);
54
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
55
+ }
56
+
57
+ async function readExistingEnv(filePath) {
58
+ const values = new Map();
59
+ try {
60
+ const text = await readFile(filePath, "utf8");
61
+ for (const line of text.split(/\r?\n/)) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed || trimmed.startsWith("#")) continue;
64
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
65
+ if (match) values.set(match[1], match[2]);
66
+ }
67
+ } catch {
68
+ // Missing env file is expected on first setup.
69
+ }
70
+ return values;
71
+ }