@podosoft/podokit 1.0.2 → 1.1.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/README.md +74 -6
- package/dist/add.d.ts +14 -1
- package/dist/add.js +42 -5
- package/dist/create.d.ts +4 -0
- package/dist/create.js +9 -0
- package/dist/deploy-compose-profile.d.ts +3 -0
- package/dist/deploy-compose-profile.js +74 -8
- package/dist/deploy-compose-render.d.ts +1 -0
- package/dist/deploy-compose-render.js +46 -1
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.js +18 -8
- package/dist/deploy.js +157 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +41 -0
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +8 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +22 -4
- package/dist/provider.d.ts +24 -0
- package/dist/provider.js +142 -0
- package/dist/remove.js +6 -3
- package/dist/templates/base/AGENTS.md +9 -3
- package/dist/templates/base/README.md +12 -8
- package/dist/templates/fullstack/AGENTS.md +24 -5
- package/dist/templates/fullstack/README.md +75 -11
- package/dist/templates/fullstack/apps/api/package.json +4 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
- package/dist/templates/fullstack/compose.dev.yaml +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-env.example +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +88 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +123 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +24 -6
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
- package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
- package/dist/templates/modules/auth/module.manifest.json +6 -6
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
- package/dist/templates/modules/bullmq/module.manifest.json +7 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
- package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
- package/dist/templates/modules/events-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
- package/dist/templates/modules/events-redis/module.manifest.json +32 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/file-upload/module.manifest.json +4 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
- package/dist/templates/modules/job-progress/module.manifest.json +7 -8
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
- package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
- package/dist/templates/modules/mailer/module.manifest.json +2 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
- package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
- package/dist/templates/modules/redis/module.manifest.json +2 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
- package/dist/templates/modules/sse/module.manifest.json +4 -6
- package/dist/templates/todo/AGENTS.md +25 -6
- package/dist/templates/todo/README.md +72 -14
- package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
- package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
- package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
- package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
- package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
- package/package.json +2 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
- package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type { MigrationInterface, QueryRunner } from "typeorm";
|
|
6
|
+
|
|
7
|
+
export interface SqliteMigrationDefinition {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
migration: MigrationInterface;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AppliedMigration {
|
|
13
|
+
id: number;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
name: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type MigrationConstructor = new () => MigrationInterface;
|
|
19
|
+
|
|
20
|
+
function isMigration(value: unknown): value is MigrationConstructor {
|
|
21
|
+
if (typeof value !== "function") return false;
|
|
22
|
+
const prototype = (value as { prototype?: unknown }).prototype;
|
|
23
|
+
if (!prototype || typeof prototype !== "object") return false;
|
|
24
|
+
const record = prototype as Record<string, unknown>;
|
|
25
|
+
return typeof record.up === "function" && typeof record.down === "function";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function discoverMigrations(): Promise<SqliteMigrationDefinition[]> {
|
|
29
|
+
const compiled = join(process.cwd(), "dist", "migrations");
|
|
30
|
+
const source = join(process.cwd(), "src", "migrations");
|
|
31
|
+
const directory = process.env.NODE_ENV === "production" && existsSync(compiled)
|
|
32
|
+
? compiled
|
|
33
|
+
: source;
|
|
34
|
+
if (!existsSync(directory)) return [];
|
|
35
|
+
const files = readdirSync(directory)
|
|
36
|
+
.filter((file) => /^\d+-.+\.(?:js|ts)$/.test(file))
|
|
37
|
+
.sort();
|
|
38
|
+
const definitions: SqliteMigrationDefinition[] = [];
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const loaded = await import(pathToFileURL(join(directory, file)).href) as Record<string, unknown>;
|
|
41
|
+
const constructors = Object.values(loaded).filter(isMigration);
|
|
42
|
+
if (constructors.length !== 1) {
|
|
43
|
+
throw new Error(`SQLite migration ${file} must export exactly one migration class`);
|
|
44
|
+
}
|
|
45
|
+
const timestamp = Number(file.slice(0, file.indexOf("-")));
|
|
46
|
+
if (!Number.isSafeInteger(timestamp)) throw new Error(`SQLite migration ${file} has an invalid timestamp`);
|
|
47
|
+
const Constructor = constructors[0];
|
|
48
|
+
if (!Constructor) throw new Error(`SQLite migration ${file} has no migration class`);
|
|
49
|
+
definitions.push({ timestamp, migration: new Constructor() });
|
|
50
|
+
}
|
|
51
|
+
return definitions;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function queryRunner(database: SqliteDatabase): QueryRunner {
|
|
55
|
+
return {
|
|
56
|
+
connection: { options: { type: "better-sqlite3" } },
|
|
57
|
+
query: (sql: string): Promise<unknown> => Promise.resolve(database.run(sql)),
|
|
58
|
+
} as unknown as QueryRunner;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function prepare(database: SqliteDatabase): void {
|
|
62
|
+
database.run("PRAGMA journal_mode = WAL");
|
|
63
|
+
database.run("PRAGMA foreign_keys = ON");
|
|
64
|
+
database.run("PRAGMA busy_timeout = 5000");
|
|
65
|
+
database.run(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS "migrations" (
|
|
67
|
+
"id" integer PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
"timestamp" bigint NOT NULL,
|
|
69
|
+
"name" text NOT NULL UNIQUE
|
|
70
|
+
)
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function migrationName(migration: MigrationInterface): string {
|
|
75
|
+
if (typeof migration.name !== "string" || migration.name.length === 0) {
|
|
76
|
+
throw new Error("SQLite migration classes must declare a non-empty name");
|
|
77
|
+
}
|
|
78
|
+
return migration.name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runSqliteMigrations(
|
|
82
|
+
database: SqliteDatabase,
|
|
83
|
+
supplied?: readonly SqliteMigrationDefinition[],
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
prepare(database);
|
|
86
|
+
const definitions = supplied ? [...supplied] : await discoverMigrations();
|
|
87
|
+
definitions.sort((left, right) => left.timestamp - right.timestamp);
|
|
88
|
+
const applied = new Set(
|
|
89
|
+
database.query<Pick<AppliedMigration, "name">, []>(
|
|
90
|
+
'SELECT "name" FROM "migrations"',
|
|
91
|
+
).all().map((row) => row.name),
|
|
92
|
+
);
|
|
93
|
+
const runner = queryRunner(database);
|
|
94
|
+
for (const definition of definitions) {
|
|
95
|
+
const name = migrationName(definition.migration);
|
|
96
|
+
if (applied.has(name)) continue;
|
|
97
|
+
database.run("BEGIN IMMEDIATE");
|
|
98
|
+
try {
|
|
99
|
+
await definition.migration.up(runner);
|
|
100
|
+
database.query(
|
|
101
|
+
'INSERT INTO "migrations" ("timestamp", "name") VALUES (?1, ?2)',
|
|
102
|
+
).run(definition.timestamp, name);
|
|
103
|
+
database.run("COMMIT");
|
|
104
|
+
} catch (error) {
|
|
105
|
+
database.run("ROLLBACK");
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function revertLatestSqliteMigration(
|
|
112
|
+
database: SqliteDatabase,
|
|
113
|
+
supplied?: readonly SqliteMigrationDefinition[],
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
prepare(database);
|
|
116
|
+
const latest = database.query<AppliedMigration, []>(
|
|
117
|
+
'SELECT "id", "timestamp", "name" FROM "migrations" ORDER BY "id" DESC LIMIT 1',
|
|
118
|
+
).get();
|
|
119
|
+
if (!latest) return false;
|
|
120
|
+
const definitions = supplied ? [...supplied] : await discoverMigrations();
|
|
121
|
+
const definition = definitions.find((item) => migrationName(item.migration) === latest.name);
|
|
122
|
+
if (!definition) throw new Error(`Cannot find SQLite migration ${latest.name} to revert`);
|
|
123
|
+
const runner = queryRunner(database);
|
|
124
|
+
database.run("BEGIN IMMEDIATE");
|
|
125
|
+
try {
|
|
126
|
+
await definition.migration.down(runner);
|
|
127
|
+
database.query('DELETE FROM "migrations" WHERE "id" = ?1').run(latest.id);
|
|
128
|
+
database.run("COMMIT");
|
|
129
|
+
return true;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
database.run("ROLLBACK");
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -102,7 +102,7 @@ services:
|
|
|
102
102
|
|
|
103
103
|
minio:
|
|
104
104
|
profiles: [storage]
|
|
105
|
-
image:
|
|
105
|
+
image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z
|
|
106
106
|
command: server /data --console-address ":9001"
|
|
107
107
|
environment:
|
|
108
108
|
MINIO_ROOT_USER: podokit
|
|
@@ -113,7 +113,7 @@ services:
|
|
|
113
113
|
|
|
114
114
|
minio-init:
|
|
115
115
|
profiles: [storage]
|
|
116
|
-
image:
|
|
116
|
+
image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z
|
|
117
117
|
depends_on:
|
|
118
118
|
minio: { condition: service_started }
|
|
119
119
|
entrypoint:
|
|
@@ -60,9 +60,14 @@ it at a different host invalidates the profile instead of deploying somewhere el
|
|
|
60
60
|
The fields that differ from the Kubernetes driver:
|
|
61
61
|
|
|
62
62
|
- `target`: Docker context, endpoint fingerprint, Compose project name
|
|
63
|
-
- `exposure`: `bindAddress` and `port
|
|
64
|
-
`
|
|
65
|
-
|
|
63
|
+
- `exposure`: `bindAddress` and `port`, plus an exact `webSocketPaths` allowlist,
|
|
64
|
+
`trustedProxyCidrs`, and `gatewayImage`. With an empty allowlist, web owns the
|
|
65
|
+
published port. With paths, an internal gateway owns it, sends only exact listed
|
|
66
|
+
paths to API, and sends every other request to web. Forwarding headers are trusted
|
|
67
|
+
only from the direct proxy CIDRs in `trustedProxyCidrs`; leave it empty without an
|
|
68
|
+
external proxy. The bind address defaults to `127.0.0.1`, because the expected
|
|
69
|
+
front door is a trusted reverse proxy — binding every interface publishes the app
|
|
70
|
+
before TLS exists.
|
|
66
71
|
- `workloads`: replicas plus Compose `cpus` and `memory` limits
|
|
67
72
|
- `dependencies`: `managed`, `external`, or `disabled`, each with a named Docker
|
|
68
73
|
volume and the path of its env file on the target host
|
|
@@ -86,3 +91,5 @@ Required keys:
|
|
|
86
91
|
Data volumes are declared `external`, so `docker compose down -v` cannot delete the
|
|
87
92
|
database. Editing an env file changes the containers' rollout label, so the next
|
|
88
93
|
apply actually restarts them instead of leaving stale environment values running.
|
|
94
|
+
The gateway image is resolved to an immutable digest during planning. The external
|
|
95
|
+
TLS proxy keeps one upstream; do not duplicate the WebSocket path split there.
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
NODE_ENV=development
|
|
5
5
|
PORT=5002
|
|
6
6
|
|
|
7
|
+
# Canonical database connection. Set this for PostgreSQL or SQLite; POSTGRES_* remains a fallback.
|
|
8
|
+
# DATABASE_URL=postgres://podokit:podokit@localhost:5432/podokit
|
|
7
9
|
POSTGRES_HOST=localhost
|
|
8
10
|
POSTGRES_PORT=5432
|
|
9
11
|
POSTGRES_USER=podokit
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
PROFILE_IMAGE_REQUIRED,
|
|
4
4
|
PROFILE_IMAGE_TOO_LARGE,
|
|
5
5
|
} from "@podosoft/podokit-contracts";
|
|
6
|
+
import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
|
|
6
7
|
import { Elysia, t } from "elysia";
|
|
7
8
|
import { AUTH } from "./auth/auth.module";
|
|
8
9
|
import {
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
SiteSettingsService,
|
|
21
22
|
} from "./site-settings/site-settings.service";
|
|
22
23
|
import { validateSiteSetting } from "./site-settings/site-settings.validation";
|
|
23
|
-
import { STORAGE } from "./storage/storage.module";
|
|
24
24
|
|
|
25
25
|
export const SITE_SETTINGS = Symbol("site-settings") as ServiceKey<SiteSettingsService>;
|
|
26
26
|
export const PROFILE_IMAGES = Symbol("profile-images") as ServiceKey<ProfileImageService>;
|
|
@@ -138,7 +138,7 @@ const adminPlugin: AppPlugin = ({ services }) => {
|
|
|
138
138
|
export const adminModule: PodokitModule = {
|
|
139
139
|
name: "admin-dashboard",
|
|
140
140
|
configure: (_env, services): void => {
|
|
141
|
-
const storage = services.resolve(
|
|
141
|
+
const storage = services.resolve(OBJECT_STORAGE);
|
|
142
142
|
const site = new SiteSettingsService(services.resolve(DATABASE).sql, storage);
|
|
143
143
|
const profileImages = new ProfileImageService(storage, services.resolve(LOGGER));
|
|
144
144
|
services.register(SITE_SETTINGS, site);
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import type { Logger } from "pino";
|
|
3
|
+
import type { ObjectStore } from "@podosoft/podokit-runtime";
|
|
3
4
|
import { runUserDeletedHandlers } from "../auth/user-delete-handlers";
|
|
4
|
-
import { StorageService } from "../storage/storage.service";
|
|
5
5
|
import { ProfileImageService } from "./profile-image.service";
|
|
6
6
|
|
|
7
7
|
describe("ProfileImageService account deletion cleanup", () => {
|
|
8
8
|
const storage = {
|
|
9
9
|
put: mock(async () => undefined),
|
|
10
|
-
get: mock(async () =>
|
|
11
|
-
|
|
10
|
+
get: mock(async () => ({
|
|
11
|
+
key: "profile-images/example.webp",
|
|
12
|
+
size: 0,
|
|
13
|
+
body: (async function* (): AsyncGenerator<Uint8Array> { yield Buffer.alloc(0); })(),
|
|
14
|
+
})),
|
|
15
|
+
exists: mock(async () => true),
|
|
16
|
+
getDownloadUrl: mock(async () => "https://example.com/image"),
|
|
12
17
|
delete: mock(async () => undefined),
|
|
18
|
+
close: mock(() => undefined),
|
|
13
19
|
};
|
|
14
20
|
const logger = { warn: mock(() => undefined) } as unknown as Logger;
|
|
15
|
-
const service = new ProfileImageService(storage as unknown as
|
|
21
|
+
const service = new ProfileImageService(storage as unknown as ObjectStore, logger);
|
|
16
22
|
|
|
17
23
|
service.connect();
|
|
18
24
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Logger } from "pino";
|
|
3
|
+
import type { ObjectData, ObjectStore } from "@podosoft/podokit-runtime";
|
|
3
4
|
import {
|
|
4
5
|
AppException,
|
|
5
6
|
PROFILE_IMAGE_NOT_FOUND,
|
|
@@ -7,7 +8,6 @@ import {
|
|
|
7
8
|
} from "@podosoft/podokit-contracts";
|
|
8
9
|
import { getAuth } from "../auth/auth-provider";
|
|
9
10
|
import { registerUserDeletedHandler } from "../auth/user-delete-handlers";
|
|
10
|
-
import type { StorageService } from "../storage/storage.service";
|
|
11
11
|
import { validateProfileImage, type ProfileImageUpload } from "./profile-image.validation";
|
|
12
12
|
|
|
13
13
|
const FILE_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(?:jpg|png|webp)$/;
|
|
@@ -33,11 +33,17 @@ export type UpdateAuthUser = (
|
|
|
33
33
|
const updateAuthUser: UpdateAuthUser = (image, headers) =>
|
|
34
34
|
getAuth().api.updateUser({ body: { image }, headers });
|
|
35
35
|
|
|
36
|
+
async function objectBuffer(object: ObjectData): Promise<Buffer> {
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
for await (const chunk of object.body) chunks.push(chunk);
|
|
39
|
+
return Buffer.concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
export class ProfileImageService {
|
|
37
43
|
private unregisterUserDeleted?: () => void;
|
|
38
44
|
|
|
39
45
|
constructor(
|
|
40
|
-
private readonly storage:
|
|
46
|
+
private readonly storage: ObjectStore,
|
|
41
47
|
private readonly logger: Logger,
|
|
42
48
|
private readonly updateUser: UpdateAuthUser = updateAuthUser,
|
|
43
49
|
) {}
|
|
@@ -63,7 +69,7 @@ export class ProfileImageService {
|
|
|
63
69
|
const key = STORAGE_PREFIX + fileName;
|
|
64
70
|
const image = PUBLIC_PREFIX + fileName;
|
|
65
71
|
|
|
66
|
-
await this.storage.put(key, file.buffer, metadata.contentType);
|
|
72
|
+
await this.storage.put(key, file.buffer, { contentType: metadata.contentType });
|
|
67
73
|
try {
|
|
68
74
|
await this.updateUser(image, headers);
|
|
69
75
|
} catch (error: unknown) {
|
|
@@ -86,7 +92,7 @@ export class ProfileImageService {
|
|
|
86
92
|
try {
|
|
87
93
|
const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
|
|
88
94
|
return {
|
|
89
|
-
body: await this.storage.get(key),
|
|
95
|
+
body: await objectBuffer(await this.storage.get(key)),
|
|
90
96
|
contentType: CONTENT_TYPES[extension] ?? "application/octet-stream",
|
|
91
97
|
};
|
|
92
98
|
} catch {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ObjectData, ObjectStore } from "@podosoft/podokit-runtime";
|
|
3
3
|
|
|
4
4
|
const PREFIX = "site.";
|
|
5
5
|
const FAVICON_KEY = "site/favicon";
|
|
@@ -31,10 +31,16 @@ export interface SiteFavicon {
|
|
|
31
31
|
contentType: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
async function objectBuffer(object: ObjectData): Promise<Buffer> {
|
|
35
|
+
const chunks: Uint8Array[] = [];
|
|
36
|
+
for await (const chunk of object.body) chunks.push(chunk);
|
|
37
|
+
return Buffer.concat(chunks);
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
export class SiteSettingsService {
|
|
35
41
|
constructor(
|
|
36
42
|
private readonly sql: SQL,
|
|
37
|
-
private readonly storage:
|
|
43
|
+
private readonly storage: ObjectStore,
|
|
38
44
|
) {}
|
|
39
45
|
|
|
40
46
|
async getAll(): Promise<Record<string, string>> {
|
|
@@ -66,7 +72,7 @@ export class SiteSettingsService {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
async setFavicon(body: Uint8Array, contentType: string): Promise<void> {
|
|
69
|
-
await this.storage.put(FAVICON_KEY, body, contentType);
|
|
75
|
+
await this.storage.put(FAVICON_KEY, body, { contentType });
|
|
70
76
|
await this.setMany({
|
|
71
77
|
faviconContentType: contentType,
|
|
72
78
|
faviconUpdatedAt: String(Date.now()),
|
|
@@ -77,7 +83,7 @@ export class SiteSettingsService {
|
|
|
77
83
|
const contentType = await this.get("faviconContentType");
|
|
78
84
|
if (!contentType) return null;
|
|
79
85
|
try {
|
|
80
|
-
return { body: await this.storage.get(FAVICON_KEY), contentType };
|
|
86
|
+
return { body: await objectBuffer(await this.storage.get(FAVICON_KEY)), contentType };
|
|
81
87
|
} catch {
|
|
82
88
|
return null;
|
|
83
89
|
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"manifestVersion":
|
|
2
|
+
"manifestVersion": 3,
|
|
3
3
|
"name": "admin-dashboard",
|
|
4
4
|
"description": "Admin console UI (SvelteKit + shadcn-svelte): the `(admin)` route group and `admin-sidebar.svelte` are reserved for `/admin/*`; put protected product pages in `(app)` or their own guarded layout. Requires auth.",
|
|
5
|
-
"requires": [
|
|
6
|
-
|
|
7
|
-
"object-storage-s3"
|
|
8
|
-
],
|
|
5
|
+
"requires": ["auth"],
|
|
6
|
+
"requiresCapabilities": ["object-storage"],
|
|
9
7
|
"targetApp": "web",
|
|
10
8
|
"dependencies": {
|
|
11
9
|
"qrcode": "^1.5.4"
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
1
|
+
import { authConfigStore } from "../auth/auth-config-store";
|
|
3
2
|
|
|
4
3
|
// Audit logging is an admin-managed, DB-backed server toggle (auth_config `server`
|
|
5
4
|
// row, env fallback AUDIT_LOG_ENABLED), read through the shared config store's
|
|
6
5
|
// short TTL cache — the same mechanism as the other server-enforced toggles.
|
|
7
6
|
// Toggling it on the Settings page takes effect within the cache TTL, no restart.
|
|
8
|
-
const store = createConfigStore(pool);
|
|
9
|
-
|
|
10
7
|
export async function auditEnabled(): Promise<boolean> {
|
|
11
8
|
try {
|
|
12
|
-
return (await
|
|
9
|
+
return (await authConfigStore.capabilitiesSnapshot()).auditLog;
|
|
13
10
|
} catch {
|
|
14
11
|
return true; // never let a config read failure silently drop the audit trail
|
|
15
12
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { QueryRunner } from "typeorm";
|
|
3
|
+
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
4
|
+
|
|
5
|
+
function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
|
|
6
|
+
return {
|
|
7
|
+
connection: { options: { type } },
|
|
8
|
+
query: (sql: string): Promise<unknown[]> => {
|
|
9
|
+
queries.push(sql);
|
|
10
|
+
return Promise.resolve([]);
|
|
11
|
+
},
|
|
12
|
+
} as unknown as QueryRunner;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("audit log migration", () => {
|
|
16
|
+
it("retains PostgreSQL UUID and JSONB storage", async () => {
|
|
17
|
+
const queries: string[] = [];
|
|
18
|
+
await new InitAuditLogs1720300000000().up(runner("postgres", queries));
|
|
19
|
+
expect(queries[0]).toContain("CREATE EXTENSION");
|
|
20
|
+
expect(queries[1]).toContain("uuid NOT NULL DEFAULT gen_random_uuid()");
|
|
21
|
+
expect(queries[1]).toContain("jsonb");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses portable SQLite column types and defaults", async () => {
|
|
25
|
+
const queries: string[] = [];
|
|
26
|
+
await new InitAuditLogs1720300000000().up(runner("better-sqlite3", queries));
|
|
27
|
+
expect(queries).toHaveLength(3);
|
|
28
|
+
expect(queries[0]).toContain('"id" text NOT NULL');
|
|
29
|
+
expect(queries[0]).toContain('"metadata" text');
|
|
30
|
+
expect(queries[0]).toContain("DEFAULT CURRENT_TIMESTAMP");
|
|
31
|
+
expect(queries.join("\n")).not.toContain("CREATE EXTENSION");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -26,6 +26,7 @@ describe("AuditService", () => {
|
|
|
26
26
|
expect(recorder.values).toHaveLength(1);
|
|
27
27
|
expect(recorder.values[0]).toContain("invoice.paid");
|
|
28
28
|
expect(recorder.values[0]).toContain('{"amount":42}');
|
|
29
|
+
expect(recorder.values[0]?.[0]).toBeString();
|
|
29
30
|
} finally {
|
|
30
31
|
service.close();
|
|
31
32
|
}
|
|
@@ -41,6 +42,18 @@ describe("AuditService", () => {
|
|
|
41
42
|
expect(entries[0]?.createdAt).toBe("2026-01-02T03:04:05.000Z");
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
test("normalizes SQLite timestamps and JSON text", async () => {
|
|
46
|
+
const recorder = sqlRecorder([{
|
|
47
|
+
id: "audit-1",
|
|
48
|
+
action: "auth.login",
|
|
49
|
+
metadata: '{"method":"password"}',
|
|
50
|
+
createdAt: "2026-01-02T03:04:05.000Z",
|
|
51
|
+
}]);
|
|
52
|
+
const entries = await new AuditService(recorder.sql).recent();
|
|
53
|
+
expect(entries[0]?.metadata).toEqual({ method: "password" });
|
|
54
|
+
expect(entries[0]?.createdAt).toBe("2026-01-02T03:04:05.000Z");
|
|
55
|
+
});
|
|
56
|
+
|
|
44
57
|
test("does not propagate persistence failures", async () => {
|
|
45
58
|
const sql = mock(() => Promise.reject(new Error("database unavailable"))) as unknown as SQL;
|
|
46
59
|
await expect(new AuditService(sql).record({ action: "auth.login" })).resolves.toBeUndefined();
|
|
@@ -8,9 +8,10 @@ export interface AuditLogView extends AuditEntry {
|
|
|
8
8
|
createdAt: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
createdAt: Date;
|
|
13
|
-
|
|
11
|
+
type AuditLogRow = Omit<AuditLogView, "createdAt" | "metadata"> & {
|
|
12
|
+
createdAt: Date | string;
|
|
13
|
+
metadata: unknown;
|
|
14
|
+
};
|
|
14
15
|
|
|
15
16
|
export interface AuditTarget {
|
|
16
17
|
type?: string;
|
|
@@ -40,13 +41,13 @@ export class AuditService {
|
|
|
40
41
|
: JSON.stringify(entry.metadata);
|
|
41
42
|
await this.sql`
|
|
42
43
|
INSERT INTO "audit_logs" (
|
|
43
|
-
"action", "actorId", "actorName", "actorEmail",
|
|
44
|
-
"targetType", "targetId", "targetLabel", "ip", "metadata"
|
|
44
|
+
"id", "action", "actorId", "actorName", "actorEmail",
|
|
45
|
+
"targetType", "targetId", "targetLabel", "ip", "metadata", "createdAt"
|
|
45
46
|
) VALUES (
|
|
46
|
-
${entry.action}, ${entry.actorId ?? null}, ${entry.actorName ?? null},
|
|
47
|
+
${crypto.randomUUID()}, ${entry.action}, ${entry.actorId ?? null}, ${entry.actorName ?? null},
|
|
47
48
|
${entry.actorEmail ?? null}, ${entry.targetType ?? null},
|
|
48
49
|
${entry.targetId ?? null}, ${entry.targetLabel ?? null}, ${entry.ip ?? null},
|
|
49
|
-
${metadata}
|
|
50
|
+
CAST(${metadata} AS JSON), ${new Date().toISOString()}
|
|
50
51
|
)
|
|
51
52
|
`;
|
|
52
53
|
} catch {
|
|
@@ -62,7 +63,15 @@ export class AuditService {
|
|
|
62
63
|
ORDER BY "createdAt" DESC
|
|
63
64
|
LIMIT ${limit}
|
|
64
65
|
`;
|
|
65
|
-
return rows.map((row) => ({
|
|
66
|
+
return rows.map((row) => ({
|
|
67
|
+
...row,
|
|
68
|
+
metadata: typeof row.metadata === "string"
|
|
69
|
+
? JSON.parse(row.metadata) as Record<string, unknown>
|
|
70
|
+
: row.metadata as Record<string, unknown> | null,
|
|
71
|
+
createdAt: row.createdAt instanceof Date
|
|
72
|
+
? row.createdAt.toISOString()
|
|
73
|
+
: new Date(row.createdAt).toISOString(),
|
|
74
|
+
}));
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
async recordRequest(
|
|
@@ -4,10 +4,11 @@ export class InitAuditLogs1720300000000 implements MigrationInterface {
|
|
|
4
4
|
name = "InitAuditLogs1720300000000";
|
|
5
5
|
|
|
6
6
|
async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
-
|
|
7
|
+
const sqlite = queryRunner.connection.options.type === "better-sqlite3";
|
|
8
|
+
if (!sqlite) await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
|
|
8
9
|
await queryRunner.query(`
|
|
9
10
|
CREATE TABLE "audit_logs" (
|
|
10
|
-
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
|
11
|
+
"id" ${sqlite ? "text" : "uuid"} NOT NULL${sqlite ? "" : " DEFAULT gen_random_uuid()"},
|
|
11
12
|
"action" character varying(128) NOT NULL,
|
|
12
13
|
"actorId" character varying,
|
|
13
14
|
"actorName" character varying,
|
|
@@ -16,8 +17,8 @@ export class InitAuditLogs1720300000000 implements MigrationInterface {
|
|
|
16
17
|
"targetId" character varying,
|
|
17
18
|
"targetLabel" character varying,
|
|
18
19
|
"ip" character varying,
|
|
19
|
-
"metadata" jsonb,
|
|
20
|
-
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
|
20
|
+
"metadata" ${sqlite ? "text" : "jsonb"},
|
|
21
|
+
"createdAt" ${sqlite ? "datetime" : "TIMESTAMP WITH TIME ZONE"} NOT NULL DEFAULT ${sqlite ? "CURRENT_TIMESTAMP" : "now()"},
|
|
21
22
|
CONSTRAINT "PK_audit_logs_id" PRIMARY KEY ("id")
|
|
22
23
|
)
|
|
23
24
|
`);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
migrateLegacyAccountIssuers,
|
|
5
|
+
migrateSqliteLegacyAccountIssuers,
|
|
6
|
+
type AccountIssuerMigrationClient,
|
|
7
|
+
} from "./account-issuer-migration";
|
|
8
|
+
|
|
9
|
+
class AccountClient implements AccountIssuerMigrationClient {
|
|
10
|
+
readonly statements: string[] = [];
|
|
11
|
+
released = false;
|
|
12
|
+
constructor(private readonly table = true, private readonly issuer = true, private readonly collisions = 0) {}
|
|
13
|
+
async query<Row extends Record<string, unknown>>(sql: string): Promise<readonly Row[]> {
|
|
14
|
+
this.statements.push(sql.trim());
|
|
15
|
+
if (sql.includes("to_regclass")) return [{ exists: this.table }] as unknown as Row[];
|
|
16
|
+
if (sql.includes("pg_attribute")) return [{ exists: this.issuer }] as unknown as Row[];
|
|
17
|
+
if (sql.includes("AS collisions")) return [{ count: this.collisions }] as unknown as Row[];
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
release(): void { this.released = true; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function migrate(client: AccountClient): Promise<string> {
|
|
24
|
+
return migrateLegacyAccountIssuers({ connect: async (): Promise<AccountIssuerMigrationClient> => client });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function legacyDatabase(): Database {
|
|
28
|
+
const database = new Database(":memory:");
|
|
29
|
+
database.run('CREATE TABLE account (id text PRIMARY KEY, "providerId" text NOT NULL, "accountId" text NOT NULL, "userId" text NOT NULL, password text, issuer text NOT NULL)');
|
|
30
|
+
database.run('CREATE UNIQUE INDEX "account_issuer_accountId_uidx" ON account (issuer, "accountId")');
|
|
31
|
+
database.run('INSERT INTO account VALUES (\'account\', \'credential\', \'user\', \'user\', \'password-hash\', \'local:credential\')');
|
|
32
|
+
return database;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("Better Auth account identity cleanup", () => {
|
|
36
|
+
it("leaves an absent PostgreSQL table to the auth migrator", async (): Promise<void> => {
|
|
37
|
+
const client = new AccountClient(false);
|
|
38
|
+
expect(await migrate(client)).toBe("absent");
|
|
39
|
+
expect(client.statements.at(-1)).toBe("COMMIT");
|
|
40
|
+
expect(client.released).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
it("relaxes legacy PostgreSQL constraints without rewriting account data", async (): Promise<void> => {
|
|
43
|
+
const client = new AccountClient();
|
|
44
|
+
expect(await migrate(client)).toBe("migrated");
|
|
45
|
+
expect(client.statements.some((sql) => sql.includes('ALTER COLUMN "issuer" DROP NOT NULL'))).toBe(true);
|
|
46
|
+
expect(client.statements.some((sql) => sql.startsWith("UPDATE") || sql.startsWith("DELETE"))).toBe(false);
|
|
47
|
+
expect(client.statements.at(-1)).toBe("COMMIT");
|
|
48
|
+
expect(client.released).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("does not add issuer to a current PostgreSQL schema", async (): Promise<void> => {
|
|
51
|
+
const client = new AccountClient(true, false);
|
|
52
|
+
expect(await migrate(client)).toBe("current");
|
|
53
|
+
expect(client.statements.some((sql) => sql.includes("ADD COLUMN") || sql.includes("ALTER COLUMN"))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it("rolls back PostgreSQL identity collisions before changing constraints", async (): Promise<void> => {
|
|
56
|
+
const client = new AccountClient(true, true, 1);
|
|
57
|
+
await expect(migrate(client)).rejects.toThrow("duplicate providerId and accountId");
|
|
58
|
+
expect(client.statements.at(-1)).toBe("ROLLBACK");
|
|
59
|
+
expect(client.statements.some((sql) => sql.startsWith("ALTER") || sql.startsWith("DROP"))).toBe(false);
|
|
60
|
+
expect(client.released).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it("leaves an absent SQLite table to the auth migrator", (): void => {
|
|
63
|
+
const database = new Database(":memory:");
|
|
64
|
+
try { expect(migrateSqliteLegacyAccountIssuers(database)).toBe("absent"); }
|
|
65
|
+
finally { database.close(); }
|
|
66
|
+
});
|
|
67
|
+
it("preserves SQLite accounts, accepts new rows without issuer, and can rerun", (): void => {
|
|
68
|
+
const database = legacyDatabase();
|
|
69
|
+
try {
|
|
70
|
+
expect(migrateSqliteLegacyAccountIssuers(database)).toBe("migrated");
|
|
71
|
+
expect(database.query('SELECT "userId", password FROM account').get()).toEqual({ userId: "user", password: "password-hash" });
|
|
72
|
+
database.run('INSERT INTO account (id, "providerId", "accountId", "userId") VALUES (\'new\', \'github\', \'user\', \'user\')');
|
|
73
|
+
expect(migrateSqliteLegacyAccountIssuers(database)).toBe("current");
|
|
74
|
+
expect(database.query('SELECT count(*) AS count FROM account').get()).toEqual({ count: 2 });
|
|
75
|
+
expect(() => database.run('INSERT INTO account (id, "providerId", "accountId", "userId") VALUES (\'duplicate\', \'github\', \'user\', \'other\')')).toThrow();
|
|
76
|
+
} finally { database.close(); }
|
|
77
|
+
});
|
|
78
|
+
it("rolls back SQLite collisions without losing rows, issuer, or its index", (): void => {
|
|
79
|
+
const database = legacyDatabase();
|
|
80
|
+
try {
|
|
81
|
+
database.run('INSERT INTO account VALUES (\'collision\', \'credential\', \'user\', \'other\', \'other-hash\', \'other-issuer\')');
|
|
82
|
+
expect(() => migrateSqliteLegacyAccountIssuers(database)).toThrow("duplicate providerId and accountId");
|
|
83
|
+
expect(database.query('SELECT count(*) AS count FROM account').get()).toEqual({ count: 2 });
|
|
84
|
+
expect(database.query('SELECT issuer FROM account WHERE id = \'account\'').get()).toEqual({ issuer: "local:credential" });
|
|
85
|
+
expect(database.query('SELECT name FROM sqlite_master WHERE name = \'account_issuer_accountId_uidx\'').get()).toBeTruthy();
|
|
86
|
+
} finally { database.close(); }
|
|
87
|
+
});
|
|
88
|
+
});
|