alepha 0.15.3 → 0.15.4
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/api/audits/index.d.ts +332 -332
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +8 -0
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.js +1 -0
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.d.ts +151 -151
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +3 -0
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +195 -195
- package/dist/api/keys/index.d.ts.map +1 -1
- package/dist/api/notifications/index.browser.js +1 -0
- package/dist/api/notifications/index.browser.js.map +1 -1
- package/dist/api/notifications/index.js +1 -0
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +260 -260
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +10 -0
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/users/index.d.ts +10 -10
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +11 -0
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +128 -128
- package/dist/api/verifications/index.d.ts.map +1 -1
- package/dist/batch/index.d.ts +4 -4
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +19 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/email/index.d.ts +13 -13
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +10554 -2
- package/dist/email/index.js.map +1 -1
- package/dist/lock/core/index.d.ts +6 -1
- package/dist/lock/core/index.d.ts.map +1 -1
- package/dist/lock/core/index.js +9 -1
- package/dist/lock/core/index.js.map +1 -1
- package/dist/react/auth/index.browser.js +2 -1
- package/dist/react/auth/index.browser.js.map +1 -1
- package/dist/react/auth/index.js +2 -1
- package/dist/react/auth/index.js.map +1 -1
- package/dist/react/core/index.d.ts +3 -3
- package/dist/react/router/index.d.ts +10 -0
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +16 -6
- package/dist/react/router/index.js.map +1 -1
- package/dist/redis/index.d.ts +19 -19
- package/dist/scheduler/index.d.ts +13 -1
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +42 -4
- package/dist/scheduler/index.js.map +1 -1
- package/dist/server/compress/index.d.ts.map +1 -1
- package/dist/server/compress/index.js +1 -0
- package/dist/server/compress/index.js.map +1 -1
- package/dist/server/core/index.d.ts +9 -9
- package/dist/server/links/index.js +1 -1
- package/dist/server/links/index.js.map +1 -1
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +28 -2
- package/dist/vite/index.js.map +1 -1
- package/dist/websocket/index.d.ts +34 -34
- package/dist/websocket/index.d.ts.map +1 -1
- package/package.json +6 -3
- package/src/api/audits/controllers/AdminAuditController.ts +8 -0
- package/src/api/files/controllers/AdminFileStatsController.ts +1 -0
- package/src/api/jobs/controllers/AdminJobController.ts +3 -0
- package/src/api/notifications/controllers/AdminNotificationController.ts +1 -0
- package/src/api/parameters/controllers/AdminConfigController.ts +10 -0
- package/src/api/users/controllers/AdminIdentityController.ts +3 -0
- package/src/api/users/controllers/AdminSessionController.ts +3 -0
- package/src/api/users/controllers/AdminUserController.ts +5 -0
- package/src/cli/commands/build.ts +1 -0
- package/src/cli/providers/ViteDevServerProvider.ts +31 -0
- package/src/email/index.workerd.ts +36 -0
- package/src/email/providers/WorkermailerEmailProvider.ts +221 -0
- package/src/lock/core/primitives/$lock.ts +13 -1
- package/src/react/auth/services/ReactAuth.ts +3 -1
- package/src/react/router/atoms/ssrManifestAtom.ts +7 -0
- package/src/react/router/providers/ReactServerProvider.ts +14 -4
- package/src/react/router/providers/SSRManifestProvider.ts +7 -0
- package/src/scheduler/index.workerd.ts +43 -0
- package/src/scheduler/providers/CronProvider.ts +53 -6
- package/src/scheduler/providers/WorkerdCronProvider.ts +102 -0
- package/src/server/compress/providers/ServerCompressProvider.ts +6 -0
- package/src/server/links/providers/ServerLinksProvider.spec.ts +332 -0
- package/src/server/links/providers/ServerLinksProvider.ts +1 -1
- package/src/vite/tasks/generateCloudflare.ts +38 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { $module } from "alepha";
|
|
2
|
+
import { AlephaLock } from "alepha/lock";
|
|
3
|
+
import { $scheduler } from "./primitives/$scheduler.ts";
|
|
4
|
+
import { CronProvider } from "./providers/CronProvider.ts";
|
|
5
|
+
import { WorkerdCronProvider } from "./providers/WorkerdCronProvider.ts";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export * from "./constants/CRON.ts";
|
|
10
|
+
export * from "./primitives/$scheduler.ts";
|
|
11
|
+
export * from "./providers/CronProvider.ts";
|
|
12
|
+
export * from "./providers/WorkerdCronProvider.ts";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
declare module "alepha" {
|
|
17
|
+
interface Hooks {
|
|
18
|
+
/**
|
|
19
|
+
* Cloudflare Workers scheduled event.
|
|
20
|
+
*
|
|
21
|
+
* Emitted when a cron trigger fires in Cloudflare Workers.
|
|
22
|
+
*/
|
|
23
|
+
"cloudflare:scheduled": {
|
|
24
|
+
cron: string;
|
|
25
|
+
scheduledTime: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export const AlephaScheduler = $module({
|
|
33
|
+
name: "alepha.scheduler",
|
|
34
|
+
primitives: [$scheduler],
|
|
35
|
+
services: [AlephaLock, CronProvider, WorkerdCronProvider],
|
|
36
|
+
register: (alepha) => {
|
|
37
|
+
// Replace CronProvider with WorkerdCronProvider for Cloudflare Workers
|
|
38
|
+
alepha.with({
|
|
39
|
+
provide: CronProvider,
|
|
40
|
+
use: WorkerdCronProvider,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -16,6 +16,11 @@ export class CronProvider {
|
|
|
16
16
|
protected readonly start = $hook({
|
|
17
17
|
on: "start",
|
|
18
18
|
handler: () => {
|
|
19
|
+
if (this.alepha.isServerless()) {
|
|
20
|
+
this.log.info("Ignoring cron jobs in serverless environment");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
for (const cron of this.cronJobs) {
|
|
20
25
|
if (!cron.running) {
|
|
21
26
|
cron.running = true;
|
|
@@ -62,12 +67,12 @@ export class CronProvider {
|
|
|
62
67
|
? this.cronJobs.find((c) => c.name === name)
|
|
63
68
|
: name;
|
|
64
69
|
|
|
65
|
-
if (!cron) {
|
|
70
|
+
if (!cron || !cron.running) {
|
|
66
71
|
return;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
cron.running = false;
|
|
70
|
-
cron.abort
|
|
75
|
+
cron.abort?.abort();
|
|
71
76
|
this.log.debug(`Cron task '${cron.name}' stopped`);
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -109,13 +114,13 @@ export class CronProvider {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
const duration = next.getTime() - now.toDate().getTime();
|
|
112
|
-
|
|
113
|
-
task.abort =
|
|
117
|
+
const abort = new AbortController();
|
|
118
|
+
task.abort = abort;
|
|
114
119
|
|
|
115
120
|
this.dt
|
|
116
121
|
.wait(duration, {
|
|
117
122
|
now: now.valueOf(),
|
|
118
|
-
signal:
|
|
123
|
+
signal: abort.signal,
|
|
119
124
|
})
|
|
120
125
|
.then(() => {
|
|
121
126
|
if (!task.running) {
|
|
@@ -141,6 +146,48 @@ export class CronProvider {
|
|
|
141
146
|
this.log.warn("Issue during cron waiting timer", err as Error);
|
|
142
147
|
});
|
|
143
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Trigger a specific cron job by name.
|
|
152
|
+
*/
|
|
153
|
+
public async trigger(name: string): Promise<void> {
|
|
154
|
+
const job = this.cronJobs.find((j) => j.name === name);
|
|
155
|
+
if (!job) {
|
|
156
|
+
this.log.warn(`Cron job '${name}' not found`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await this.runJobs([job], this.dt.now());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Trigger all registered cron jobs.
|
|
164
|
+
*/
|
|
165
|
+
public async triggerAll(): Promise<void> {
|
|
166
|
+
await this.runJobs(this.cronJobs, this.dt.now());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run multiple cron jobs in parallel.
|
|
171
|
+
*/
|
|
172
|
+
protected async runJobs(jobs: CronJob[], now: DateTime): Promise<void> {
|
|
173
|
+
const results = await Promise.allSettled(
|
|
174
|
+
jobs.map(async (job) => {
|
|
175
|
+
this.log.debug(`Running cron job '${job.name}'`);
|
|
176
|
+
try {
|
|
177
|
+
await job.handler({ now });
|
|
178
|
+
this.log.debug(`Cron job '${job.name}' completed`);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.log.error(`Cron job '${job.name}' failed`, error);
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
187
|
+
if (failures.length > 0) {
|
|
188
|
+
this.log.error(`${failures.length}/${jobs.length} cron jobs failed`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
144
191
|
}
|
|
145
192
|
|
|
146
193
|
export interface CronJob {
|
|
@@ -151,5 +198,5 @@ export interface CronJob {
|
|
|
151
198
|
loop: boolean;
|
|
152
199
|
running?: boolean;
|
|
153
200
|
onError?: (error: Error) => void;
|
|
154
|
-
abort
|
|
201
|
+
abort?: AbortController;
|
|
155
202
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { $hook } from "alepha";
|
|
2
|
+
import type { DateTime } from "alepha/datetime";
|
|
3
|
+
import { parseCronExpression } from "cron-schedule";
|
|
4
|
+
import { CronProvider } from "./CronProvider.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
declare module "alepha" {
|
|
9
|
+
interface Hooks {
|
|
10
|
+
/**
|
|
11
|
+
* Cloudflare Workers scheduled event.
|
|
12
|
+
*
|
|
13
|
+
* Emitted when a cron trigger fires in Cloudflare Workers.
|
|
14
|
+
*/
|
|
15
|
+
"cloudflare:scheduled": {
|
|
16
|
+
cron: string;
|
|
17
|
+
scheduledTime: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cloudflare Workers cron provider.
|
|
26
|
+
*
|
|
27
|
+
* This provider handles scheduled events from Cloudflare Workers Cron Triggers.
|
|
28
|
+
* Unlike the Node.js CronProvider, this doesn't use intervals/timeouts - instead,
|
|
29
|
+
* it reacts to scheduled events triggered by Cloudflare.
|
|
30
|
+
*
|
|
31
|
+
* **Usage:**
|
|
32
|
+
* 1. Define schedulers with `$scheduler({ cron: "0 * * * *", handler: ... })`
|
|
33
|
+
* 2. Build your app with `alepha build` - cron triggers are automatically added to `wrangler.jsonc`
|
|
34
|
+
* 3. Deploy to Cloudflare Workers
|
|
35
|
+
*
|
|
36
|
+
* **How it works:**
|
|
37
|
+
* - During build, all registered `$scheduler` cron expressions are collected
|
|
38
|
+
* - The build generates `wrangler.jsonc` with `triggers.crons` automatically filled
|
|
39
|
+
* - When Cloudflare fires a cron trigger, the `scheduled` handler emits `cloudflare:scheduled`
|
|
40
|
+
* - This provider listens to that event and runs matching schedulers
|
|
41
|
+
*
|
|
42
|
+
* @see https://developers.cloudflare.com/workers/configuration/cron-triggers/
|
|
43
|
+
*/
|
|
44
|
+
export class WorkerdCronProvider extends CronProvider {
|
|
45
|
+
/**
|
|
46
|
+
* Override to avoid creating AbortController in global scope.
|
|
47
|
+
* Cloudflare Workers doesn't allow this during initialization.
|
|
48
|
+
*/
|
|
49
|
+
public override createCronJob(
|
|
50
|
+
name: string,
|
|
51
|
+
expression: string,
|
|
52
|
+
handler: (context: { now: DateTime }) => Promise<void>,
|
|
53
|
+
): void {
|
|
54
|
+
this.cronJobs.push({
|
|
55
|
+
name,
|
|
56
|
+
cron: parseCronExpression(expression),
|
|
57
|
+
expression,
|
|
58
|
+
handler,
|
|
59
|
+
loop: false,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handle a scheduled event from Cloudflare Workers.
|
|
65
|
+
*/
|
|
66
|
+
protected readonly onScheduledEvent = $hook({
|
|
67
|
+
on: "cloudflare:scheduled",
|
|
68
|
+
handler: async (event) => {
|
|
69
|
+
const now = this.dt.of(event.scheduledTime);
|
|
70
|
+
|
|
71
|
+
this.log.info("Received scheduled event", {
|
|
72
|
+
cron: event.cron,
|
|
73
|
+
scheduledTime: now.format(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Find jobs that match this cron expression
|
|
77
|
+
const matchingJobs = this.cronJobs.filter(
|
|
78
|
+
(job) => job.expression === event.cron,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (matchingJobs.length === 0) {
|
|
82
|
+
// No exact match - try to find jobs that would fire at this time
|
|
83
|
+
const matchingByTime = this.cronJobs.filter((job) =>
|
|
84
|
+
job.cron.matchDate(now.toDate()),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (matchingByTime.length > 0) {
|
|
88
|
+
this.log.debug(
|
|
89
|
+
`No exact cron match for '${event.cron}', found ${matchingByTime.length} jobs matching by time`,
|
|
90
|
+
);
|
|
91
|
+
await this.runJobs(matchingByTime, now);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.log.warn(`No cron jobs found for expression '${event.cron}'`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.runJobs(matchingJobs, now);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -62,6 +62,12 @@ export class ServerCompressProvider {
|
|
|
62
62
|
public readonly onResponse = $hook({
|
|
63
63
|
on: "server:onResponse",
|
|
64
64
|
handler: async ({ request, response }) => {
|
|
65
|
+
// In serverless (Cloudflare Workers), skip compression entirely:
|
|
66
|
+
// Cloudflare's edge network automatically compresses responses
|
|
67
|
+
if (this.alepha.isServerless()) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
// skip if already compressed
|
|
66
72
|
if (response.headers["content-encoding"]) {
|
|
67
73
|
return;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Alepha, t } from "alepha";
|
|
3
|
+
import { $issuer, AlephaSecurity } from "alepha/security";
|
|
4
|
+
import { $action, ServerProvider } from "alepha/server";
|
|
5
|
+
import { describe, it } from "vitest";
|
|
6
|
+
import { LinkProvider, ServerLinksProvider } from "../index.ts";
|
|
7
|
+
|
|
8
|
+
describe("ServerLinksProvider", () => {
|
|
9
|
+
describe("secured field in links", () => {
|
|
10
|
+
it("should set secured=undefined for public actions (no secure option)", async ({
|
|
11
|
+
expect,
|
|
12
|
+
}) => {
|
|
13
|
+
class App {
|
|
14
|
+
publicAction = $action({
|
|
15
|
+
handler: () => "PUBLIC",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const alepha = Alepha.create().with(App).with(ServerLinksProvider);
|
|
20
|
+
await alepha.start();
|
|
21
|
+
|
|
22
|
+
const links = alepha.inject(LinkProvider).getServerLinks();
|
|
23
|
+
const link = links.find((l) => l.name === "publicAction");
|
|
24
|
+
|
|
25
|
+
expect(link).toBeDefined();
|
|
26
|
+
expect(link?.secured).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should set secured=false for explicitly public actions", async ({
|
|
30
|
+
expect,
|
|
31
|
+
}) => {
|
|
32
|
+
class App {
|
|
33
|
+
publicAction = $action({
|
|
34
|
+
secure: false,
|
|
35
|
+
handler: () => "PUBLIC",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const alepha = Alepha.create().with(App).with(ServerLinksProvider);
|
|
40
|
+
await alepha.start();
|
|
41
|
+
|
|
42
|
+
const links = alepha.inject(LinkProvider).getServerLinks();
|
|
43
|
+
const link = links.find((l) => l.name === "publicAction");
|
|
44
|
+
|
|
45
|
+
expect(link).toBeDefined();
|
|
46
|
+
expect(link?.secured).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should set secured=true for secured actions", async ({ expect }) => {
|
|
50
|
+
class App {
|
|
51
|
+
securedAction = $action({
|
|
52
|
+
secure: true,
|
|
53
|
+
handler: () => "SECURED",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const alepha = Alepha.create().with(App).with(ServerLinksProvider);
|
|
58
|
+
await alepha.start();
|
|
59
|
+
|
|
60
|
+
const links = alepha.inject(LinkProvider).getServerLinks();
|
|
61
|
+
const link = links.find((l) => l.name === "securedAction");
|
|
62
|
+
|
|
63
|
+
expect(link).toBeDefined();
|
|
64
|
+
expect(link?.secured).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should set secured to object for realm-secured actions", async ({
|
|
68
|
+
expect,
|
|
69
|
+
}) => {
|
|
70
|
+
class App {
|
|
71
|
+
realmAction = $action({
|
|
72
|
+
secure: { realm: "admin" },
|
|
73
|
+
handler: () => "REALM",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const alepha = Alepha.create().with(App).with(ServerLinksProvider);
|
|
78
|
+
await alepha.start();
|
|
79
|
+
|
|
80
|
+
const links = alepha.inject(LinkProvider).getServerLinks();
|
|
81
|
+
const link = links.find((l) => l.name === "realmAction");
|
|
82
|
+
|
|
83
|
+
expect(link).toBeDefined();
|
|
84
|
+
expect(link?.secured).toEqual({ realm: "admin" });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("/_links endpoint with security", () => {
|
|
89
|
+
it("should return public actions to unauthenticated users", async ({
|
|
90
|
+
expect,
|
|
91
|
+
}) => {
|
|
92
|
+
class App {
|
|
93
|
+
publicAction = $action({
|
|
94
|
+
schema: { response: t.text() },
|
|
95
|
+
handler: () => "PUBLIC",
|
|
96
|
+
});
|
|
97
|
+
issuer = $issuer({
|
|
98
|
+
secret: "test",
|
|
99
|
+
roles: [{ name: "user", permissions: [{ name: "*" }] }],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const alepha = Alepha.create()
|
|
104
|
+
.with(App)
|
|
105
|
+
.with(ServerLinksProvider)
|
|
106
|
+
.with(AlephaSecurity);
|
|
107
|
+
await alepha.start();
|
|
108
|
+
|
|
109
|
+
const res = await fetch(
|
|
110
|
+
`${alepha.inject(ServerProvider).hostname}/api/_links`,
|
|
111
|
+
);
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
|
|
114
|
+
expect(data.links).toContainEqual(
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
name: "publicAction",
|
|
117
|
+
path: "/publicAction",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should NOT return secured actions to unauthenticated users", async ({
|
|
123
|
+
expect,
|
|
124
|
+
}) => {
|
|
125
|
+
class App {
|
|
126
|
+
securedAction = $action({
|
|
127
|
+
secure: true,
|
|
128
|
+
schema: { response: t.text() },
|
|
129
|
+
handler: () => "SECURED",
|
|
130
|
+
});
|
|
131
|
+
issuer = $issuer({
|
|
132
|
+
secret: "test",
|
|
133
|
+
roles: [{ name: "user", permissions: [{ name: "*" }] }],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const alepha = Alepha.create()
|
|
138
|
+
.with(App)
|
|
139
|
+
.with(ServerLinksProvider)
|
|
140
|
+
.with(AlephaSecurity);
|
|
141
|
+
await alepha.start();
|
|
142
|
+
|
|
143
|
+
const res = await fetch(
|
|
144
|
+
`${alepha.inject(ServerProvider).hostname}/api/_links`,
|
|
145
|
+
);
|
|
146
|
+
const data = await res.json();
|
|
147
|
+
|
|
148
|
+
expect(data.links).not.toContainEqual(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
name: "securedAction",
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should return secured actions to authenticated users with permissions", async ({
|
|
156
|
+
expect,
|
|
157
|
+
}) => {
|
|
158
|
+
class App {
|
|
159
|
+
securedAction = $action({
|
|
160
|
+
secure: true,
|
|
161
|
+
schema: { response: t.text() },
|
|
162
|
+
handler: () => "SECURED",
|
|
163
|
+
});
|
|
164
|
+
issuer = $issuer({
|
|
165
|
+
secret: "test",
|
|
166
|
+
roles: [{ name: "user", permissions: [{ name: "*" }] }],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const alepha = Alepha.create()
|
|
171
|
+
.with(App)
|
|
172
|
+
.with(ServerLinksProvider)
|
|
173
|
+
.with(AlephaSecurity);
|
|
174
|
+
await alepha.start();
|
|
175
|
+
|
|
176
|
+
// Use HttpClient to get a token automatically in test mode
|
|
177
|
+
const app = alepha.inject(App);
|
|
178
|
+
const { data } = await app.securedAction.fetch(
|
|
179
|
+
{},
|
|
180
|
+
{
|
|
181
|
+
user: { id: randomUUID(), roles: ["user"] },
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
expect(data).toBe("SECURED");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should return both public and secured actions when user is authenticated", async ({
|
|
188
|
+
expect,
|
|
189
|
+
}) => {
|
|
190
|
+
class App {
|
|
191
|
+
publicAction = $action({
|
|
192
|
+
schema: { response: t.text() },
|
|
193
|
+
handler: () => "PUBLIC",
|
|
194
|
+
});
|
|
195
|
+
securedAction = $action({
|
|
196
|
+
secure: true,
|
|
197
|
+
schema: { response: t.text() },
|
|
198
|
+
handler: () => "SECURED",
|
|
199
|
+
});
|
|
200
|
+
issuer = $issuer({
|
|
201
|
+
secret: "test",
|
|
202
|
+
roles: [{ name: "user", permissions: [{ name: "*" }] }],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const alepha = Alepha.create()
|
|
207
|
+
.with(App)
|
|
208
|
+
.with(ServerLinksProvider)
|
|
209
|
+
.with(AlephaSecurity);
|
|
210
|
+
await alepha.start();
|
|
211
|
+
|
|
212
|
+
const linksProvider = alepha.inject(ServerLinksProvider);
|
|
213
|
+
const user = { id: randomUUID(), roles: ["user"] };
|
|
214
|
+
|
|
215
|
+
const { links } = await linksProvider.getUserApiLinks({ user });
|
|
216
|
+
|
|
217
|
+
expect(links).toContainEqual(
|
|
218
|
+
expect.objectContaining({ name: "publicAction" }),
|
|
219
|
+
);
|
|
220
|
+
expect(links).toContainEqual(
|
|
221
|
+
expect.objectContaining({ name: "securedAction" }),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should filter secured actions based on user permissions", async ({
|
|
226
|
+
expect,
|
|
227
|
+
}) => {
|
|
228
|
+
class App {
|
|
229
|
+
adminOnly = $action({
|
|
230
|
+
secure: true,
|
|
231
|
+
group: "admin",
|
|
232
|
+
schema: { response: t.text() },
|
|
233
|
+
handler: () => "ADMIN",
|
|
234
|
+
});
|
|
235
|
+
userAction = $action({
|
|
236
|
+
secure: true,
|
|
237
|
+
group: "user",
|
|
238
|
+
schema: { response: t.text() },
|
|
239
|
+
handler: () => "USER",
|
|
240
|
+
});
|
|
241
|
+
issuer = $issuer({
|
|
242
|
+
secret: "test",
|
|
243
|
+
roles: [
|
|
244
|
+
{ name: "admin", permissions: [{ name: "*" }] },
|
|
245
|
+
{ name: "user", permissions: [{ name: "user:*" }] },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const alepha = Alepha.create()
|
|
251
|
+
.with(App)
|
|
252
|
+
.with(ServerLinksProvider)
|
|
253
|
+
.with(AlephaSecurity);
|
|
254
|
+
await alepha.start();
|
|
255
|
+
|
|
256
|
+
const linksProvider = alepha.inject(ServerLinksProvider);
|
|
257
|
+
|
|
258
|
+
// User with "user" role should only see userAction
|
|
259
|
+
const userLinks = await linksProvider.getUserApiLinks({
|
|
260
|
+
user: { id: randomUUID(), roles: ["user"] },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(userLinks.links).toContainEqual(
|
|
264
|
+
expect.objectContaining({ name: "userAction" }),
|
|
265
|
+
);
|
|
266
|
+
expect(userLinks.links).not.toContainEqual(
|
|
267
|
+
expect.objectContaining({ name: "adminOnly" }),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// User with "admin" role should see both
|
|
271
|
+
const adminLinks = await linksProvider.getUserApiLinks({
|
|
272
|
+
user: { id: randomUUID(), roles: ["admin"] },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(adminLinks.links).toContainEqual(
|
|
276
|
+
expect.objectContaining({ name: "userAction" }),
|
|
277
|
+
);
|
|
278
|
+
expect(adminLinks.links).toContainEqual(
|
|
279
|
+
expect.objectContaining({ name: "adminOnly" }),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("mixed public and secured actions", () => {
|
|
285
|
+
it("should correctly differentiate public from secured in server links", async ({
|
|
286
|
+
expect,
|
|
287
|
+
}) => {
|
|
288
|
+
class App {
|
|
289
|
+
getUsers = $action({
|
|
290
|
+
path: "/users",
|
|
291
|
+
schema: { response: t.array(t.text()) },
|
|
292
|
+
handler: () => ["user1", "user2"],
|
|
293
|
+
});
|
|
294
|
+
createUser = $action({
|
|
295
|
+
path: "/users",
|
|
296
|
+
secure: true,
|
|
297
|
+
schema: {
|
|
298
|
+
body: t.object({ name: t.text() }),
|
|
299
|
+
response: t.text(),
|
|
300
|
+
},
|
|
301
|
+
handler: ({ body }) => body.name,
|
|
302
|
+
});
|
|
303
|
+
deleteUser = $action({
|
|
304
|
+
method: "DELETE",
|
|
305
|
+
path: "/users/:id",
|
|
306
|
+
secure: true,
|
|
307
|
+
schema: {
|
|
308
|
+
params: t.object({ id: t.text() }),
|
|
309
|
+
response: t.void(),
|
|
310
|
+
},
|
|
311
|
+
handler: () => {},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const alepha = Alepha.create().with(App).with(ServerLinksProvider);
|
|
316
|
+
await alepha.start();
|
|
317
|
+
|
|
318
|
+
const links = alepha.inject(LinkProvider).getServerLinks();
|
|
319
|
+
|
|
320
|
+
const getUsers = links.find((l) => l.name === "getUsers");
|
|
321
|
+
const createUser = links.find((l) => l.name === "createUser");
|
|
322
|
+
const deleteUser = links.find((l) => l.name === "deleteUser");
|
|
323
|
+
|
|
324
|
+
// getUsers is public (no secure option)
|
|
325
|
+
expect(getUsers?.secured).toBeUndefined();
|
|
326
|
+
|
|
327
|
+
// createUser and deleteUser are secured
|
|
328
|
+
expect(createUser?.secured).toBe(true);
|
|
329
|
+
expect(deleteUser?.secured).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -48,7 +48,7 @@ export class ServerLinksProvider {
|
|
|
48
48
|
group: action.group,
|
|
49
49
|
schema: action.options.schema,
|
|
50
50
|
requestBodyType: action.getBodyContentType(),
|
|
51
|
-
secured: action.options.secure
|
|
51
|
+
secured: action.options.secure,
|
|
52
52
|
method: action.method === "GET" ? undefined : action.method,
|
|
53
53
|
prefix: action.prefix,
|
|
54
54
|
path: action.path,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { access, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
|
+
import type { Alepha } from "alepha";
|
|
4
|
+
import type { CronProvider } from "alepha/scheduler";
|
|
5
|
+
import type { WorkerdCronProvider } from "../../scheduler/providers/WorkerdCronProvider.ts";
|
|
3
6
|
|
|
4
7
|
export interface GenerateCloudflareOptions {
|
|
5
8
|
/**
|
|
@@ -13,6 +16,8 @@ export interface GenerateCloudflareOptions {
|
|
|
13
16
|
* Additional Wrangler configuration options to merge into wrangler.jsonc.
|
|
14
17
|
*/
|
|
15
18
|
config?: WranglerConfig;
|
|
19
|
+
|
|
20
|
+
alepha: Alepha;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
export interface WranglerConfig {
|
|
@@ -31,7 +36,7 @@ const WARNING_COMMENT =
|
|
|
31
36
|
* - worker.js entry point for Cloudflare Workers
|
|
32
37
|
*/
|
|
33
38
|
export async function generateCloudflare(
|
|
34
|
-
opts: GenerateCloudflareOptions
|
|
39
|
+
opts: GenerateCloudflareOptions,
|
|
35
40
|
): Promise<void> {
|
|
36
41
|
const distDir = opts.distDir ?? "dist";
|
|
37
42
|
const root = process.cwd();
|
|
@@ -40,6 +45,15 @@ export async function generateCloudflare(
|
|
|
40
45
|
.then(() => true)
|
|
41
46
|
.catch(() => false);
|
|
42
47
|
|
|
48
|
+
let workerdCronProvider: CronProvider | undefined;
|
|
49
|
+
try {
|
|
50
|
+
workerdCronProvider = opts.alepha.inject(
|
|
51
|
+
"CronProvider",
|
|
52
|
+
) as WorkerdCronProvider;
|
|
53
|
+
} catch {}
|
|
54
|
+
|
|
55
|
+
const crons = workerdCronProvider?.getCronJobs();
|
|
56
|
+
|
|
43
57
|
const wrangler: WranglerConfig = {
|
|
44
58
|
name,
|
|
45
59
|
main: "./main.cloudflare.js",
|
|
@@ -62,6 +76,12 @@ export async function generateCloudflare(
|
|
|
62
76
|
};
|
|
63
77
|
}
|
|
64
78
|
|
|
79
|
+
if (crons && crons.length > 0) {
|
|
80
|
+
const cronExpressions = [...new Set(crons.map((c) => c.expression))];
|
|
81
|
+
wrangler.triggers ??= {};
|
|
82
|
+
wrangler.triggers.crons = cronExpressions;
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
const url = process.env.DATABASE_URL;
|
|
66
86
|
if (url?.startsWith("d1:")) {
|
|
67
87
|
const [name, id] = url.replace("d1://", "").replace("d1:", "").split(":");
|
|
@@ -102,7 +122,7 @@ export default {
|
|
|
102
122
|
try {
|
|
103
123
|
await __alepha.start();
|
|
104
124
|
} catch (err) {
|
|
105
|
-
console.error(err);
|
|
125
|
+
console.error("Failed to start Alepha for fetch event", err);
|
|
106
126
|
return new Response("Internal Server Error", { status: 500 });
|
|
107
127
|
}
|
|
108
128
|
|
|
@@ -110,6 +130,22 @@ export default {
|
|
|
110
130
|
|
|
111
131
|
return ctx.res;
|
|
112
132
|
},
|
|
133
|
+
|
|
134
|
+
scheduled: async (event, env, ctx) => {
|
|
135
|
+
__alepha.set("cloudflare.env", env);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await __alepha.start();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error("Failed to start Alepha for scheduled event", err);
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await __alepha.events.emit("cloudflare:scheduled", {
|
|
145
|
+
cron: event.cron,
|
|
146
|
+
scheduledTime: event.scheduledTime,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
113
149
|
};
|
|
114
150
|
`.trim();
|
|
115
151
|
|