@pramen/server 0.0.11 → 0.0.13
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/durable-object.js +10 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/runtime/dispatch.d.ts +1 -1
- package/dist/runtime/dispatch.js +17 -2
- package/dist/runtime/mail.d.ts +80 -0
- package/dist/runtime/mail.js +102 -0
- package/dist/sdk/app.js +2 -2
- package/dist/sdk/handlers.d.ts +21 -0
- package/dist/sdk/handlers.js +14 -2
- package/dist/worker.js +3 -1
- package/package.json +1 -1
- package/src/durable-object.ts +10 -1
- package/src/index.ts +6 -2
- package/src/runtime/dispatch.ts +18 -3
- package/src/runtime/mail.ts +136 -0
- package/src/sdk/app.ts +2 -2
- package/src/sdk/handlers.ts +32 -2
- package/src/worker.ts +3 -1
package/dist/durable-object.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { DurableObject } from "cloudflare:workers";
|
|
18
18
|
import { migrate } from "./runtime/migrate";
|
|
19
19
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
20
|
+
import { createMail } from "./runtime/mail";
|
|
20
21
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
21
22
|
import { Db } from "./runtime/db";
|
|
22
23
|
import { digest } from "./runtime/digest";
|
|
@@ -154,7 +155,15 @@ export class PramenDOBase extends DurableObject {
|
|
|
154
155
|
taskCtx() {
|
|
155
156
|
const identity = { roles: ["admin"] };
|
|
156
157
|
const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true }, this.app.schema);
|
|
157
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
db,
|
|
160
|
+
kv: this.kv,
|
|
161
|
+
files: this.filesFor(this.tenant),
|
|
162
|
+
env: this.envBag,
|
|
163
|
+
identity,
|
|
164
|
+
tasks: tasksFacade(this.driver),
|
|
165
|
+
mail: createMail(this.envBag, this.kv),
|
|
166
|
+
};
|
|
158
167
|
}
|
|
159
168
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
160
169
|
* task context is scoped correctly. No-op once loaded/persisted this instance. */
|
package/dist/index.d.ts
CHANGED
|
@@ -3,14 +3,16 @@ export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
|
3
3
|
export { isValidUuid } from "./sdk/uuid";
|
|
4
4
|
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
|
|
5
5
|
export { createApp } from "./sdk/app";
|
|
6
|
-
export { query, mutation } from "./sdk/handlers";
|
|
7
|
-
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
6
|
+
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
7
|
+
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
8
8
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
9
9
|
export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
10
10
|
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
|
|
11
11
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
12
12
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
13
13
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
14
|
+
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
15
|
+
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
14
16
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
15
17
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
16
18
|
export type { Driver, Dialect, Row } from "./runtime/driver";
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,12 @@ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, de
|
|
|
11
11
|
export { isValidUuid } from "./sdk/uuid";
|
|
12
12
|
// --- app + handlers ---
|
|
13
13
|
export { createApp } from "./sdk/app";
|
|
14
|
-
export { query, mutation } from "./sdk/handlers";
|
|
14
|
+
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
15
15
|
// --- ACL ---
|
|
16
16
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
17
17
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
18
|
+
// --- mail (ctx.mail) ---
|
|
19
|
+
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
18
20
|
// --- errors ---
|
|
19
21
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
20
22
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
|
@@ -4,7 +4,7 @@ import type { Driver } from "./driver";
|
|
|
4
4
|
import type { Kv } from "./kv";
|
|
5
5
|
import type { Files } from "../sdk/files";
|
|
6
6
|
import type { SchemaDef } from "../sdk/schema";
|
|
7
|
-
import type
|
|
7
|
+
import { type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
|
|
8
8
|
export interface DispatchResult {
|
|
9
9
|
readonly result: unknown;
|
|
10
10
|
readonly kind: HandlerKind;
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
// layer can match a mutation's writes against each subscription's reads.
|
|
10
10
|
import { Db } from "./db";
|
|
11
11
|
import { warmup } from "./acl";
|
|
12
|
-
import { BadRequest } from "./errors";
|
|
12
|
+
import { BadRequest, Forbidden } from "./errors";
|
|
13
13
|
import { enqueueTask } from "./outbox";
|
|
14
|
+
import { createMail } from "./mail";
|
|
15
|
+
import { authorizeHandler } from "../sdk/handlers";
|
|
14
16
|
/** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
|
|
15
17
|
* so it can wake the drainer. */
|
|
16
18
|
export function tasksFacade(driver, onEnqueue) {
|
|
@@ -32,6 +34,11 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
32
34
|
const handler = handlers[name];
|
|
33
35
|
if (!handler)
|
|
34
36
|
throw new BadRequest(`unknown handler: ${name}`);
|
|
37
|
+
// Per-handler authorization, enforced before any work (input parse / handler body) —
|
|
38
|
+
// gates handlers that bypass the row-ACL by touching ctx.kv/ctx.env/ctx.mail directly.
|
|
39
|
+
if (handler.auth && !authorizeHandler(handler.auth, acl.identity)) {
|
|
40
|
+
throw new Forbidden(`not authorized to call '${name}'`);
|
|
41
|
+
}
|
|
35
42
|
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
36
43
|
let parsed = input;
|
|
37
44
|
if (handler.input) {
|
|
@@ -48,7 +55,15 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
48
55
|
const resolved = await warmup(acl.acl, acl.identity, systemDb);
|
|
49
56
|
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
|
|
50
57
|
let enqueued = 0;
|
|
51
|
-
const ctx = {
|
|
58
|
+
const ctx = {
|
|
59
|
+
db,
|
|
60
|
+
kv,
|
|
61
|
+
files,
|
|
62
|
+
env,
|
|
63
|
+
identity: acl.identity,
|
|
64
|
+
tasks: tasksFacade(driver, () => enqueued++),
|
|
65
|
+
mail: createMail(env, kv),
|
|
66
|
+
};
|
|
52
67
|
const result = handler.kind === "query"
|
|
53
68
|
? await handler.run(ctx, parsed)
|
|
54
69
|
: await driver.transaction(async () => handler.run(ctx, parsed));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Kv } from "./kv";
|
|
2
|
+
export interface MailAddress {
|
|
3
|
+
email: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MailMessage {
|
|
7
|
+
to: string | string[];
|
|
8
|
+
/** Sender. Optional — defaults to MAIL_FROM (a verified address). */
|
|
9
|
+
from?: MailAddress;
|
|
10
|
+
subject: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
html?: string;
|
|
13
|
+
replyTo?: string | MailAddress;
|
|
14
|
+
}
|
|
15
|
+
/** The transport seam. One per backend (Cloudflare Email Sending, a dev stash, …). */
|
|
16
|
+
export interface MailAdapter {
|
|
17
|
+
/** Deliver a fully-resolved message (`from` already filled by the facade). */
|
|
18
|
+
send(message: MailMessage & {
|
|
19
|
+
from: MailAddress;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
|
|
23
|
+
export declare class Mail {
|
|
24
|
+
private readonly adapter;
|
|
25
|
+
private readonly defaultFrom?;
|
|
26
|
+
constructor(adapter: MailAdapter, defaultFrom?: MailAddress | undefined);
|
|
27
|
+
send(message: MailMessage): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** The Cloudflare `send_email` binding shape (workers binding form: `from` uses `email`). */
|
|
30
|
+
export interface SendEmailBinding {
|
|
31
|
+
send(message: {
|
|
32
|
+
to: string | string[];
|
|
33
|
+
from: MailAddress;
|
|
34
|
+
subject: string;
|
|
35
|
+
text?: string;
|
|
36
|
+
html?: string;
|
|
37
|
+
replyTo?: string | MailAddress;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/** Cloudflare Email Sending — sends via the `send_email` binding (no API keys). The
|
|
41
|
+
* `from` domain must be onboarded (`wrangler email sending enable yourdomain.com`). */
|
|
42
|
+
export declare class CloudflareEmailAdapter implements MailAdapter {
|
|
43
|
+
private readonly binding;
|
|
44
|
+
constructor(binding: SendEmailBinding);
|
|
45
|
+
send(message: MailMessage & {
|
|
46
|
+
from: MailAddress;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
50
|
+
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
51
|
+
export declare class KvMailAdapter implements MailAdapter {
|
|
52
|
+
private readonly kv;
|
|
53
|
+
constructor(kv: Kv);
|
|
54
|
+
send(message: MailMessage & {
|
|
55
|
+
from: MailAddress;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
/** In-memory transport: captures sent messages (pure; for unit tests). */
|
|
59
|
+
export declare class MemoryMailAdapter implements MailAdapter {
|
|
60
|
+
readonly sent: Array<MailMessage & {
|
|
61
|
+
from: MailAddress;
|
|
62
|
+
}>;
|
|
63
|
+
send(message: MailMessage & {
|
|
64
|
+
from: MailAddress;
|
|
65
|
+
}): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
/** Fail-closed transport: no real sender and no explicit dev-capture opt-in, so a
|
|
68
|
+
* `send` THROWS rather than silently capturing. Prevents a misconfigured production
|
|
69
|
+
* (no MAIL_FROM) from writing security emails — magic-link tokens, resets — into KV
|
|
70
|
+
* instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
|
|
71
|
+
export declare class UnconfiguredMailAdapter implements MailAdapter {
|
|
72
|
+
send(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
/** Build `ctx.mail` from the environment:
|
|
75
|
+
* - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
76
|
+
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
77
|
+
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
78
|
+
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
79
|
+
* stash security emails in KV). */
|
|
80
|
+
export declare function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ctx.mail — transactional-ish email facade, the same shape as ctx.files: an adapter
|
|
2
|
+
// seam (CloudflareEmailAdapter / KvMailAdapter / MemoryMailAdapter) behind a thin
|
|
3
|
+
// `Mail` facade, chosen from the environment. Handlers send mail without touching the
|
|
4
|
+
// `send_email` binding directly:
|
|
5
|
+
//
|
|
6
|
+
// await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
|
|
7
|
+
//
|
|
8
|
+
// On Cloudflare the transport is Cloudflare Email Sending (the `send_email`/`EMAIL`
|
|
9
|
+
// binding, no API keys). With no verified sender configured (local/dev), mail is
|
|
10
|
+
// captured instead of sent — to KV (so an e2e/dashboard can read the "inbox") or
|
|
11
|
+
// in-memory — so handlers work unchanged off-platform.
|
|
12
|
+
/** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
|
|
13
|
+
export class Mail {
|
|
14
|
+
adapter;
|
|
15
|
+
defaultFrom;
|
|
16
|
+
constructor(adapter, defaultFrom) {
|
|
17
|
+
this.adapter = adapter;
|
|
18
|
+
this.defaultFrom = defaultFrom;
|
|
19
|
+
}
|
|
20
|
+
async send(message) {
|
|
21
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
22
|
+
if (to.length === 0 || to.some((a) => typeof a !== "string" || a.length === 0)) {
|
|
23
|
+
throw new Error("ctx.mail.send: `to` is required");
|
|
24
|
+
}
|
|
25
|
+
if (typeof message.subject !== "string" || message.subject.length === 0) {
|
|
26
|
+
throw new Error("ctx.mail.send: `subject` is required");
|
|
27
|
+
}
|
|
28
|
+
const from = message.from ?? this.defaultFrom;
|
|
29
|
+
if (!from)
|
|
30
|
+
throw new Error("ctx.mail.send: no sender — set the MAIL_FROM var or pass `from`");
|
|
31
|
+
await this.adapter.send({ ...message, from });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Cloudflare Email Sending — sends via the `send_email` binding (no API keys). The
|
|
35
|
+
* `from` domain must be onboarded (`wrangler email sending enable yourdomain.com`). */
|
|
36
|
+
export class CloudflareEmailAdapter {
|
|
37
|
+
binding;
|
|
38
|
+
constructor(binding) {
|
|
39
|
+
this.binding = binding;
|
|
40
|
+
}
|
|
41
|
+
async send(message) {
|
|
42
|
+
await this.binding.send({
|
|
43
|
+
to: message.to,
|
|
44
|
+
from: message.from,
|
|
45
|
+
subject: message.subject,
|
|
46
|
+
text: message.text,
|
|
47
|
+
html: message.html,
|
|
48
|
+
replyTo: message.replyTo,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
53
|
+
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
54
|
+
export class KvMailAdapter {
|
|
55
|
+
kv;
|
|
56
|
+
constructor(kv) {
|
|
57
|
+
this.kv = kv;
|
|
58
|
+
}
|
|
59
|
+
async send(message) {
|
|
60
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
61
|
+
const value = JSON.stringify({ from: message.from, subject: message.subject, text: message.text, html: message.html });
|
|
62
|
+
for (const addr of to)
|
|
63
|
+
await this.kv.put(`mail:${addr}`, value, { expirationTtl: 900 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** In-memory transport: captures sent messages (pure; for unit tests). */
|
|
67
|
+
export class MemoryMailAdapter {
|
|
68
|
+
sent = [];
|
|
69
|
+
async send(message) {
|
|
70
|
+
this.sent.push(message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Fail-closed transport: no real sender and no explicit dev-capture opt-in, so a
|
|
74
|
+
* `send` THROWS rather than silently capturing. Prevents a misconfigured production
|
|
75
|
+
* (no MAIL_FROM) from writing security emails — magic-link tokens, resets — into KV
|
|
76
|
+
* instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
|
|
77
|
+
export class UnconfiguredMailAdapter {
|
|
78
|
+
async send() {
|
|
79
|
+
throw new Error("ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
|
|
80
|
+
"or MAIL_CAPTURE=true to capture in dev.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Build `ctx.mail` from the environment:
|
|
84
|
+
* - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
85
|
+
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
86
|
+
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
87
|
+
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
88
|
+
* stash security emails in KV). */
|
|
89
|
+
export function createMail(env, kv) {
|
|
90
|
+
const binding = env.EMAIL;
|
|
91
|
+
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
92
|
+
if (binding && fromAddr) {
|
|
93
|
+
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
94
|
+
return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
|
|
95
|
+
}
|
|
96
|
+
if (env.MAIL_CAPTURE === "true") {
|
|
97
|
+
const devFrom = { email: "dev@pramen.local", name: "pramen (dev)" };
|
|
98
|
+
return new Mail(kv ? new KvMailAdapter(kv) : new MemoryMailAdapter(), devFrom);
|
|
99
|
+
}
|
|
100
|
+
// Sentinel `from` so the facade delegates to the adapter, which throws the clear error.
|
|
101
|
+
return new Mail(new UnconfiguredMailAdapter(), { email: "unconfigured@invalid" });
|
|
102
|
+
}
|
package/dist/sdk/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// const { query, mutation } = createApp(schema);
|
|
6
6
|
// const listNotes = query((ctx) => ctx.db.find({ from: "notes" })); // typed!
|
|
7
7
|
export function createApp(schema) {
|
|
8
|
-
const query = (run, opts) => ({ kind: "query", run: run, input: opts?.input, partition: opts?.partition });
|
|
9
|
-
const mutation = (run, opts) => ({ kind: "mutation", run: run, input: opts?.input, partition: opts?.partition });
|
|
8
|
+
const query = (run, opts) => ({ kind: "query", run: run, input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
9
|
+
const mutation = (run, opts) => ({ kind: "mutation", run: run, input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
10
10
|
return { schema, query, mutation };
|
|
11
11
|
}
|
package/dist/sdk/handlers.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Db } from "../runtime/db";
|
|
2
2
|
import type { Kv } from "../runtime/kv";
|
|
3
|
+
import type { Mail } from "../runtime/mail";
|
|
3
4
|
import type { Identity } from "./acl";
|
|
4
5
|
import type { Files } from "./files";
|
|
5
6
|
import type { SchemaDef } from "./schema";
|
|
@@ -12,6 +13,11 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
12
13
|
/** Per-tenant file storage: mint signed upload/download urls, head/delete blobs.
|
|
13
14
|
* Bytes flow through the Worker /files/* route, never through the DO. */
|
|
14
15
|
readonly files: Files;
|
|
16
|
+
/** Send email: `ctx.mail.send({ to, subject, text/html })`. On Cloudflare this is
|
|
17
|
+
* Cloudflare Email Sending (the `send_email` binding); off-platform / unconfigured it
|
|
18
|
+
* captures instead of sending. Prefer enqueuing the send as a task (see `ctx.tasks`)
|
|
19
|
+
* so it runs off the single-writer write path. */
|
|
20
|
+
readonly mail: Mail;
|
|
15
21
|
/** The Worker/DO environment — bindings (KV, R2, DB, …) plus vars and secrets
|
|
16
22
|
* (AUTH_SECRET, plus anything in wrangler.jsonc / .dev.vars / `wrangler secret`).
|
|
17
23
|
* Use it to call external services from handlers — Cloudflare bindings (e.g. the
|
|
@@ -50,6 +56,17 @@ export type TaskHandler = (ctx: HandlerContext, payload: unknown, meta: TaskMeta
|
|
|
50
56
|
/** Map of `kind` → handler. Set as `app.tasks`; drained by the DO alarm / a Cron. */
|
|
51
57
|
export type AppTaskMap = Record<string, TaskHandler>;
|
|
52
58
|
export type HandlerKind = "query" | "mutation";
|
|
59
|
+
/** Authorization required to CALL a handler, enforced BEFORE its body runs. This is
|
|
60
|
+
* distinct from the row-level ACL (which gates `ctx.db`): use it to gate handlers that
|
|
61
|
+
* touch `ctx.kv`/`ctx.env`/`ctx.mail`/`ctx.tasks` directly — those bypass the ACL, so an
|
|
62
|
+
* un-gated such handler is callable by anyone (incl. anonymous) on an open tenant. Forms:
|
|
63
|
+
* - `"authenticated"` — any non-anonymous caller (identity != null)
|
|
64
|
+
* - `string[]` — the caller must hold one of these roles
|
|
65
|
+
* - `(identity) => boolean` — a custom predicate
|
|
66
|
+
* Absent ⇒ open (the prior behavior; a `ctx.db` handler is still ACL-gated). */
|
|
67
|
+
export type HandlerAuth = "authenticated" | readonly string[] | ((identity: Identity | null) => boolean);
|
|
68
|
+
/** Evaluate a handler's `auth` requirement against the caller's identity. */
|
|
69
|
+
export declare function authorizeHandler(auth: HandlerAuth, identity: Identity | null): boolean;
|
|
53
70
|
export interface Handler<I = unknown, O = unknown> {
|
|
54
71
|
readonly kind: HandlerKind;
|
|
55
72
|
readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
|
|
@@ -60,11 +77,15 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
60
77
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
61
78
|
* default partition (routed to the bare tenant key). */
|
|
62
79
|
readonly partition?: string;
|
|
80
|
+
/** Optional call-authorization, enforced before the handler runs (see HandlerAuth). */
|
|
81
|
+
readonly auth?: HandlerAuth;
|
|
63
82
|
}
|
|
64
83
|
export interface HandlerOpts<I> {
|
|
65
84
|
input?: (raw: unknown) => I;
|
|
66
85
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
67
86
|
partition?: string;
|
|
87
|
+
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
|
88
|
+
auth?: HandlerAuth;
|
|
68
89
|
}
|
|
69
90
|
export declare function query<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
|
|
70
91
|
export declare function mutation<I = unknown, O = unknown>(run: (ctx: HandlerContext, input: I) => O | Promise<O>, opts?: HandlerOpts<I>): Handler<I, O>;
|
package/dist/sdk/handlers.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
// Handler factories — `query()` and `mutation()`. A query reads; a mutation is
|
|
2
2
|
// wrapped in BEGIN/COMMIT by the dispatcher and
|
|
3
3
|
// rolls back on throw (see runtime/dispatch.ts).
|
|
4
|
+
/** Evaluate a handler's `auth` requirement against the caller's identity. */
|
|
5
|
+
export function authorizeHandler(auth, identity) {
|
|
6
|
+
if (auth === "authenticated")
|
|
7
|
+
return identity != null;
|
|
8
|
+
if (typeof auth === "function")
|
|
9
|
+
return auth(identity);
|
|
10
|
+
if (!identity)
|
|
11
|
+
return false; // a role list can never be satisfied by an anonymous caller
|
|
12
|
+
const roles = Array.isArray(identity.roles) ? identity.roles : [];
|
|
13
|
+
const held = identity.role ? [identity.role, ...roles] : roles;
|
|
14
|
+
return auth.some((r) => held.includes(r));
|
|
15
|
+
}
|
|
4
16
|
// Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
|
|
5
17
|
// typed ctx.db; these remain for untyped/ad-hoc use.
|
|
6
18
|
export function query(run, opts) {
|
|
7
|
-
return { kind: "query", run, input: opts?.input, partition: opts?.partition };
|
|
19
|
+
return { kind: "query", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
8
20
|
}
|
|
9
21
|
export function mutation(run, opts) {
|
|
10
|
-
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition };
|
|
22
|
+
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
11
23
|
}
|
package/dist/worker.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity } from "./auth";
|
|
7
7
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
8
8
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
9
|
+
import { createMail } from "./runtime/mail";
|
|
9
10
|
import { migrate } from "./runtime/migrate";
|
|
10
11
|
import { compileAcl } from "./runtime/acl";
|
|
11
12
|
import { Db } from "./runtime/db";
|
|
@@ -110,7 +111,8 @@ export function makeWorker(app) {
|
|
|
110
111
|
const identity = { roles: ["admin"] };
|
|
111
112
|
const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
112
113
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
113
|
-
|
|
114
|
+
const kv = new Kv(env.KV);
|
|
115
|
+
return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv) };
|
|
114
116
|
};
|
|
115
117
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
116
118
|
* /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/durable-object.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { DurableObject } from "cloudflare:workers";
|
|
19
19
|
import { migrate } from "./runtime/migrate";
|
|
20
20
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
21
|
+
import { createMail } from "./runtime/mail";
|
|
21
22
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
22
23
|
import { Db } from "./runtime/db";
|
|
23
24
|
import { digest } from "./runtime/digest";
|
|
@@ -205,7 +206,15 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
205
206
|
{ acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
|
|
206
207
|
this.app.schema,
|
|
207
208
|
);
|
|
208
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
db,
|
|
211
|
+
kv: this.kv,
|
|
212
|
+
files: this.filesFor(this.tenant),
|
|
213
|
+
env: this.envBag,
|
|
214
|
+
identity,
|
|
215
|
+
tasks: tasksFacade(this.driver),
|
|
216
|
+
mail: createMail(this.envBag, this.kv),
|
|
217
|
+
};
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
package/src/index.ts
CHANGED
|
@@ -26,8 +26,8 @@ export type {
|
|
|
26
26
|
|
|
27
27
|
// --- app + handlers ---
|
|
28
28
|
export { createApp } from "./sdk/app";
|
|
29
|
-
export { query, mutation } from "./sdk/handlers";
|
|
30
|
-
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
29
|
+
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
30
|
+
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
31
31
|
|
|
32
32
|
// --- ACL ---
|
|
33
33
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
@@ -72,6 +72,10 @@ export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } fro
|
|
|
72
72
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
73
73
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
74
74
|
|
|
75
|
+
// --- mail (ctx.mail) ---
|
|
76
|
+
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
77
|
+
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
78
|
+
|
|
75
79
|
// --- errors ---
|
|
76
80
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
77
81
|
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { Db } from "./db";
|
|
12
12
|
import { warmup, type AclContext } from "./acl";
|
|
13
|
-
import { BadRequest } from "./errors";
|
|
13
|
+
import { BadRequest, Forbidden } from "./errors";
|
|
14
14
|
import { enqueueTask, type TaskMap } from "./outbox";
|
|
15
|
+
import { createMail } from "./mail";
|
|
15
16
|
import type { Driver } from "./driver";
|
|
16
17
|
import type { Kv } from "./kv";
|
|
17
18
|
import type { Files } from "../sdk/files";
|
|
18
19
|
import type { ResolverDb } from "../sdk/acl";
|
|
19
20
|
import type { SchemaDef } from "../sdk/schema";
|
|
20
|
-
import type
|
|
21
|
+
import { authorizeHandler, type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
|
|
21
22
|
|
|
22
23
|
export interface DispatchResult {
|
|
23
24
|
readonly result: unknown;
|
|
@@ -59,6 +60,12 @@ export async function dispatch(
|
|
|
59
60
|
const handler = handlers[name];
|
|
60
61
|
if (!handler) throw new BadRequest(`unknown handler: ${name}`);
|
|
61
62
|
|
|
63
|
+
// Per-handler authorization, enforced before any work (input parse / handler body) —
|
|
64
|
+
// gates handlers that bypass the row-ACL by touching ctx.kv/ctx.env/ctx.mail directly.
|
|
65
|
+
if (handler.auth && !authorizeHandler(handler.auth, acl.identity)) {
|
|
66
|
+
throw new Forbidden(`not authorized to call '${name}'`);
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
63
70
|
let parsed = input;
|
|
64
71
|
if (handler.input) {
|
|
@@ -76,7 +83,15 @@ export async function dispatch(
|
|
|
76
83
|
|
|
77
84
|
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
|
|
78
85
|
let enqueued = 0;
|
|
79
|
-
const ctx: HandlerContext = {
|
|
86
|
+
const ctx: HandlerContext = {
|
|
87
|
+
db,
|
|
88
|
+
kv,
|
|
89
|
+
files,
|
|
90
|
+
env,
|
|
91
|
+
identity: acl.identity,
|
|
92
|
+
tasks: tasksFacade(driver, () => enqueued++),
|
|
93
|
+
mail: createMail(env, kv),
|
|
94
|
+
};
|
|
80
95
|
|
|
81
96
|
const result =
|
|
82
97
|
handler.kind === "query"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ctx.mail — transactional-ish email facade, the same shape as ctx.files: an adapter
|
|
2
|
+
// seam (CloudflareEmailAdapter / KvMailAdapter / MemoryMailAdapter) behind a thin
|
|
3
|
+
// `Mail` facade, chosen from the environment. Handlers send mail without touching the
|
|
4
|
+
// `send_email` binding directly:
|
|
5
|
+
//
|
|
6
|
+
// await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
|
|
7
|
+
//
|
|
8
|
+
// On Cloudflare the transport is Cloudflare Email Sending (the `send_email`/`EMAIL`
|
|
9
|
+
// binding, no API keys). With no verified sender configured (local/dev), mail is
|
|
10
|
+
// captured instead of sent — to KV (so an e2e/dashboard can read the "inbox") or
|
|
11
|
+
// in-memory — so handlers work unchanged off-platform.
|
|
12
|
+
|
|
13
|
+
import type { Kv } from "./kv";
|
|
14
|
+
|
|
15
|
+
export interface MailAddress {
|
|
16
|
+
email: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MailMessage {
|
|
21
|
+
to: string | string[];
|
|
22
|
+
/** Sender. Optional — defaults to MAIL_FROM (a verified address). */
|
|
23
|
+
from?: MailAddress;
|
|
24
|
+
subject: string;
|
|
25
|
+
text?: string;
|
|
26
|
+
html?: string;
|
|
27
|
+
replyTo?: string | MailAddress;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The transport seam. One per backend (Cloudflare Email Sending, a dev stash, …). */
|
|
31
|
+
export interface MailAdapter {
|
|
32
|
+
/** Deliver a fully-resolved message (`from` already filled by the facade). */
|
|
33
|
+
send(message: MailMessage & { from: MailAddress }): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
|
|
37
|
+
export class Mail {
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly adapter: MailAdapter,
|
|
40
|
+
private readonly defaultFrom?: MailAddress,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
async send(message: MailMessage): Promise<void> {
|
|
44
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
45
|
+
if (to.length === 0 || to.some((a) => typeof a !== "string" || a.length === 0)) {
|
|
46
|
+
throw new Error("ctx.mail.send: `to` is required");
|
|
47
|
+
}
|
|
48
|
+
if (typeof message.subject !== "string" || message.subject.length === 0) {
|
|
49
|
+
throw new Error("ctx.mail.send: `subject` is required");
|
|
50
|
+
}
|
|
51
|
+
const from = message.from ?? this.defaultFrom;
|
|
52
|
+
if (!from) throw new Error("ctx.mail.send: no sender — set the MAIL_FROM var or pass `from`");
|
|
53
|
+
await this.adapter.send({ ...message, from });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The Cloudflare `send_email` binding shape (workers binding form: `from` uses `email`). */
|
|
58
|
+
export interface SendEmailBinding {
|
|
59
|
+
send(message: {
|
|
60
|
+
to: string | string[];
|
|
61
|
+
from: MailAddress;
|
|
62
|
+
subject: string;
|
|
63
|
+
text?: string;
|
|
64
|
+
html?: string;
|
|
65
|
+
replyTo?: string | MailAddress;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Cloudflare Email Sending — sends via the `send_email` binding (no API keys). The
|
|
70
|
+
* `from` domain must be onboarded (`wrangler email sending enable yourdomain.com`). */
|
|
71
|
+
export class CloudflareEmailAdapter implements MailAdapter {
|
|
72
|
+
constructor(private readonly binding: SendEmailBinding) {}
|
|
73
|
+
async send(message: MailMessage & { from: MailAddress }): Promise<void> {
|
|
74
|
+
await this.binding.send({
|
|
75
|
+
to: message.to,
|
|
76
|
+
from: message.from,
|
|
77
|
+
subject: message.subject,
|
|
78
|
+
text: message.text,
|
|
79
|
+
html: message.html,
|
|
80
|
+
replyTo: message.replyTo,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
86
|
+
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
87
|
+
export class KvMailAdapter implements MailAdapter {
|
|
88
|
+
constructor(private readonly kv: Kv) {}
|
|
89
|
+
async send(message: MailMessage & { from: MailAddress }): Promise<void> {
|
|
90
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
91
|
+
const value = JSON.stringify({ from: message.from, subject: message.subject, text: message.text, html: message.html });
|
|
92
|
+
for (const addr of to) await this.kv.put(`mail:${addr}`, value, { expirationTtl: 900 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** In-memory transport: captures sent messages (pure; for unit tests). */
|
|
97
|
+
export class MemoryMailAdapter implements MailAdapter {
|
|
98
|
+
readonly sent: Array<MailMessage & { from: MailAddress }> = [];
|
|
99
|
+
async send(message: MailMessage & { from: MailAddress }): Promise<void> {
|
|
100
|
+
this.sent.push(message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Fail-closed transport: no real sender and no explicit dev-capture opt-in, so a
|
|
105
|
+
* `send` THROWS rather than silently capturing. Prevents a misconfigured production
|
|
106
|
+
* (no MAIL_FROM) from writing security emails — magic-link tokens, resets — into KV
|
|
107
|
+
* instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
|
|
108
|
+
export class UnconfiguredMailAdapter implements MailAdapter {
|
|
109
|
+
async send(): Promise<void> {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
|
|
112
|
+
"or MAIL_CAPTURE=true to capture in dev.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build `ctx.mail` from the environment:
|
|
118
|
+
* - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
119
|
+
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
120
|
+
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
121
|
+
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
122
|
+
* stash security emails in KV). */
|
|
123
|
+
export function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail {
|
|
124
|
+
const binding = env.EMAIL as SendEmailBinding | undefined;
|
|
125
|
+
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
126
|
+
if (binding && fromAddr) {
|
|
127
|
+
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
128
|
+
return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
|
|
129
|
+
}
|
|
130
|
+
if (env.MAIL_CAPTURE === "true") {
|
|
131
|
+
const devFrom: MailAddress = { email: "dev@pramen.local", name: "pramen (dev)" };
|
|
132
|
+
return new Mail(kv ? new KvMailAdapter(kv) : new MemoryMailAdapter(), devFrom);
|
|
133
|
+
}
|
|
134
|
+
// Sentinel `from` so the facade delegates to the adapter, which throws the clear error.
|
|
135
|
+
return new Mail(new UnconfiguredMailAdapter(), { email: "unconfigured@invalid" });
|
|
136
|
+
}
|
package/src/sdk/app.ts
CHANGED
|
@@ -14,12 +14,12 @@ export function createApp<S extends SchemaDef>(schema: S) {
|
|
|
14
14
|
const query = <I = unknown, O = unknown>(
|
|
15
15
|
run: (ctx: Ctx, input: I) => O | Promise<O>,
|
|
16
16
|
opts?: HandlerOpts<I>,
|
|
17
|
-
): Handler<I, O> => ({ kind: "query", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition });
|
|
17
|
+
): Handler<I, O> => ({ kind: "query", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
18
18
|
|
|
19
19
|
const mutation = <I = unknown, O = unknown>(
|
|
20
20
|
run: (ctx: Ctx, input: I) => O | Promise<O>,
|
|
21
21
|
opts?: HandlerOpts<I>,
|
|
22
|
-
): Handler<I, O> => ({ kind: "mutation", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition });
|
|
22
|
+
): Handler<I, O> => ({ kind: "mutation", run: run as Handler<I, O>["run"], input: opts?.input, partition: opts?.partition, auth: opts?.auth });
|
|
23
23
|
|
|
24
24
|
return { schema, query, mutation };
|
|
25
25
|
}
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Db } from "../runtime/db";
|
|
6
6
|
import type { Kv } from "../runtime/kv";
|
|
7
|
+
import type { Mail } from "../runtime/mail";
|
|
7
8
|
import type { Identity } from "./acl";
|
|
8
9
|
import type { Files } from "./files";
|
|
9
10
|
import type { SchemaDef } from "./schema";
|
|
@@ -17,6 +18,11 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
17
18
|
/** Per-tenant file storage: mint signed upload/download urls, head/delete blobs.
|
|
18
19
|
* Bytes flow through the Worker /files/* route, never through the DO. */
|
|
19
20
|
readonly files: Files;
|
|
21
|
+
/** Send email: `ctx.mail.send({ to, subject, text/html })`. On Cloudflare this is
|
|
22
|
+
* Cloudflare Email Sending (the `send_email` binding); off-platform / unconfigured it
|
|
23
|
+
* captures instead of sending. Prefer enqueuing the send as a task (see `ctx.tasks`)
|
|
24
|
+
* so it runs off the single-writer write path. */
|
|
25
|
+
readonly mail: Mail;
|
|
20
26
|
/** The Worker/DO environment — bindings (KV, R2, DB, …) plus vars and secrets
|
|
21
27
|
* (AUTH_SECRET, plus anything in wrangler.jsonc / .dev.vars / `wrangler secret`).
|
|
22
28
|
* Use it to call external services from handlers — Cloudflare bindings (e.g. the
|
|
@@ -56,6 +62,26 @@ export type AppTaskMap = Record<string, TaskHandler>;
|
|
|
56
62
|
|
|
57
63
|
export type HandlerKind = "query" | "mutation";
|
|
58
64
|
|
|
65
|
+
/** Authorization required to CALL a handler, enforced BEFORE its body runs. This is
|
|
66
|
+
* distinct from the row-level ACL (which gates `ctx.db`): use it to gate handlers that
|
|
67
|
+
* touch `ctx.kv`/`ctx.env`/`ctx.mail`/`ctx.tasks` directly — those bypass the ACL, so an
|
|
68
|
+
* un-gated such handler is callable by anyone (incl. anonymous) on an open tenant. Forms:
|
|
69
|
+
* - `"authenticated"` — any non-anonymous caller (identity != null)
|
|
70
|
+
* - `string[]` — the caller must hold one of these roles
|
|
71
|
+
* - `(identity) => boolean` — a custom predicate
|
|
72
|
+
* Absent ⇒ open (the prior behavior; a `ctx.db` handler is still ACL-gated). */
|
|
73
|
+
export type HandlerAuth = "authenticated" | readonly string[] | ((identity: Identity | null) => boolean);
|
|
74
|
+
|
|
75
|
+
/** Evaluate a handler's `auth` requirement against the caller's identity. */
|
|
76
|
+
export function authorizeHandler(auth: HandlerAuth, identity: Identity | null): boolean {
|
|
77
|
+
if (auth === "authenticated") return identity != null;
|
|
78
|
+
if (typeof auth === "function") return auth(identity);
|
|
79
|
+
if (!identity) return false; // a role list can never be satisfied by an anonymous caller
|
|
80
|
+
const roles = Array.isArray(identity.roles) ? identity.roles : [];
|
|
81
|
+
const held = identity.role ? [identity.role, ...roles] : roles;
|
|
82
|
+
return auth.some((r) => held.includes(r));
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
export interface Handler<I = unknown, O = unknown> {
|
|
60
86
|
readonly kind: HandlerKind;
|
|
61
87
|
// Stored handlers are schema-agnostic; createApp() binds the typed surface.
|
|
@@ -68,12 +94,16 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
68
94
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
69
95
|
* default partition (routed to the bare tenant key). */
|
|
70
96
|
readonly partition?: string;
|
|
97
|
+
/** Optional call-authorization, enforced before the handler runs (see HandlerAuth). */
|
|
98
|
+
readonly auth?: HandlerAuth;
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
export interface HandlerOpts<I> {
|
|
74
102
|
input?: (raw: unknown) => I;
|
|
75
103
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
76
104
|
partition?: string;
|
|
105
|
+
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
|
106
|
+
auth?: HandlerAuth;
|
|
77
107
|
}
|
|
78
108
|
|
|
79
109
|
// Standalone (schema-agnostic) handler factories. Prefer createApp(schema) for a
|
|
@@ -82,14 +112,14 @@ export function query<I = unknown, O = unknown>(
|
|
|
82
112
|
run: (ctx: HandlerContext, input: I) => O | Promise<O>,
|
|
83
113
|
opts?: HandlerOpts<I>,
|
|
84
114
|
): Handler<I, O> {
|
|
85
|
-
return { kind: "query", run, input: opts?.input, partition: opts?.partition };
|
|
115
|
+
return { kind: "query", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
export function mutation<I = unknown, O = unknown>(
|
|
89
119
|
run: (ctx: HandlerContext, input: I) => O | Promise<O>,
|
|
90
120
|
opts?: HandlerOpts<I>,
|
|
91
121
|
): Handler<I, O> {
|
|
92
|
-
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition };
|
|
122
|
+
return { kind: "mutation", run, input: opts?.input, partition: opts?.partition, auth: opts?.auth };
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
// Registry of handlers keyed by RPC name. Uses `any` for the per-handler input/
|
package/src/worker.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyStrategy } from "./auth";
|
|
8
8
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
9
9
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
10
|
+
import { createMail } from "./runtime/mail";
|
|
10
11
|
import { migrate } from "./runtime/migrate";
|
|
11
12
|
import { compileAcl } from "./runtime/acl";
|
|
12
13
|
import { Db } from "./runtime/db";
|
|
@@ -149,7 +150,8 @@ export function makeWorker(app: PramenApp) {
|
|
|
149
150
|
const identity: Identity = { roles: ["admin"] };
|
|
150
151
|
const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
151
152
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
152
|
-
|
|
153
|
+
const kv = new Kv(env.KV);
|
|
154
|
+
return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv) };
|
|
153
155
|
};
|
|
154
156
|
|
|
155
157
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|