alepha 0.20.3 → 0.20.5
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.map +1 -1
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/jobs/index.d.ts +14 -14
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/organizations/index.d.ts.map +1 -1
- package/dist/api/parameters/index.d.ts +6 -1
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +20 -4
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/payments/index.d.ts.map +1 -1
- package/dist/api/users/index.browser.js +6 -0
- package/dist/api/users/index.browser.js.map +1 -1
- package/dist/api/users/index.d.ts +5032 -134
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +58 -10
- package/dist/api/users/index.js.map +1 -1
- package/dist/bin/index.js +0 -0
- package/dist/bucket/index.d.ts +77 -107
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +148 -4
- package/dist/bucket/index.js.map +1 -1
- package/dist/bucket/index.workerd.js +7 -1
- package/dist/bucket/index.workerd.js.map +1 -1
- package/dist/cache/core/index.d.ts +26 -0
- package/dist/cache/core/index.d.ts.map +1 -1
- package/dist/cache/core/index.js +11 -1
- package/dist/cache/core/index.js.map +1 -1
- package/dist/cache/core/index.workerd.js +11 -1
- package/dist/cache/core/index.workerd.js.map +1 -1
- package/dist/cli/config/index.d.ts +7 -5
- package/dist/cli/config/index.d.ts.map +1 -1
- package/dist/cli/config/index.js +2 -3
- package/dist/cli/config/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +419 -12
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +22 -511
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.d.ts +4 -8
- package/dist/cli/devtools/index.d.ts.map +1 -1
- package/dist/cli/devtools/index.js +13 -15
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/platform/index.d.ts +10 -13
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +18 -15
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +10 -13
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +16 -13
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.d.ts +1 -1
- package/dist/core/index.browser.js +27 -3
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +6 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +27 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +27 -3
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +27 -3
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/datetime/index.d.ts +69 -10
- package/dist/datetime/index.d.ts.map +1 -1
- package/dist/datetime/index.js +135 -13
- package/dist/datetime/index.js.map +1 -1
- package/dist/email/smtp/index.js +10636 -2
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/fake/index.d.ts +8085 -4
- package/dist/fake/index.d.ts.map +1 -1
- package/dist/fake/index.js +33554 -3
- package/dist/fake/index.js.map +1 -1
- package/dist/lock/core/index.d.ts +30 -2
- package/dist/lock/core/index.d.ts.map +1 -1
- package/dist/lock/core/index.js +35 -12
- package/dist/lock/core/index.js.map +1 -1
- package/dist/mcp/index.d.ts +238 -31
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +198 -71
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js +1 -1
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js +4 -3
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +4877 -9
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +4 -3
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.d.ts +608 -1
- package/dist/orm/postgres/index.d.ts.map +1 -1
- package/dist/react/core/index.d.ts +102 -1
- package/dist/react/core/index.d.ts.map +1 -1
- package/dist/react/core/index.js +65 -1
- package/dist/react/core/index.js.map +1 -1
- package/dist/react/form/index.d.ts +6 -0
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +7 -7
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +7 -1
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +6 -0
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/router/index.browser.js +20 -2
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +36 -4
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +20 -2
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/testing/chunk-6Ep1yQYe.js +16 -0
- package/dist/react/testing/index.d.ts +411 -1
- package/dist/react/testing/index.d.ts.map +1 -1
- package/dist/react/testing/index.js +12293 -13
- package/dist/react/testing/index.js.map +1 -1
- package/dist/react/ui/index.d.ts +195 -1
- package/dist/react/ui/index.d.ts.map +1 -1
- package/dist/react/ui/index.js +61 -1
- package/dist/react/ui/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +84 -3
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +390 -1
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/index.workerd.js +390 -1
- package/dist/scheduler/index.workerd.js.map +1 -1
- package/dist/security/index.d.ts +325 -2
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +1361 -2
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +1054 -1
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +1223 -1
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/core/index.browser.js +10 -3
- package/dist/server/core/index.browser.js.map +1 -1
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +28 -5
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/metrics/index.d.ts +514 -1
- package/dist/server/metrics/index.d.ts.map +1 -1
- package/dist/server/metrics/index.js +4374 -4
- package/dist/server/metrics/index.js.map +1 -1
- package/dist/server/swagger/index.d.ts.map +1 -1
- package/dist/server/swagger/index.js +3 -4
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/websocket/index.browser.js +11 -5
- package/dist/websocket/index.browser.js.map +1 -1
- package/dist/websocket/index.d.ts +3 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js +21 -6
- package/dist/websocket/index.js.map +1 -1
- package/package.json +416 -8
- package/src/api/parameters/services/ParameterProvider.ts +21 -4
- package/src/api/users/__tests__/SessionService.spec.ts +99 -0
- package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
- package/src/api/users/entities/sessions.ts +6 -0
- package/src/api/users/jobs/UserJobs.ts +44 -17
- package/src/api/users/providers/RealmProvider.ts +4 -0
- package/src/api/users/services/SessionService.ts +27 -0
- package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
- package/src/bucket/index.ts +19 -2
- package/src/bucket/primitives/$bucket.ts +9 -1
- package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
- package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
- package/src/cache/core/index.ts +29 -0
- package/src/cache/core/primitives/$cache.ts +14 -1
- package/src/cli/config/defineConfig.ts +13 -15
- package/src/cli/core/__tests__/init.spec.ts +6 -7
- package/src/cli/core/services/ProjectScaffolder.ts +18 -14
- package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
- package/src/cli/core/templates/agentMd.ts +2 -10
- package/src/cli/core/templates/saasAdminLayoutTsx.ts +3 -3
- package/src/cli/devtools/index.ts +12 -26
- package/src/cli/platform/index.ts +15 -24
- package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
- package/src/cli/vendor/index.ts +14 -23
- package/src/core/Alepha.ts +11 -1
- package/src/core/helpers/ref.ts +18 -0
- package/src/core/index.shared.ts +1 -0
- package/src/core/providers/SchemaValidator.ts +9 -1
- package/src/core/providers/TypeProvider.ts +1 -2
- package/src/datetime/REFACTORING.md +118 -0
- package/src/datetime/providers/DateTimeProvider.ts +203 -24
- package/src/lock/core/index.ts +31 -0
- package/src/lock/core/primitives/$lock.ts +14 -1
- package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
- package/src/mcp/helpers/jsonrpc.ts +26 -1
- package/src/mcp/index.ts +10 -5
- package/src/mcp/interfaces/McpTypes.ts +83 -6
- package/src/mcp/primitives/$prompt.ts +18 -1
- package/src/mcp/primitives/$resource.ts +18 -1
- package/src/mcp/primitives/$tool.ts +83 -7
- package/src/mcp/providers/McpServerProvider.ts +74 -16
- package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
- package/src/orm/REFACTORING.md +330 -0
- package/src/orm/core/primitives/$transactional.ts +11 -0
- package/src/orm/core/schemas/updateSchema.ts +1 -1
- package/src/orm/core/services/PgRelationManager.ts +4 -2
- package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
- package/src/react/core/hooks/useQuery.ts +153 -0
- package/src/react/core/index.ts +1 -0
- package/src/react/form/services/FormModel.ts +15 -6
- package/src/react/form/services/parseField.ts +8 -0
- package/src/react/i18n/providers/I18nProvider.ts +8 -2
- package/src/react/router/__tests__/$page.spec.tsx +0 -16
- package/src/react/router/__tests__/ssr.spec.tsx +339 -0
- package/src/react/router/primitives/$page.ts +28 -4
- package/src/react/router/providers/ReactPageProvider.ts +27 -9
- package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
- package/src/react/ui/index.ts +6 -0
- package/src/react/ui/services/SchemaControl.ts +209 -0
- package/src/security/primitives/$issuer.ts +6 -3
- package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
- package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
- package/src/server/core/errors/ValidationError.ts +13 -1
- package/src/server/core/primitives/$action.ts +16 -5
- package/src/server/core/providers/ServerRouterProvider.ts +26 -4
- package/src/server/swagger/providers/ServerSwaggerProvider.ts +5 -7
- package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
- package/src/websocket/services/WebSocketClient.ts +11 -5
- package/src/mcp/transports/SseMcpTransport.ts +0 -182
|
@@ -5,7 +5,9 @@ import { AlephaOrmPostgres } from "alepha/orm/postgres";
|
|
|
5
5
|
import { describe, test } from "vitest";
|
|
6
6
|
import { sessions } from "../entities/sessions.ts";
|
|
7
7
|
import { users } from "../entities/users.ts";
|
|
8
|
+
import { AlephaApiUsers } from "../index.ts";
|
|
8
9
|
import { UserJobs } from "../jobs/UserJobs.ts";
|
|
10
|
+
import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
9
11
|
|
|
10
12
|
describe("UserJobs", () => {
|
|
11
13
|
describe("purgeExpiredSessions", () => {
|
|
@@ -93,5 +95,70 @@ describe("UserJobs", () => {
|
|
|
93
95
|
|
|
94
96
|
expect(await repos.sessionRepository.findMany()).toHaveLength(1);
|
|
95
97
|
});
|
|
98
|
+
|
|
99
|
+
test("purges idle sessions when expirationIdle is configured", async ({
|
|
100
|
+
expect,
|
|
101
|
+
}) => {
|
|
102
|
+
const alepha = Alepha.create()
|
|
103
|
+
.with(AlephaOrmPostgres)
|
|
104
|
+
.with(AlephaApiJobs)
|
|
105
|
+
.with(AlephaApiUsers);
|
|
106
|
+
|
|
107
|
+
class TestRepositories {
|
|
108
|
+
userRepository = $repository(users);
|
|
109
|
+
sessionRepository = $repository(sessions);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const userJobs = alepha.inject(UserJobs);
|
|
113
|
+
const repos = alepha.inject(TestRepositories);
|
|
114
|
+
const realmProvider = alepha.inject(RealmProvider);
|
|
115
|
+
realmProvider.register("default", {
|
|
116
|
+
settings: {
|
|
117
|
+
refreshToken: { expirationIdle: 5 * 60 * 1000 }, // 5 minutes
|
|
118
|
+
} as never,
|
|
119
|
+
});
|
|
120
|
+
await alepha.start();
|
|
121
|
+
|
|
122
|
+
const user = await repos.userRepository.create({
|
|
123
|
+
email: "idle-sweep@example.com",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const farFuture = new Date(
|
|
127
|
+
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
|
128
|
+
).toISOString();
|
|
129
|
+
const oldUsed = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // 30 min ago
|
|
130
|
+
const recentUsed = new Date(Date.now() - 60 * 1000).toISOString(); // 1 min ago
|
|
131
|
+
|
|
132
|
+
// Idle by lastUsedAt — should be purged (lastUsedAt > 5 min ago).
|
|
133
|
+
await repos.sessionRepository.create({
|
|
134
|
+
userId: user.id,
|
|
135
|
+
refreshToken: crypto.randomUUID(),
|
|
136
|
+
expiresAt: farFuture,
|
|
137
|
+
lastUsedAt: oldUsed,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Recently used — within idle window, should remain.
|
|
141
|
+
await repos.sessionRepository.create({
|
|
142
|
+
userId: user.id,
|
|
143
|
+
refreshToken: crypto.randomUUID(),
|
|
144
|
+
expiresAt: farFuture,
|
|
145
|
+
lastUsedAt: recentUsed,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Pre-migration row (lastUsedAt null) with old createdAt — should be
|
|
149
|
+
// purged via createdAt fallback. We can't directly set createdAt via
|
|
150
|
+
// create() (auto-managed), so simulate by creating a row and then
|
|
151
|
+
// updating lastUsedAt to null while the row is fresh — the createdAt
|
|
152
|
+
// fallback will not trip yet (createdAt is now). Skip this case in this
|
|
153
|
+
// test; covered logically by the deleteMany filter shape.
|
|
154
|
+
|
|
155
|
+
expect(await repos.sessionRepository.findMany()).toHaveLength(2);
|
|
156
|
+
|
|
157
|
+
await userJobs.purgeExpiredSessions.trigger();
|
|
158
|
+
|
|
159
|
+
const remaining = await repos.sessionRepository.findMany();
|
|
160
|
+
expect(remaining).toHaveLength(1);
|
|
161
|
+
expect(remaining[0].lastUsedAt).toBe(recentUsed);
|
|
162
|
+
});
|
|
96
163
|
});
|
|
97
164
|
});
|
|
@@ -121,6 +121,18 @@ export const realmAuthSettingsAtom = $atom({
|
|
|
121
121
|
minimum: 1000,
|
|
122
122
|
}),
|
|
123
123
|
}),
|
|
124
|
+
refreshToken: t.object({
|
|
125
|
+
expirationIdle: t.optional(
|
|
126
|
+
t.integer({
|
|
127
|
+
description:
|
|
128
|
+
"Maximum time in milliseconds a refresh token may stay unused before being invalidated. " +
|
|
129
|
+
"When set, sessions whose last refresh is older than this window are rejected and deleted, " +
|
|
130
|
+
"even if the absolute `expiresAt` has not been reached. Recommended for SaaS auth posture " +
|
|
131
|
+
"(SOC2/ISO27001). Leave undefined to disable idle invalidation (default).",
|
|
132
|
+
minimum: 1000,
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
}),
|
|
124
136
|
}),
|
|
125
137
|
default: {
|
|
126
138
|
// for a fresh hello world setup, we accept registration and email login
|
|
@@ -149,6 +161,9 @@ export const realmAuthSettingsAtom = $atom({
|
|
|
149
161
|
accountMaxAttempts: 5,
|
|
150
162
|
windowMs: 15 * 60 * 1000,
|
|
151
163
|
},
|
|
164
|
+
refreshToken: {
|
|
165
|
+
// expirationIdle: undefined — opt-in
|
|
166
|
+
},
|
|
152
167
|
},
|
|
153
168
|
});
|
|
154
169
|
|
|
@@ -12,6 +12,12 @@ export const sessions = $entity({
|
|
|
12
12
|
refreshToken: t.uuid(),
|
|
13
13
|
userId: db.ref(t.uuid(), () => users.cols.id),
|
|
14
14
|
expiresAt: t.datetime(),
|
|
15
|
+
/**
|
|
16
|
+
* Last time the session was used to refresh an access token.
|
|
17
|
+
* Used by realm `refreshToken.expirationIdle` to invalidate idle sessions.
|
|
18
|
+
* `null` on existing rows pre-migration — falls back to `createdAt`.
|
|
19
|
+
*/
|
|
20
|
+
lastUsedAt: t.optional(t.datetime()),
|
|
15
21
|
ip: t.optional(t.text()),
|
|
16
22
|
userAgent: t.optional(
|
|
17
23
|
t.object({
|
|
@@ -4,6 +4,7 @@ import { DateTimeProvider } from "alepha/datetime";
|
|
|
4
4
|
import { $logger } from "alepha/logger";
|
|
5
5
|
import { $repository } from "alepha/orm";
|
|
6
6
|
import { sessions } from "../entities/sessions.ts";
|
|
7
|
+
import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* User-specific jobs wrapper service.
|
|
@@ -20,11 +21,19 @@ export class UserJobs {
|
|
|
20
21
|
protected readonly log = $logger();
|
|
21
22
|
protected readonly dateTimeProvider = $inject(DateTimeProvider);
|
|
22
23
|
protected readonly sessionRepository = $repository(sessions);
|
|
24
|
+
protected readonly realmProvider = $inject(RealmProvider);
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Purge expired sessions from the database.
|
|
26
28
|
*
|
|
27
|
-
* Runs hourly (at :00) and deletes
|
|
29
|
+
* Runs hourly (at :00) and deletes:
|
|
30
|
+
* - sessions whose absolute `expiresAt` has passed
|
|
31
|
+
* - sessions whose `lastUsedAt` exceeds the realm's `refreshToken.expirationIdle`
|
|
32
|
+
* (when configured). Falls back to `createdAt` for sessions without a
|
|
33
|
+
* recorded `lastUsedAt`.
|
|
34
|
+
*
|
|
35
|
+
* The idle sweep is best-effort cleanup — runtime enforcement happens in
|
|
36
|
+
* `SessionService.refreshSession()`.
|
|
28
37
|
*/
|
|
29
38
|
public readonly purgeExpiredSessions = $job({
|
|
30
39
|
name: "api:users:purgeExpiredSessions",
|
|
@@ -34,28 +43,46 @@ export class UserJobs {
|
|
|
34
43
|
|
|
35
44
|
this.log.info("Starting expired sessions purge", { cutoffTime: now });
|
|
36
45
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
expiresAt: { lt: now },
|
|
40
|
-
},
|
|
46
|
+
const absoluteDeletedIds = await this.sessionRepository.deleteMany({
|
|
47
|
+
expiresAt: { lt: now },
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
if (
|
|
44
|
-
this.log.info("
|
|
45
|
-
|
|
50
|
+
if (absoluteDeletedIds.length > 0) {
|
|
51
|
+
this.log.info("Expired sessions purged (absolute)", {
|
|
52
|
+
deletedCount: absoluteDeletedIds.length,
|
|
53
|
+
});
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
// Idle sweep — only if the default realm has expirationIdle configured.
|
|
57
|
+
// Multi-realm setups with per-realm session tables should add their own
|
|
58
|
+
// job; this default job sweeps the default sessions table.
|
|
59
|
+
const realm = this.realmProvider.getRealm();
|
|
60
|
+
const settings = await realm.getSettings();
|
|
61
|
+
const idleMs = settings.refreshToken?.expirationIdle;
|
|
62
|
+
if (idleMs && idleMs > 0) {
|
|
63
|
+
const cutoff = this.dateTimeProvider
|
|
64
|
+
.now()
|
|
65
|
+
.subtract(idleMs, "milliseconds")
|
|
66
|
+
.toISOString();
|
|
51
67
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
// Two passes: rows with an explicit lastUsedAt, and pre-migration rows
|
|
69
|
+
// where lastUsedAt is null — those fall back to createdAt.
|
|
70
|
+
const lastUsedDeletedIds = await this.sessionRepository.deleteMany({
|
|
71
|
+
lastUsedAt: { lt: cutoff },
|
|
72
|
+
});
|
|
73
|
+
const fallbackDeletedIds = await this.sessionRepository.deleteMany({
|
|
74
|
+
lastUsedAt: { isNull: true },
|
|
75
|
+
createdAt: { lt: cutoff },
|
|
76
|
+
});
|
|
55
77
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
const idleTotal = lastUsedDeletedIds.length + fallbackDeletedIds.length;
|
|
79
|
+
if (idleTotal > 0) {
|
|
80
|
+
this.log.info("Expired sessions purged (idle)", {
|
|
81
|
+
deletedCount: idleTotal,
|
|
82
|
+
thresholdMs: idleMs,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
59
86
|
},
|
|
60
87
|
});
|
|
61
88
|
}
|
|
@@ -71,6 +71,10 @@ export class RealmProvider {
|
|
|
71
71
|
...realmAuthSettingsAtom.options.default.loginRateLimit,
|
|
72
72
|
...realmOptions.settings?.loginRateLimit,
|
|
73
73
|
},
|
|
74
|
+
refreshToken: {
|
|
75
|
+
...realmAuthSettingsAtom.options.default.refreshToken,
|
|
76
|
+
...realmOptions.settings?.refreshToken,
|
|
77
|
+
},
|
|
74
78
|
},
|
|
75
79
|
features,
|
|
76
80
|
getSettings: async function () {
|
|
@@ -523,6 +523,7 @@ export class SessionService {
|
|
|
523
523
|
const session = await this.sessions(userRealmName).create({
|
|
524
524
|
userId: user.id,
|
|
525
525
|
expiresAt,
|
|
526
|
+
lastUsedAt: this.dateTimeProvider.nowISOString(),
|
|
526
527
|
ip: request?.ip,
|
|
527
528
|
userAgent: request?.userAgent,
|
|
528
529
|
refreshToken,
|
|
@@ -563,6 +564,27 @@ export class SessionService {
|
|
|
563
564
|
throw new UnauthorizedError("Session expired");
|
|
564
565
|
}
|
|
565
566
|
|
|
567
|
+
// Idle timeout check — opt-in via realm settings.
|
|
568
|
+
// Falls back to createdAt when lastUsedAt is null (pre-migration rows or
|
|
569
|
+
// sessions that never refreshed since the column was introduced).
|
|
570
|
+
const realm = this.realmProvider.getRealm(userRealmName);
|
|
571
|
+
const settings = await realm.getSettings();
|
|
572
|
+
const idleMs = settings.refreshToken?.expirationIdle;
|
|
573
|
+
if (idleMs && idleMs > 0) {
|
|
574
|
+
const lastUsedRef = session.lastUsedAt ?? session.createdAt;
|
|
575
|
+
const idleSince = now.diff(this.dateTimeProvider.of(lastUsedRef));
|
|
576
|
+
if (idleSince > idleMs) {
|
|
577
|
+
this.log.info("Session expired (idle timeout)", {
|
|
578
|
+
sessionId: session.id,
|
|
579
|
+
userId: session.userId,
|
|
580
|
+
idleMs: idleSince,
|
|
581
|
+
thresholdMs: idleMs,
|
|
582
|
+
});
|
|
583
|
+
await this.sessions(userRealmName).deleteById(session.id);
|
|
584
|
+
throw new UnauthorizedError("Session expired");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
566
588
|
const user = await this.users(userRealmName).getOne({
|
|
567
589
|
where: {
|
|
568
590
|
id: { eq: session.userId },
|
|
@@ -582,6 +604,11 @@ export class SessionService {
|
|
|
582
604
|
// Auto-promote to admin if configured (handles "I promote you admin" case)
|
|
583
605
|
await this.ensureAdminRole(user, userRealmName);
|
|
584
606
|
|
|
607
|
+
// Update lastUsedAt — sliding-window for idle timeout enforcement.
|
|
608
|
+
await this.sessions(userRealmName).updateById(session.id, {
|
|
609
|
+
lastUsedAt: now.toISOString(),
|
|
610
|
+
});
|
|
611
|
+
|
|
585
612
|
this.log.debug("Session refreshed", {
|
|
586
613
|
sessionId: session.id,
|
|
587
614
|
userId: session.userId,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { describe, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
AlephaBucket,
|
|
5
|
+
FileStorageProvider,
|
|
6
|
+
NodeS3BucketProvider,
|
|
7
|
+
} from "../index.ts";
|
|
8
|
+
import {
|
|
9
|
+
TestApp,
|
|
10
|
+
testCustomFileId,
|
|
11
|
+
testDeleteFile,
|
|
12
|
+
testDeleteNonExistentFile,
|
|
13
|
+
testDownloadAndMetadata,
|
|
14
|
+
testEmptyFiles,
|
|
15
|
+
testFileExistence,
|
|
16
|
+
testFileStream,
|
|
17
|
+
testNonExistentFile,
|
|
18
|
+
testNonExistentFileError,
|
|
19
|
+
testUploadAndExistence,
|
|
20
|
+
testUploadIntoBuckets,
|
|
21
|
+
} from "./shared.ts";
|
|
22
|
+
|
|
23
|
+
const alepha = Alepha.create()
|
|
24
|
+
.with({ provide: FileStorageProvider, use: NodeS3BucketProvider })
|
|
25
|
+
.with(AlephaBucket)
|
|
26
|
+
.with(TestApp);
|
|
27
|
+
|
|
28
|
+
const provider = alepha.inject(NodeS3BucketProvider);
|
|
29
|
+
|
|
30
|
+
describe("NodeS3BucketProvider", () => {
|
|
31
|
+
test("should upload a file and return a fileId", async () => {
|
|
32
|
+
await testUploadAndExistence(provider);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("should download a file and restore its metadata", async () => {
|
|
36
|
+
await testDownloadAndMetadata(provider);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("exists() should return false for a non-existent file", async () => {
|
|
40
|
+
await testNonExistentFile(provider);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("exists() should return true for an existing file", async () => {
|
|
44
|
+
await testFileExistence(provider);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("should delete a file", async () => {
|
|
48
|
+
await testDeleteFile(provider);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("delete() should not throw for a non-existent file", async () => {
|
|
52
|
+
await testDeleteNonExistentFile(provider);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("download() should throw FileNotFoundError for a non-existent file", async () => {
|
|
56
|
+
await testNonExistentFileError(provider);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("should handle uploading to different buckets", async () => {
|
|
60
|
+
await testUploadIntoBuckets(provider);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("should handle empty files correctly", async () => {
|
|
64
|
+
await testEmptyFiles(provider);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("should be able to upload with a specific fileId", async () => {
|
|
68
|
+
await testCustomFileId(provider);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("should be able to upload, stream with metadata", async () => {
|
|
72
|
+
await testFileStream(provider);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/bucket/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { FileStorageProvider } from "./providers/FileStorageProvider.ts";
|
|
8
8
|
import { LocalFileStorageProvider } from "./providers/LocalFileStorageProvider.ts";
|
|
9
9
|
import { MemoryFileStorageProvider } from "./providers/MemoryFileStorageProvider.ts";
|
|
10
|
+
import { NodeS3BucketProvider } from "./providers/NodeS3BucketProvider.ts";
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
12
13
|
|
|
@@ -16,6 +17,7 @@ export * from "./providers/CloudflareR2Provider.ts";
|
|
|
16
17
|
export * from "./providers/FileStorageProvider.ts";
|
|
17
18
|
export * from "./providers/LocalFileStorageProvider.ts";
|
|
18
19
|
export * from "./providers/MemoryFileStorageProvider.ts";
|
|
20
|
+
export * from "./providers/NodeS3BucketProvider.ts";
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
21
23
|
|
|
@@ -38,6 +40,14 @@ declare module "alepha" {
|
|
|
38
40
|
id: string;
|
|
39
41
|
bucket: BucketPrimitive;
|
|
40
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Triggered when a file is downloaded from a bucket.
|
|
45
|
+
*/
|
|
46
|
+
"bucket:file:downloaded": {
|
|
47
|
+
id: string;
|
|
48
|
+
file: FileLike;
|
|
49
|
+
bucket: BucketPrimitive;
|
|
50
|
+
};
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
|
|
@@ -61,15 +71,22 @@ export const AlephaBucket = $module({
|
|
|
61
71
|
name: "alepha.bucket",
|
|
62
72
|
primitives: [$bucket],
|
|
63
73
|
services: [FileStorageProvider],
|
|
64
|
-
variants: [
|
|
74
|
+
variants: [
|
|
75
|
+
MemoryFileStorageProvider,
|
|
76
|
+
LocalFileStorageProvider,
|
|
77
|
+
NodeS3BucketProvider,
|
|
78
|
+
],
|
|
65
79
|
register: (alepha) => {
|
|
80
|
+
const useS3 = !!alepha.env.S3_ENDPOINT;
|
|
66
81
|
alepha.with({
|
|
67
82
|
optional: true,
|
|
68
83
|
provide: FileStorageProvider,
|
|
69
84
|
use:
|
|
70
85
|
alepha.isTest() || alepha.isServerless()
|
|
71
86
|
? MemoryFileStorageProvider
|
|
72
|
-
:
|
|
87
|
+
: useS3
|
|
88
|
+
? NodeS3BucketProvider
|
|
89
|
+
: LocalFileStorageProvider,
|
|
73
90
|
});
|
|
74
91
|
},
|
|
75
92
|
});
|
|
@@ -288,7 +288,15 @@ export class BucketPrimitive extends Primitive<BucketPrimitiveOptions> {
|
|
|
288
288
|
* Downloads a file from the bucket.
|
|
289
289
|
*/
|
|
290
290
|
public async download(fileId: string): Promise<FileLike> {
|
|
291
|
-
|
|
291
|
+
const file = await this.provider.download(this.name, fileId);
|
|
292
|
+
|
|
293
|
+
await this.alepha.events.emit("bucket:file:downloaded", {
|
|
294
|
+
id: fileId,
|
|
295
|
+
bucket: this,
|
|
296
|
+
file,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return file;
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
protected $provider() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { R2Bucket } from "@cloudflare/workers-types";
|
|
1
2
|
import {
|
|
2
3
|
$env,
|
|
3
4
|
$hook,
|
|
@@ -14,142 +15,6 @@ import type { FileStorageProvider } from "./FileStorageProvider.ts";
|
|
|
14
15
|
|
|
15
16
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
* R2Bucket interface matching Cloudflare's R2 API.
|
|
19
|
-
*/
|
|
20
|
-
export interface R2Bucket {
|
|
21
|
-
put(
|
|
22
|
-
key: string,
|
|
23
|
-
value:
|
|
24
|
-
| ReadableStream
|
|
25
|
-
| ArrayBuffer
|
|
26
|
-
| ArrayBufferView
|
|
27
|
-
| string
|
|
28
|
-
| Blob
|
|
29
|
-
| null,
|
|
30
|
-
options?: R2PutOptions,
|
|
31
|
-
): Promise<R2Object | null>;
|
|
32
|
-
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>;
|
|
33
|
-
head(key: string): Promise<R2Object | null>;
|
|
34
|
-
delete(keys: string | string[]): Promise<void>;
|
|
35
|
-
list(options?: R2ListOptions): Promise<R2Objects>;
|
|
36
|
-
createMultipartUpload(
|
|
37
|
-
key: string,
|
|
38
|
-
options?: R2MultipartOptions,
|
|
39
|
-
): Promise<R2MultipartUpload>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface R2Object {
|
|
43
|
-
key: string;
|
|
44
|
-
version: string;
|
|
45
|
-
size: number;
|
|
46
|
-
etag: string;
|
|
47
|
-
httpEtag: string;
|
|
48
|
-
checksums: R2Checksums;
|
|
49
|
-
uploaded: Date;
|
|
50
|
-
httpMetadata?: R2HTTPMetadata;
|
|
51
|
-
customMetadata?: Record<string, string>;
|
|
52
|
-
range?: R2Range;
|
|
53
|
-
storageClass: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface R2ObjectBody extends R2Object {
|
|
57
|
-
body: ReadableStream;
|
|
58
|
-
bodyUsed: boolean;
|
|
59
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
60
|
-
text(): Promise<string>;
|
|
61
|
-
json<T>(): Promise<T>;
|
|
62
|
-
blob(): Promise<Blob>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface R2PutOptions {
|
|
66
|
-
onlyIf?: R2Conditional;
|
|
67
|
-
httpMetadata?: R2HTTPMetadata;
|
|
68
|
-
customMetadata?: Record<string, string>;
|
|
69
|
-
md5?: ArrayBuffer | string;
|
|
70
|
-
sha1?: ArrayBuffer | string;
|
|
71
|
-
sha256?: ArrayBuffer | string;
|
|
72
|
-
sha384?: ArrayBuffer | string;
|
|
73
|
-
sha512?: ArrayBuffer | string;
|
|
74
|
-
storageClass?: string;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface R2GetOptions {
|
|
78
|
-
onlyIf?: R2Conditional;
|
|
79
|
-
range?: R2Range;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface R2ListOptions {
|
|
83
|
-
limit?: number;
|
|
84
|
-
prefix?: string;
|
|
85
|
-
cursor?: string;
|
|
86
|
-
delimiter?: string;
|
|
87
|
-
startAfter?: string;
|
|
88
|
-
include?: ("httpMetadata" | "customMetadata")[];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface R2Objects {
|
|
92
|
-
objects: R2Object[];
|
|
93
|
-
truncated: boolean;
|
|
94
|
-
cursor?: string;
|
|
95
|
-
delimitedPrefixes: string[];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface R2Checksums {
|
|
99
|
-
md5?: ArrayBuffer;
|
|
100
|
-
sha1?: ArrayBuffer;
|
|
101
|
-
sha256?: ArrayBuffer;
|
|
102
|
-
sha384?: ArrayBuffer;
|
|
103
|
-
sha512?: ArrayBuffer;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface R2HTTPMetadata {
|
|
107
|
-
contentType?: string;
|
|
108
|
-
contentLanguage?: string;
|
|
109
|
-
contentDisposition?: string;
|
|
110
|
-
contentEncoding?: string;
|
|
111
|
-
cacheControl?: string;
|
|
112
|
-
cacheExpiry?: Date;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export interface R2Conditional {
|
|
116
|
-
etagMatches?: string;
|
|
117
|
-
etagDoesNotMatch?: string;
|
|
118
|
-
uploadedBefore?: Date;
|
|
119
|
-
uploadedAfter?: Date;
|
|
120
|
-
secondsGranularity?: boolean;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface R2Range {
|
|
124
|
-
offset?: number;
|
|
125
|
-
length?: number;
|
|
126
|
-
suffix?: number;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export interface R2MultipartOptions {
|
|
130
|
-
httpMetadata?: R2HTTPMetadata;
|
|
131
|
-
customMetadata?: Record<string, string>;
|
|
132
|
-
storageClass?: string;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export interface R2MultipartUpload {
|
|
136
|
-
key: string;
|
|
137
|
-
uploadId: string;
|
|
138
|
-
uploadPart(
|
|
139
|
-
partNumber: number,
|
|
140
|
-
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,
|
|
141
|
-
): Promise<R2UploadedPart>;
|
|
142
|
-
abort(): Promise<void>;
|
|
143
|
-
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export interface R2UploadedPart {
|
|
147
|
-
partNumber: number;
|
|
148
|
-
etag: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ---------------------------------------------------------------------------------------------------------------------
|
|
152
|
-
|
|
153
18
|
/**
|
|
154
19
|
* Cloudflare R2 storage provider.
|
|
155
20
|
*
|
|
@@ -311,7 +176,7 @@ export class CloudflareR2Provider implements FileStorageProvider {
|
|
|
311
176
|
type: contentType,
|
|
312
177
|
size: object.size,
|
|
313
178
|
lastModified: object.uploaded.getTime(),
|
|
314
|
-
stream: () => object.body,
|
|
179
|
+
stream: () => object.body as unknown as ReadableStream,
|
|
315
180
|
arrayBuffer: () => object.arrayBuffer(),
|
|
316
181
|
text: () => object.text(),
|
|
317
182
|
};
|