Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions services/cloud-api/bun.lock

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions services/cloud-api/migrations/0001_shares.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
PRAGMA foreign_keys = ON;

CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
) STRICT;

CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
) STRICT;

CREATE TABLE entitlements (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
plan TEXT NOT NULL CHECK (plan IN ('free', 'cloud')) DEFAULT 'free',
status TEXT NOT NULL CHECK (status IN ('active', 'trialing', 'past_due', 'canceled')) DEFAULT 'active',
current_period_end INTEGER
) STRICT;

CREATE TABLE shares (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
object_key TEXT NOT NULL,
content_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
idempotency_key TEXT NOT NULL,
published_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
revoked_at INTEGER
) STRICT;

CREATE UNIQUE INDEX shares_owner_idempotency
ON shares(user_id, idempotency_key);

CREATE UNIQUE INDEX shares_active_entry
ON shares(user_id, entry_id)
WHERE status = 'active';

CREATE INDEX shares_public_slug
ON shares(slug, status);

CREATE INDEX sessions_token_expiry
ON sessions(token_hash, expires_at);

CREATE TRIGGER shares_free_limit_before_insert
BEFORE INSERT ON shares
WHEN NEW.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM entitlements
WHERE user_id = NEW.user_id
AND plan = 'cloud'
AND status IN ('active', 'trialing')
)
AND EXISTS (
SELECT 1 FROM shares
WHERE user_id = NEW.user_id AND status = 'active'
)
BEGIN
SELECT RAISE(ABORT, 'free_share_limit');
END;
21 changes: 21 additions & 0 deletions services/cloud-api/migrations/0002_email_otp.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
PRAGMA foreign_keys = ON;

CREATE TABLE otp_challenges (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
code_hash TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL,
consumed_at INTEGER,
created_at INTEGER NOT NULL
) STRICT;

CREATE INDEX otp_challenges_email_created
ON otp_challenges(email, created_at DESC);

CREATE INDEX otp_challenges_fingerprint_created
ON otp_challenges(request_fingerprint, created_at DESC);

CREATE INDEX otp_challenges_expiry
ON otp_challenges(expires_at);
20 changes: 20 additions & 0 deletions services/cloud-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "markd-cloud-api",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"deploy:check": "wrangler deploy --dry-run",
"db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:remote": "wrangler d1 migrations apply DB --remote",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260716.1",
"@types/bun": "^1.3.0",
"typescript": "^5.8.3",
"wrangler": "^4.42.0"
}
}
51 changes: 51 additions & 0 deletions services/cloud-api/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { sha256 } from "./crypto";
import { error } from "./http";
import type { AccountPlan, AuthenticatedUser, Env } from "./types";

interface SessionRow {
user_id: string;
email: string;
plan: AccountPlan | null;
}

export class AuthenticationError extends Error {}

export function bearerToken(request: Request): string {
const authorization = request.headers.get("authorization") ?? "";
const match = /^Bearer ([A-Za-z0-9_-]{32,256})$/.exec(authorization);
if (!match) throw new AuthenticationError();
return match[1];
}

export async function authenticatedUser(
request: Request,
env: Env,
): Promise<AuthenticatedUser> {
const token = bearerToken(request);

const row = await env.DB.prepare(
`SELECT users.id AS user_id, users.email, entitlements.plan
FROM sessions
JOIN users ON users.id = sessions.user_id
LEFT JOIN entitlements ON entitlements.user_id = users.id
AND entitlements.status IN ('active', 'trialing')
WHERE sessions.token_hash = ? AND sessions.expires_at > ?`,
)
.bind(await sha256(token), Date.now())
.first<SessionRow>();
if (!row) throw new AuthenticationError();

return { id: row.user_id, email: row.email, plan: row.plan ?? "free" };
}

export async function revokeSession(request: Request, env: Env): Promise<Response> {
const token = bearerToken(request);
await env.DB.prepare("DELETE FROM sessions WHERE token_hash = ?")
.bind(await sha256(token))
.run();
return new Response(null, { status: 204 });
}

export function authenticationRequired(): Response {
return error(401, "login_required", "Sign in to Markd before publishing a note.");
}
55 changes: 55 additions & 0 deletions services/cloud-api/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const encoder = new TextEncoder();

export async function sha256(value: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}

export async function hmacSha256(secret: string, value: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
return Array.from(new Uint8Array(signature), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}

function randomBase64Url(byteLength: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
}

export function randomOtp(): string {
const value = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
return value.toString().padStart(6, "0");
}

export function randomToken(): string {
return randomBase64Url(32);
}

export function randomSlug(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
}

export function newId(prefix: "share" | "otp" | "session" | "user"): string {
return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
}
35 changes: 35 additions & 0 deletions services/cloud-api/src/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const OTP_SENDER = "no-reply@usemarkd.app";

export async function sendOtpEmail(
env: { EMAIL: SendEmail },
email: string,
code: string,
): Promise<void> {
await env.EMAIL.send({
to: email,
from: { email: OTP_SENDER, name: "Markd" },
subject: "Your Markd sign-in code",
text: [
"Sign in to Markd",
"",
`Your verification code is: ${code}`,
"",
"This code expires in 5 minutes and can only be used once.",
"If you did not request this code, you can ignore this email.",
].join("\n"),
html: `<!doctype html>
<html lang="en">
<body style="margin:0;background:#f5f5f3;color:#171717;font-family:Arial,sans-serif">
<div style="max-width:480px;margin:0 auto;padding:48px 20px">
<div style="background:#ffffff;border:1px solid #e5e5e2;border-radius:16px;padding:32px">
<p style="margin:0 0 24px;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#737373">Markd</p>
<h1 style="margin:0;font-size:22px;line-height:1.25">Sign in to Markd</h1>
<p style="margin:12px 0 0;font-size:14px;line-height:1.6;color:#666">Enter this code in the Markd desktop app:</p>
<div style="margin:24px 0;padding:18px 20px;border-radius:12px;background:#f5f5f3;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:30px;font-weight:600;letter-spacing:.24em;text-align:center">${code}</div>
<p style="margin:0;font-size:12px;line-height:1.6;color:#858585">The code expires in 5 minutes and can only be used once. If you did not request it, you can safely ignore this email.</p>
</div>
</div>
</body>
</html>`,
});
}
54 changes: 54 additions & 0 deletions services/cloud-api/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const JSON_HEADERS = {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
} as const;

export function json(
body: unknown,
status = 200,
headers?: HeadersInit,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...JSON_HEADERS, ...headers },
});
}

export function error(
status: number,
code: string,
message: string,
extra?: Record<string, unknown>,
): Response {
return json({ error: { code, message, ...extra } }, status);
}

export async function readJson(
request: Request,
maxBytes: number,
): Promise<unknown> {
const buffer = await request.arrayBuffer();
if (buffer.byteLength > maxBytes) {
throw new RequestBodyError(413, "payload_too_large", "The request body is too large.");
}
try {
return JSON.parse(new TextDecoder().decode(buffer));
} catch {
throw new RequestBodyError(400, "invalid_json", "The request body must be valid JSON.");
}
}

export class RequestBodyError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}

export function notFound(): Response {
return error(404, "not_found", "The published note was not found.");
}
87 changes: 87 additions & 0 deletions services/cloud-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { error, json, RequestBodyError } from "./http";
import {
createShare,
getOwnedShare,
getPublicShare,
revokeShare,
updateShare,
} from "./shares";
import type { Env } from "./types";
import { ValidationError } from "./validation";
import {
authenticatedUser,
AuthenticationError,
authenticationRequired,
revokeSession,
} from "./auth";
import { OtpError, requestOtp, verifyOtp } from "./otp";

const SHARE_ID = /^\/v1\/shares\/(share_[a-f0-9]{32})$/;
const PUBLIC_SLUG = /^\/v1\/public\/shares\/([a-zA-Z0-9_-]{20,24})$/;

async function route(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const url = new URL(request.url);

if (request.method === "GET" && url.pathname === "/health") {
return json({ ok: true });
}
if (request.method === "POST" && url.pathname === "/v1/auth/otp/request") {
return requestOtp(request, env);
}
if (request.method === "POST" && url.pathname === "/v1/auth/otp/verify") {
return verifyOtp(request, env);
}
if (request.method === "DELETE" && url.pathname === "/v1/session") {
return revokeSession(request, env);
}
if (request.method === "GET" && url.pathname === "/v1/me") {
const user = await authenticatedUser(request, env);
return json({ user: { id: user.id, email: user.email, plan: user.plan } });
}
if (request.method === "POST" && url.pathname === "/v1/shares") {
return createShare(request, env);
}

const publicMatch = PUBLIC_SLUG.exec(url.pathname);
if (request.method === "GET" && publicMatch) {
return getPublicShare(env, publicMatch[1]);
}

const shareMatch = SHARE_ID.exec(url.pathname);
if (shareMatch) {
if (request.method === "GET") {
return getOwnedShare(request, env, shareMatch[1]);
}
if (request.method === "PUT") {
return updateShare(request, env, ctx, shareMatch[1]);
}
if (request.method === "DELETE") {
return revokeShare(request, env, ctx, shareMatch[1]);
}
}

return error(404, "route_not_found", "The requested endpoint does not exist.");
}

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
return await route(request, env, ctx);
} catch (cause) {
if (cause instanceof AuthenticationError) return authenticationRequired();
if (cause instanceof OtpError) return error(cause.status, cause.code, cause.message);
if (cause instanceof ValidationError) {
return error(400, cause.code, cause.message);
}
if (cause instanceof RequestBodyError) {
return error(cause.status, cause.code, cause.message);
}
console.error("Unhandled publishing API error", cause);
return error(500, "internal_error", "The publishing service could not complete the request.");
}
},
} satisfies ExportedHandler<Env>;
Loading