alif-fund 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/dist/worker.js ADDED
@@ -0,0 +1,460 @@
1
+ const jsonHeaders = {
2
+ "content-type": "application/json; charset=utf-8"
3
+ };
4
+ export default {
5
+ async fetch(request, env) {
6
+ const url = new URL(request.url);
7
+ try {
8
+ if (request.method === "OPTIONS") {
9
+ return new Response(null, { headers: corsHeaders() });
10
+ }
11
+ if (url.pathname === "/health") {
12
+ return json({ ok: true, env: env.ALIF_ENV ?? "unknown" });
13
+ }
14
+ if (request.method === "POST" && url.pathname === "/v1/auth/otp/start") {
15
+ return startEmailOtp(request, env);
16
+ }
17
+ if (request.method === "POST" && url.pathname === "/v1/auth/otp/verify") {
18
+ return verifyEmailOtp(request, env);
19
+ }
20
+ if (request.method === "POST" && url.pathname === "/v1/applications") {
21
+ return createApplication(request, env);
22
+ }
23
+ if (request.method === "GET" && url.pathname === "/v1/status") {
24
+ const auth = await requireAuth(request, env, "application:read");
25
+ return getStatus(env, auth);
26
+ }
27
+ if (request.method === "POST" && url.pathname === "/v1/metrics") {
28
+ const auth = await requireAuth(request, env, "metrics:create");
29
+ return createMetric(request, env, auth);
30
+ }
31
+ const pointMatch = url.pathname.match(/^\/v1\/metrics\/([^/]+)\/points$/);
32
+ if (request.method === "POST" && pointMatch) {
33
+ const auth = await requireAuth(request, env, "metrics:write");
34
+ return createMetricPoint(request, env, auth, decodeURIComponent(pointMatch[1]));
35
+ }
36
+ return json({ error: "not_found" }, 404);
37
+ }
38
+ catch (error) {
39
+ if (error instanceof HttpError) {
40
+ return json({ error: error.code, message: error.message }, error.status);
41
+ }
42
+ console.error(error);
43
+ return json({ error: "internal_error" }, 500);
44
+ }
45
+ },
46
+ async queue(batch, env) {
47
+ for (const message of batch.messages) {
48
+ await evaluateMetricAlert(env, message.body);
49
+ message.ack();
50
+ }
51
+ },
52
+ async scheduled(_event, env) {
53
+ await env.DB.prepare(`INSERT INTO audit_log (id, actor, action, resource_type, metadata_json)
54
+ VALUES (?, 'system', 'scheduled_tick', 'worker', ?)`)
55
+ .bind(id("audit"), JSON.stringify({ note: "reserved for source sync jobs" }))
56
+ .run();
57
+ }
58
+ };
59
+ async function createApplication(request, env) {
60
+ const session = await optionalSession(request, env);
61
+ if (env.SIGNUP_SECRET) {
62
+ const presented = request.headers.get("x-alif-signup-secret");
63
+ if (presented !== env.SIGNUP_SECRET) {
64
+ throw new HttpError(401, "unauthorized", "Invalid signup secret.");
65
+ }
66
+ }
67
+ else if (env.REQUIRE_EMAIL_OTP === "true" && !session) {
68
+ throw new HttpError(401, "email_otp_required", "Run `alif login` before applying.");
69
+ }
70
+ const body = await readJson(request);
71
+ const companyName = requireString(body.company_name, "company_name");
72
+ const founderName = requireString(body.founder_name, "founder_name");
73
+ const founderEmail = normalizeEmail(requireString(body.founder_email, "founder_email"));
74
+ if (session && founderEmail !== session.email) {
75
+ throw new HttpError(403, "email_mismatch", "Founder email must match the verified login email.");
76
+ }
77
+ const narrative = body.narrative ?? {};
78
+ const companyId = id("co");
79
+ const founderId = id("founder");
80
+ const applicationId = id("app");
81
+ const token = await issueToken(env, {
82
+ companyId,
83
+ applicationId,
84
+ name: "default-agent-token",
85
+ scopes: ["application:read", "metrics:create", "metrics:write"],
86
+ allowedMetrics: null,
87
+ expiresAt: null
88
+ });
89
+ const statements = [
90
+ env.DB.prepare("INSERT INTO companies (id, name, website) VALUES (?, ?, ?)")
91
+ .bind(companyId, companyName, body.website ?? null),
92
+ env.DB.prepare("INSERT INTO founders (id, company_id, email, name) VALUES (?, ?, ?, ?)")
93
+ .bind(founderId, companyId, founderEmail, founderName),
94
+ env.DB.prepare("INSERT INTO applications (id, company_id, narrative_json) VALUES (?, ?, ?)")
95
+ .bind(applicationId, companyId, JSON.stringify(narrative)),
96
+ env.DB.prepare(`INSERT INTO api_tokens
97
+ (public_id, token_hash, company_id, application_id, name, scopes, allowed_metrics, expires_at)
98
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).bind(token.publicId, token.hash, companyId, applicationId, "default-agent-token", token.scopes, null, null),
99
+ env.DB.prepare(`INSERT INTO audit_log (id, company_id, actor, action, resource_type, resource_id, metadata_json)
100
+ VALUES (?, ?, ?, 'application.created', 'application', ?, ?)`).bind(id("audit"), companyId, founderEmail, applicationId, "{}")
101
+ ];
102
+ const metric = body.primary_metric;
103
+ if (metric?.key) {
104
+ statements.push(env.DB.prepare(`INSERT INTO metrics
105
+ (id, company_id, key, display_name, unit, cadence, direction)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).bind(id("metric"), companyId, slug(metric.key), metric.display_name ?? titleize(metric.key), metric.unit ?? "count", metric.cadence ?? "weekly", metric.direction ?? "up"));
107
+ }
108
+ await env.DB.batch(statements);
109
+ return json({
110
+ company_id: companyId,
111
+ application_id: applicationId,
112
+ founder_email_verified: Boolean(session),
113
+ token: token.raw,
114
+ token_scopes: token.scopes.split(" ")
115
+ }, 201);
116
+ }
117
+ async function startEmailOtp(request, env) {
118
+ const body = await readJson(request);
119
+ const email = normalizeEmail(requireString(body.email, "email"));
120
+ const recent = await env.DB.prepare(`SELECT COUNT(*) AS count
121
+ FROM email_otps
122
+ WHERE email = ?
123
+ AND created_at > datetime('now', '-10 minutes')`).bind(email).first();
124
+ if ((recent?.count ?? 0) >= 5) {
125
+ throw new HttpError(429, "otp_rate_limited", "Too many OTP requests. Try again later.");
126
+ }
127
+ const code = randomOtp();
128
+ const otpId = id("otp");
129
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
130
+ await env.DB.prepare(`INSERT INTO email_otps (id, email, code_hash, expires_at)
131
+ VALUES (?, ?, ?, ?)`).bind(otpId, email, await sha256(`${email}:${code}`), expiresAt).run();
132
+ const sent = await sendOtpEmail(env, email, code);
133
+ await env.DB.prepare(`INSERT INTO audit_log (id, actor, action, resource_type, resource_id, metadata_json)
134
+ VALUES (?, ?, 'auth.otp.started', 'email_otp', ?, ?)`).bind(id("audit"), email, otpId, JSON.stringify({ sent })).run();
135
+ const payload = {
136
+ ok: true,
137
+ email,
138
+ expires_at: expiresAt,
139
+ delivery: sent ? "email" : "development_response"
140
+ };
141
+ if (!sent)
142
+ payload.dev_otp = code;
143
+ return json(payload);
144
+ }
145
+ async function verifyEmailOtp(request, env) {
146
+ const body = await readJson(request);
147
+ const email = normalizeEmail(requireString(body.email, "email"));
148
+ const code = requireString(body.code, "code").replace(/\s+/g, "");
149
+ if (!/^\d{6}$/.test(code)) {
150
+ throw new HttpError(400, "invalid_otp", "OTP code must be 6 digits.");
151
+ }
152
+ const otp = await env.DB.prepare(`SELECT id, code_hash, expires_at, attempts
153
+ FROM email_otps
154
+ WHERE email = ? AND consumed_at IS NULL
155
+ ORDER BY created_at DESC
156
+ LIMIT 1`).bind(email).first();
157
+ if (!otp)
158
+ throw new HttpError(401, "invalid_otp", "Invalid or expired OTP.");
159
+ if (Date.parse(otp.expires_at) <= Date.now()) {
160
+ throw new HttpError(401, "expired_otp", "OTP expired. Request a new code.");
161
+ }
162
+ if (otp.attempts >= 5) {
163
+ throw new HttpError(429, "otp_attempts_exceeded", "Too many attempts. Request a new code.");
164
+ }
165
+ const codeHash = await sha256(`${email}:${code}`);
166
+ if (!timingSafeEqual(codeHash, otp.code_hash)) {
167
+ await env.DB.prepare("UPDATE email_otps SET attempts = attempts + 1 WHERE id = ?").bind(otp.id).run();
168
+ throw new HttpError(401, "invalid_otp", "Invalid OTP.");
169
+ }
170
+ const session = await issueSession(email);
171
+ await env.DB.batch([
172
+ env.DB.prepare("UPDATE email_otps SET consumed_at = ? WHERE id = ?").bind(new Date().toISOString(), otp.id),
173
+ env.DB.prepare(`INSERT INTO email_sessions (public_id, token_hash, email, expires_at)
174
+ VALUES (?, ?, ?, ?)`).bind(session.publicId, session.hash, email, session.expiresAt),
175
+ env.DB.prepare(`INSERT INTO audit_log (id, actor, action, resource_type, resource_id, metadata_json)
176
+ VALUES (?, ?, 'auth.otp.verified', 'email_session', ?, ?)`).bind(id("audit"), email, session.publicId, "{}")
177
+ ]);
178
+ return json({
179
+ ok: true,
180
+ email,
181
+ session_token: session.raw,
182
+ expires_at: session.expiresAt
183
+ });
184
+ }
185
+ async function getStatus(env, auth) {
186
+ const application = await env.DB.prepare(`SELECT a.id, a.status, a.submitted_at, c.name AS company_name, c.website
187
+ FROM applications a
188
+ JOIN companies c ON c.id = a.company_id
189
+ WHERE a.id = ? AND a.company_id = ?`).bind(auth.token.application_id, auth.token.company_id).first();
190
+ const metrics = await env.DB.prepare(`SELECT m.key, m.display_name, m.unit, m.cadence, m.direction, m.verification_level,
191
+ p.value AS latest_value, p.timestamp AS latest_timestamp
192
+ FROM metrics m
193
+ LEFT JOIN metric_points p ON p.id = (
194
+ SELECT id FROM metric_points
195
+ WHERE metric_id = m.id
196
+ ORDER BY timestamp DESC, created_at DESC
197
+ LIMIT 1
198
+ )
199
+ WHERE m.company_id = ?
200
+ ORDER BY m.created_at ASC`).bind(auth.token.company_id).all();
201
+ const alerts = await env.DB.prepare(`SELECT alert_type, severity, summary, created_at
202
+ FROM alerts
203
+ WHERE company_id = ?
204
+ ORDER BY created_at DESC
205
+ LIMIT 10`).bind(auth.token.company_id).all();
206
+ return json({ application, metrics: metrics.results, alerts: alerts.results });
207
+ }
208
+ async function createMetric(request, env, auth) {
209
+ const body = await readJson(request);
210
+ const key = slug(requireString(body.key, "key"));
211
+ const metricId = id("metric");
212
+ await env.DB.prepare(`INSERT INTO metrics
213
+ (id, company_id, key, display_name, unit, cadence, direction, source_type)
214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).bind(metricId, auth.token.company_id, key, body.display_name ?? titleize(key), body.unit ?? "count", body.cadence ?? "weekly", body.direction ?? "up", body.source_type ?? "self_reported").run();
215
+ await audit(env, auth, "metric.created", "metric", metricId, { key });
216
+ return json({ id: metricId, key }, 201);
217
+ }
218
+ async function createMetricPoint(request, env, auth, metricKey) {
219
+ const key = slug(metricKey);
220
+ if (auth.allowedMetrics && !auth.allowedMetrics.has(key)) {
221
+ throw new HttpError(403, "forbidden", "Token is not allowed to write this metric.");
222
+ }
223
+ const body = await readJson(request);
224
+ if (typeof body.value !== "number" || !Number.isFinite(body.value)) {
225
+ throw new HttpError(400, "invalid_value", "value must be a finite number.");
226
+ }
227
+ const timestamp = body.timestamp ?? new Date().toISOString();
228
+ if (Number.isNaN(Date.parse(timestamp))) {
229
+ throw new HttpError(400, "invalid_timestamp", "timestamp must be an ISO date.");
230
+ }
231
+ const metric = await env.DB.prepare("SELECT id FROM metrics WHERE company_id = ? AND key = ?").bind(auth.token.company_id, key).first();
232
+ if (!metric) {
233
+ throw new HttpError(404, "metric_not_found", "Create the metric before writing points.");
234
+ }
235
+ const idempotencyKey = request.headers.get("idempotency-key")
236
+ ?? body.idempotency_key
237
+ ?? `${auth.token.company_id}:${key}:${timestamp}`;
238
+ const pointId = id("point");
239
+ try {
240
+ await env.DB.prepare(`INSERT INTO metric_points
241
+ (id, metric_id, company_id, timestamp, value, source, confidence, raw_event_id, idempotency_key)
242
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).bind(pointId, metric.id, auth.token.company_id, timestamp, body.value, body.source ?? auth.token.name, body.confidence ?? 0.5, body.raw_event_id ?? null, idempotencyKey).run();
243
+ }
244
+ catch (error) {
245
+ if (String(error).includes("UNIQUE constraint failed")) {
246
+ const existing = await env.DB.prepare("SELECT id FROM metric_points WHERE metric_id = ? AND idempotency_key = ?").bind(metric.id, idempotencyKey).first();
247
+ return json({ id: existing?.id, duplicate: true }, 200);
248
+ }
249
+ throw error;
250
+ }
251
+ await audit(env, auth, "metric_point.created", "metric_point", pointId, { key, value: body.value });
252
+ await env.METRIC_QUEUE?.send({ companyId: auth.token.company_id, metricId: metric.id, pointId });
253
+ return json({ id: pointId, duplicate: false }, 201);
254
+ }
255
+ async function evaluateMetricAlert(env, event) {
256
+ const rows = await env.DB.prepare(`SELECT id, value, timestamp
257
+ FROM metric_points
258
+ WHERE metric_id = ?
259
+ ORDER BY timestamp DESC, created_at DESC
260
+ LIMIT 3`).bind(event.metricId).all();
261
+ const points = rows.results;
262
+ if (points.length < 2)
263
+ return;
264
+ const [latest, previous] = points;
265
+ if (latest.id !== event.pointId)
266
+ return;
267
+ if (previous.value <= 0)
268
+ return;
269
+ const growth = (latest.value - previous.value) / previous.value;
270
+ if (growth < 0.5)
271
+ return;
272
+ const metric = await env.DB.prepare("SELECT display_name FROM metrics WHERE id = ?").bind(event.metricId).first();
273
+ await env.DB.prepare(`INSERT INTO alerts (id, company_id, metric_id, alert_type, severity, summary)
274
+ VALUES (?, ?, ?, 'growth_spike', 'high', ?)`).bind(id("alert"), event.companyId, event.metricId, `${metric?.display_name ?? "Metric"} increased ${(growth * 100).toFixed(0)}% from ${previous.value} to ${latest.value}.`).run();
275
+ }
276
+ async function requireAuth(request, env, requiredScope) {
277
+ const authHeader = request.headers.get("authorization");
278
+ const token = authHeader?.match(/^Bearer\s+(.+)$/i)?.[1];
279
+ if (!token)
280
+ throw new HttpError(401, "missing_token", "Missing bearer token.");
281
+ const parsed = parseToken(token);
282
+ const record = await env.DB.prepare("SELECT * FROM api_tokens WHERE public_id = ?").bind(parsed.publicId).first();
283
+ if (!record || record.revoked_at) {
284
+ throw new HttpError(401, "invalid_token", "Invalid token.");
285
+ }
286
+ if (record.expires_at && Date.parse(record.expires_at) <= Date.now()) {
287
+ throw new HttpError(401, "expired_token", "Expired token.");
288
+ }
289
+ const hash = await sha256(parsed.secret);
290
+ if (!timingSafeEqual(hash, record.token_hash)) {
291
+ throw new HttpError(401, "invalid_token", "Invalid token.");
292
+ }
293
+ const scopes = new Set(record.scopes.split(/\s+/).filter(Boolean));
294
+ if (!scopes.has(requiredScope)) {
295
+ throw new HttpError(403, "missing_scope", `Token requires ${requiredScope}.`);
296
+ }
297
+ return {
298
+ token: record,
299
+ scopes,
300
+ allowedMetrics: record.allowed_metrics
301
+ ? new Set(record.allowed_metrics.split(/\s+/).filter(Boolean))
302
+ : null
303
+ };
304
+ }
305
+ async function optionalSession(request, env) {
306
+ const authHeader = request.headers.get("authorization");
307
+ const token = authHeader?.match(/^Bearer\s+(.+)$/i)?.[1];
308
+ if (!token?.startsWith("alif_session_"))
309
+ return null;
310
+ return requireSession(token, env);
311
+ }
312
+ async function requireSession(raw, env) {
313
+ const parsed = parseSessionToken(raw);
314
+ const record = await env.DB.prepare("SELECT * FROM email_sessions WHERE public_id = ?").bind(parsed.publicId).first();
315
+ if (!record || record.revoked_at) {
316
+ throw new HttpError(401, "invalid_session", "Invalid session.");
317
+ }
318
+ if (Date.parse(record.expires_at) <= Date.now()) {
319
+ throw new HttpError(401, "expired_session", "Session expired. Run `alif login` again.");
320
+ }
321
+ const hash = await sha256(parsed.secret);
322
+ if (!timingSafeEqual(hash, record.token_hash)) {
323
+ throw new HttpError(401, "invalid_session", "Invalid session.");
324
+ }
325
+ return record;
326
+ }
327
+ async function issueToken(_env, input) {
328
+ const publicId = randomHex(9);
329
+ const secret = randomBase64Url(32);
330
+ const raw = `alif_live_${publicId}_${secret}`;
331
+ return {
332
+ publicId,
333
+ raw,
334
+ hash: await sha256(secret),
335
+ scopes: input.scopes.join(" ")
336
+ };
337
+ }
338
+ async function issueSession(email) {
339
+ const publicId = randomHex(9);
340
+ const secret = randomBase64Url(32);
341
+ const raw = `alif_session_${publicId}_${secret}`;
342
+ return {
343
+ publicId,
344
+ raw,
345
+ hash: await sha256(secret),
346
+ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
347
+ };
348
+ }
349
+ function parseToken(raw) {
350
+ const match = raw.match(/^alif_live_([a-f0-9]+)_(.+)$/);
351
+ if (!match)
352
+ throw new HttpError(401, "invalid_token", "Invalid token format.");
353
+ return { publicId: match[1], secret: match[2] };
354
+ }
355
+ function parseSessionToken(raw) {
356
+ const match = raw.match(/^alif_session_([a-f0-9]+)_(.+)$/);
357
+ if (!match)
358
+ throw new HttpError(401, "invalid_session", "Invalid session format.");
359
+ return { publicId: match[1], secret: match[2] };
360
+ }
361
+ async function audit(env, auth, action, resourceType, resourceId, metadata) {
362
+ await env.DB.prepare(`INSERT INTO audit_log
363
+ (id, company_id, actor, action, resource_type, resource_id, metadata_json)
364
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).bind(id("audit"), auth.token.company_id, auth.token.name, action, resourceType, resourceId, JSON.stringify(metadata)).run();
365
+ }
366
+ async function readJson(request) {
367
+ try {
368
+ return await request.json();
369
+ }
370
+ catch {
371
+ throw new HttpError(400, "invalid_json", "Request body must be valid JSON.");
372
+ }
373
+ }
374
+ function requireString(value, field) {
375
+ if (typeof value !== "string" || value.trim() === "") {
376
+ throw new HttpError(400, "missing_field", `${field} is required.`);
377
+ }
378
+ return value.trim();
379
+ }
380
+ function normalizeEmail(value) {
381
+ const email = value.trim().toLowerCase();
382
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
383
+ throw new HttpError(400, "invalid_email", "Email must be valid.");
384
+ }
385
+ return email;
386
+ }
387
+ function slug(value) {
388
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
389
+ }
390
+ function titleize(value) {
391
+ return slug(value).split("_").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
392
+ }
393
+ function id(prefix) {
394
+ return `${prefix}_${randomBase64Url(12)}`;
395
+ }
396
+ function randomBase64Url(bytes) {
397
+ const data = new Uint8Array(bytes);
398
+ crypto.getRandomValues(data);
399
+ return btoa(String.fromCharCode(...data)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
400
+ }
401
+ function randomHex(bytes) {
402
+ const data = new Uint8Array(bytes);
403
+ crypto.getRandomValues(data);
404
+ return [...data].map((byte) => byte.toString(16).padStart(2, "0")).join("");
405
+ }
406
+ function randomOtp() {
407
+ const data = new Uint8Array(4);
408
+ crypto.getRandomValues(data);
409
+ const value = new DataView(data.buffer).getUint32(0) % 1_000_000;
410
+ return value.toString().padStart(6, "0");
411
+ }
412
+ async function sha256(value) {
413
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
414
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
415
+ }
416
+ async function sendOtpEmail(env, email, code) {
417
+ if (!env.EMAIL || !env.OTP_FROM_EMAIL)
418
+ return false;
419
+ const fromName = env.OTP_FROM_NAME ?? "Alif";
420
+ const text = `Your Alif login code is ${code}. It expires in 10 minutes.`;
421
+ await env.EMAIL.send({
422
+ to: email,
423
+ from: { email: env.OTP_FROM_EMAIL, name: fromName },
424
+ subject: "Your Alif login code",
425
+ text,
426
+ html: `<p>Your Alif login code is <strong>${code}</strong>.</p><p>It expires in 10 minutes.</p>`
427
+ });
428
+ return true;
429
+ }
430
+ function timingSafeEqual(left, right) {
431
+ if (left.length !== right.length)
432
+ return false;
433
+ let mismatch = 0;
434
+ for (let i = 0; i < left.length; i += 1) {
435
+ mismatch |= left.charCodeAt(i) ^ right.charCodeAt(i);
436
+ }
437
+ return mismatch === 0;
438
+ }
439
+ function json(body, status = 200) {
440
+ return new Response(JSON.stringify(body, null, 2), {
441
+ status,
442
+ headers: { ...jsonHeaders, ...corsHeaders() }
443
+ });
444
+ }
445
+ function corsHeaders() {
446
+ return {
447
+ "access-control-allow-origin": "*",
448
+ "access-control-allow-methods": "GET,POST,OPTIONS",
449
+ "access-control-allow-headers": "authorization,content-type,idempotency-key,x-alif-signup-secret"
450
+ };
451
+ }
452
+ class HttpError extends Error {
453
+ status;
454
+ code;
455
+ constructor(status, code, message) {
456
+ super(message);
457
+ this.status = status;
458
+ this.code = code;
459
+ }
460
+ }
package/docs/auth.md ADDED
@@ -0,0 +1,50 @@
1
+ # Auth Model
2
+
3
+ Alif uses two different token types.
4
+
5
+ ## Human Session Token
6
+
7
+ Email OTP login returns:
8
+
9
+ ```text
10
+ alif_session_<public_id>_<secret>
11
+ ```
12
+
13
+ This token proves founder email ownership. Application creation can require this session when the Worker is configured with:
14
+
15
+ ```text
16
+ REQUIRE_EMAIL_OTP=true
17
+ ```
18
+
19
+ OTP challenges:
20
+
21
+ - are 6 digits
22
+ - expire after 10 minutes
23
+ - are stored hashed
24
+ - allow a limited number of verification attempts
25
+
26
+ ## Agent Automation Token
27
+
28
+ Application creation returns:
29
+
30
+ ```text
31
+ alif_live_<public_id>_<secret>
32
+ ```
33
+
34
+ This token is for agents, cron jobs, CI, and scripts.
35
+
36
+ Current scopes:
37
+
38
+ ```text
39
+ application:read
40
+ metrics:create
41
+ metrics:write
42
+ ```
43
+
44
+ Agents should receive this token through `ALIF_API_TOKEN`, not through committed config files.
45
+
46
+ ## Why Two Tokens?
47
+
48
+ The founder session is identity. The agent token is delegation.
49
+
50
+ That means a founder can eventually revoke or rotate automation credentials without changing their login identity.
@@ -0,0 +1,22 @@
1
+ # Security Notes
2
+
3
+ Implemented:
4
+
5
+ - write endpoints require authentication
6
+ - email OTP challenges are hashed before storage
7
+ - OTPs expire after 10 minutes
8
+ - human session tokens are separate from agent tokens
9
+ - API token secrets are hashed before storage
10
+ - metric writes are idempotent
11
+ - mutations are recorded in `audit_log`
12
+ - metric processing happens asynchronously through Cloudflare Queues
13
+
14
+ Recommended before broad public launch:
15
+
16
+ - enable Cloudflare Email Sending and set `REQUIRE_EMAIL_OTP=true`
17
+ - add Cloudflare rate limits for OTP, applications, and metric writes
18
+ - add token creation/list/revocation commands
19
+ - scope automation tokens to specific metric keys
20
+ - add Turnstile or invite-gating for public application creation
21
+ - put reviewer dashboard behind Cloudflare Access
22
+ - add CI tests for auth, idempotency, and alert generation
@@ -0,0 +1,62 @@
1
+ # Self-Hosting On Cloudflare
2
+
3
+ Alif is built for Cloudflare:
4
+
5
+ - Workers for the API
6
+ - D1 for relational storage
7
+ - Queues for async metric evaluation
8
+ - Cron Triggers for scheduled backend work
9
+ - Email Sending for production OTP delivery
10
+
11
+ ## Create Resources
12
+
13
+ ```bash
14
+ npx wrangler d1 create alif-db
15
+ npx wrangler queues create alif-metric-events
16
+ ```
17
+
18
+ Copy the D1 `database_id` into `wrangler.jsonc`.
19
+
20
+ ## Email OTP Delivery
21
+
22
+ Enable Cloudflare Email Sending for a domain:
23
+
24
+ ```bash
25
+ npx wrangler email sending enable yourdomain.com
26
+ npx wrangler email sending list
27
+ ```
28
+
29
+ Add the binding and production vars to `wrangler.jsonc`:
30
+
31
+ ```jsonc
32
+ {
33
+ "send_email": [{ "name": "EMAIL" }],
34
+ "vars": {
35
+ "ALIF_ENV": "production",
36
+ "REQUIRE_EMAIL_OTP": "true",
37
+ "OTP_FROM_EMAIL": "login@yourdomain.com",
38
+ "OTP_FROM_NAME": "Alif"
39
+ }
40
+ }
41
+ ```
42
+
43
+ Without an Email Sending binding, development OTP requests return the OTP in the API response. Do not run production with that fallback.
44
+
45
+ ## Migrate And Deploy
46
+
47
+ ```bash
48
+ npm run db:migrate:remote
49
+ npm run deploy
50
+ ```
51
+
52
+ Optional private beta gate:
53
+
54
+ ```bash
55
+ npx wrangler secret put SIGNUP_SECRET
56
+ ```
57
+
58
+ Then pass it during application creation:
59
+
60
+ ```bash
61
+ ALIF_SIGNUP_SECRET=... npx alif-fund apply --api-url https://your-worker.example.workers.dev
62
+ ```
@@ -0,0 +1,19 @@
1
+ # Claude Code Example
2
+
3
+ Set the token in your shell or project environment:
4
+
5
+ ```bash
6
+ export ALIF_API_TOKEN=alif_live_...
7
+ ```
8
+
9
+ Prompt:
10
+
11
+ ```text
12
+ Read our analytics/revenue source and submit weekly_revenue to Alif.
13
+ Use:
14
+
15
+ npx alif-fund metric update weekly_revenue <value> \
16
+ --timestamp <period_end_iso> \
17
+ --idempotency-key <company>-weekly-revenue-<iso-week> \
18
+ --source claude-code
19
+ ```
@@ -0,0 +1,21 @@
1
+ # Codex Example
2
+
3
+ Give Codex access to the repo or data source that contains your metric, then provide:
4
+
5
+ ```bash
6
+ export ALIF_API_TOKEN=alif_live_...
7
+ ```
8
+
9
+ Prompt:
10
+
11
+ ```text
12
+ Calculate last week's weekly_revenue from the source of truth. Then run:
13
+
14
+ ALIF_API_TOKEN=$ALIF_API_TOKEN \
15
+ npx alif-fund metric update weekly_revenue <value> \
16
+ --timestamp <period_end_iso> \
17
+ --idempotency-key <company>-weekly-revenue-<iso-week> \
18
+ --source codex
19
+
20
+ Use the same idempotency key if you retry the same reporting period.
21
+ ```
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ export ALIF_API_TOKEN="${ALIF_API_TOKEN:?Set ALIF_API_TOKEN first}"
5
+
6
+ VALUE="12000"
7
+ PERIOD="$(date +%G-W%V)"
8
+
9
+ npx alif-fund metric update weekly_revenue "$VALUE" \
10
+ --idempotency-key "acme-weekly-revenue-$PERIOD" \
11
+ --source cron
@@ -0,0 +1,24 @@
1
+ name: Update Alif Metric
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 16 * * 1"
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ update-alif:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: "22"
16
+ - name: Calculate and submit metric
17
+ env:
18
+ ALIF_API_TOKEN: ${{ secrets.ALIF_API_TOKEN }}
19
+ run: |
20
+ VALUE="12000"
21
+ PERIOD="2026-W23"
22
+ npx alif-fund metric update weekly_revenue "$VALUE" \
23
+ --idempotency-key "acme-weekly-revenue-$PERIOD" \
24
+ --source github-actions
@@ -0,0 +1,11 @@
1
+ # Hermes Example
2
+
3
+ Any agent that can run shell commands can update Alif.
4
+
5
+ ```bash
6
+ ALIF_API_TOKEN=alif_live_... \
7
+ npx alif-fund metric update weekly_active_users 1842 \
8
+ --timestamp 2026-06-07T16:00:00Z \
9
+ --idempotency-key acme-wau-2026-W23 \
10
+ --source hermes
11
+ ```