@ismaelsoilet/synapsejs 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/synapse.ts +919 -0
- package/package.json +70 -0
- package/src/client/components.tsx +508 -0
- package/src/client/context.tsx +62 -0
- package/src/client/hooks.ts +152 -0
- package/src/client/i18n.ts +40 -0
- package/src/client.ts +66 -0
- package/src/compiler/agent-diagnostic-json.ts +113 -0
- package/src/compiler/ast-daemon-compressor.ts +190 -0
- package/src/compiler/client-bundler.ts +356 -0
- package/src/compiler/context-bench.ts +191 -0
- package/src/compiler/contract.ts +260 -0
- package/src/compiler/db-schema-generator.ts +306 -0
- package/src/compiler/fields-parser.ts +267 -0
- package/src/compiler/impact-analyzer.ts +279 -0
- package/src/compiler/migration-runner.ts +555 -0
- package/src/compiler/oracle-runner.ts +293 -0
- package/src/compiler/scaffolder.ts +528 -0
- package/src/compiler/schema-dag.ts +137 -0
- package/src/compiler/schema-drift.ts +184 -0
- package/src/compiler/slice-discovery.ts +154 -0
- package/src/compiler/slice-splitter.ts +838 -0
- package/src/compiler/slice-templates.ts +1527 -0
- package/src/compiler/standalone-builder.ts +117 -0
- package/src/core/action-context.ts +152 -0
- package/src/core/config.ts +77 -0
- package/src/core/database-client.ts +246 -0
- package/src/core/database-factory.ts +46 -0
- package/src/core/image-optimizer.ts +83 -0
- package/src/core/index.ts +14 -0
- package/src/core/jobs.ts +53 -0
- package/src/core/machine-types.ts +75 -0
- package/src/core/oracle.ts +20 -0
- package/src/core/postgres-client.ts +164 -0
- package/src/core/postgres-queue.ts +288 -0
- package/src/core/query-builder.ts +343 -0
- package/src/core/rpc-client.ts +70 -0
- package/src/core/session-context.ts +87 -0
- package/src/core/session-cookie.ts +81 -0
- package/src/core/session-token.ts +170 -0
- package/src/core/sqlite-client.ts +201 -0
- package/src/core/storage.ts +317 -0
- package/src/index.ts +133 -0
- package/src/mcp/server.ts +553 -0
- package/src/runtime/client-entry.ts +65 -0
- package/src/runtime/discovery-rules.ts +63 -0
- package/src/runtime/event-hub.ts +97 -0
- package/src/runtime/postgres-event-hub.ts +148 -0
- package/src/runtime/queue-engine.ts +255 -0
- package/src/runtime/rate-limiter.ts +152 -0
- package/src/runtime/server.ts +1550 -0
- package/src/runtime/uploads.ts +192 -0
- package/templates/starter/.dockerignore +7 -0
- package/templates/starter/Dockerfile +40 -0
- package/templates/starter/README.md +40 -0
- package/templates/starter/gitignore +6 -0
- package/templates/starter/package.json +34 -0
- package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
- package/templates/starter/tsconfig.json +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Signed Session Tokens
|
|
3
|
+
*
|
|
4
|
+
* The framework does not ship a login screen; it ships the primitive that makes
|
|
5
|
+
* one safe. A slice signs a session (user id plus roles) with a server secret, the
|
|
6
|
+
* browser carries it in the `synapse_token` cookie, and the server derives the
|
|
7
|
+
* session from the signature instead of trusting a header any client can write.
|
|
8
|
+
*
|
|
9
|
+
* Without `SYNAPSE_SESSION_SECRET` the server keeps its development behaviour
|
|
10
|
+
* (roles from a header), which is convenient locally and unsafe in production —
|
|
11
|
+
* that difference is deliberate and documented.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DatabaseClient } from './database-client';
|
|
15
|
+
import { Err, Ok, type Result } from './machine-types';
|
|
16
|
+
|
|
17
|
+
export interface SessionClaims {
|
|
18
|
+
userId: string;
|
|
19
|
+
roles: string[];
|
|
20
|
+
/** Expiry, in seconds since the epoch. */
|
|
21
|
+
exp: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SessionTokenError = 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'TOKEN_REVOKED';
|
|
25
|
+
|
|
26
|
+
const inMemoryRevokedSignatures = new Set<string>();
|
|
27
|
+
|
|
28
|
+
export function revokeSessionToken(token: string): boolean {
|
|
29
|
+
const [, signature] = token.split('.');
|
|
30
|
+
if (!signature) return false;
|
|
31
|
+
inMemoryRevokedSignatures.add(signature);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isSessionTokenRevoked(token: string): boolean {
|
|
36
|
+
const [, signature] = token.split('.');
|
|
37
|
+
if (!signature) return true;
|
|
38
|
+
return inMemoryRevokedSignatures.has(signature);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearSessionTokenRevocations(): void {
|
|
42
|
+
inMemoryRevokedSignatures.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Persists a token revocation into the database blacklist table.
|
|
47
|
+
*/
|
|
48
|
+
export async function revokeSessionTokenInDb(db: DatabaseClient, token: string): Promise<boolean> {
|
|
49
|
+
const [, signature] = token.split('.');
|
|
50
|
+
if (!signature) return false;
|
|
51
|
+
|
|
52
|
+
await db.query(`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS _synapse_session_blacklist (
|
|
54
|
+
token_signature TEXT PRIMARY KEY,
|
|
55
|
+
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
56
|
+
);
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
await db
|
|
60
|
+
.query('INSERT INTO _synapse_session_blacklist (token_signature) VALUES ($1) ON CONFLICT DO NOTHING;', [signature])
|
|
61
|
+
.catch(async () => {
|
|
62
|
+
// SQLite fallback without ON CONFLICT DO NOTHING if needed
|
|
63
|
+
await db
|
|
64
|
+
.query('INSERT OR IGNORE INTO _synapse_session_blacklist (token_signature) VALUES ($1);', [signature])
|
|
65
|
+
.catch(() => {});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Also sync in-memory for instant local lookup
|
|
69
|
+
inMemoryRevokedSignatures.add(signature);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Checks whether a token signature exists in the database blacklist table.
|
|
75
|
+
*/
|
|
76
|
+
export async function isSessionTokenRevokedInDb(db: DatabaseClient, token: string): Promise<boolean> {
|
|
77
|
+
const [, signature] = token.split('.');
|
|
78
|
+
if (!signature) return true;
|
|
79
|
+
|
|
80
|
+
if (inMemoryRevokedSignatures.has(signature)) return true;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const rows = await db.query('SELECT token_signature FROM _synapse_session_blacklist WHERE token_signature = $1;', [
|
|
84
|
+
signature
|
|
85
|
+
]);
|
|
86
|
+
if (rows && rows.length > 0) {
|
|
87
|
+
inMemoryRevokedSignatures.add(signature);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// If table doesn't exist yet, it's not revoked
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toBase64Url(value: Uint8Array): string {
|
|
98
|
+
return Buffer.from(value).toString('base64url');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function encodePayload(payload: string): string {
|
|
102
|
+
return toBase64Url(new TextEncoder().encode(payload));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sign(payload: string, secret: string): string {
|
|
106
|
+
return toBase64Url(new Bun.CryptoHasher('sha256', secret).update(payload).digest());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function constantTimeEquals(left: string, right: string): boolean {
|
|
110
|
+
if (left.length !== right.length) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const a = Buffer.from(left);
|
|
115
|
+
const b = Buffer.from(right);
|
|
116
|
+
|
|
117
|
+
return a.length === b.length && require('node:crypto').timingSafeEqual(a, b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function signSessionToken(
|
|
121
|
+
claims: { userId: string; roles: string[] },
|
|
122
|
+
secret: string,
|
|
123
|
+
maxAgeSeconds = 86_400
|
|
124
|
+
): string {
|
|
125
|
+
const payload = encodePayload(
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
userId: claims.userId,
|
|
128
|
+
roles: claims.roles,
|
|
129
|
+
exp: Math.floor(Date.now() / 1000) + maxAgeSeconds
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return `${payload}.${sign(payload, secret)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function verifySessionToken(
|
|
137
|
+
token: string,
|
|
138
|
+
secret: string,
|
|
139
|
+
options?: { checkRevoked?: boolean }
|
|
140
|
+
): Result<SessionClaims, SessionTokenError> {
|
|
141
|
+
const [payload, provided] = token.split('.');
|
|
142
|
+
if (!payload || !provided) {
|
|
143
|
+
return Err('INVALID_TOKEN');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!constantTimeEquals(sign(payload, secret), provided)) {
|
|
147
|
+
return Err('INVALID_TOKEN');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (options?.checkRevoked !== false && isSessionTokenRevoked(token)) {
|
|
151
|
+
return Err('TOKEN_REVOKED');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let claims: SessionClaims;
|
|
155
|
+
try {
|
|
156
|
+
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')) as SessionClaims;
|
|
157
|
+
} catch {
|
|
158
|
+
return Err('INVALID_TOKEN');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof claims.userId !== 'string' || !Array.isArray(claims.roles) || typeof claims.exp !== 'number') {
|
|
162
|
+
return Err('INVALID_TOKEN');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (claims.exp * 1000 < Date.now()) {
|
|
166
|
+
return Err('EXPIRED_TOKEN');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Ok(claims);
|
|
170
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Native SQLite Database Driver (bun:sqlite)
|
|
3
|
+
*
|
|
4
|
+
* Provides zero-configuration embedded persistence implementing the core
|
|
5
|
+
* DatabaseClient contract, with an explicit prepared-statement cache and
|
|
6
|
+
* correct read/write classification (including CTEs and RETURNING clauses).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Database, type SQLQueryBindings, type Statement } from 'bun:sqlite';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import {
|
|
13
|
+
compileDelete,
|
|
14
|
+
compileInsert,
|
|
15
|
+
compileSelect,
|
|
16
|
+
compileTaggedSql,
|
|
17
|
+
compileUpdate,
|
|
18
|
+
type DatabaseClient,
|
|
19
|
+
nestJoinedRow,
|
|
20
|
+
type QueryOptions,
|
|
21
|
+
type WhereCondition
|
|
22
|
+
} from './database-client';
|
|
23
|
+
|
|
24
|
+
const READ_KEYWORDS = new Set(['SELECT', 'WITH', 'EXPLAIN', 'PRAGMA', 'VALUES']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* First SQL keyword, ignoring leading whitespace and comments.
|
|
28
|
+
*/
|
|
29
|
+
function firstKeyword(sql: string): string {
|
|
30
|
+
const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
|
|
31
|
+
|
|
32
|
+
const match = withoutComments.trim().match(/^[A-Za-z]+/);
|
|
33
|
+
return match ? match[0].toUpperCase() : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SqliteDatabaseClient implements DatabaseClient {
|
|
37
|
+
private db: Database;
|
|
38
|
+
private statements: Map<string, Statement<unknown, SQLQueryBindings[]>> = new Map();
|
|
39
|
+
private maxStatements: number;
|
|
40
|
+
private txLock: Promise<void> = Promise.resolve();
|
|
41
|
+
|
|
42
|
+
constructor(filePath?: string, maxStatements = 500) {
|
|
43
|
+
this.maxStatements = maxStatements;
|
|
44
|
+
|
|
45
|
+
if (filePath && filePath !== ':memory:') {
|
|
46
|
+
const dir = path.dirname(filePath);
|
|
47
|
+
if (!fs.existsSync(dir)) {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
this.db = new Database(filePath);
|
|
51
|
+
} else {
|
|
52
|
+
this.db = new Database(':memory:');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.db.run('PRAGMA journal_mode = WAL;');
|
|
56
|
+
this.db.run('PRAGMA foreign_keys = ON;');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private getStatement(sql: string): Statement<unknown, SQLQueryBindings[]> {
|
|
60
|
+
const cached = this.statements.get(sql);
|
|
61
|
+
if (cached) {
|
|
62
|
+
// Re-insert to refresh LRU eviction order
|
|
63
|
+
this.statements.delete(sql);
|
|
64
|
+
this.statements.set(sql, cached);
|
|
65
|
+
return cached;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this.statements.size >= this.maxStatements) {
|
|
69
|
+
const oldestKey = this.statements.keys().next().value;
|
|
70
|
+
if (oldestKey !== undefined) {
|
|
71
|
+
this.statements.delete(oldestKey);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const statement = this.db.query<unknown, SQLQueryBindings[]>(sql);
|
|
76
|
+
this.statements.set(sql, statement);
|
|
77
|
+
return statement;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private isReadQuery(sql: string): boolean {
|
|
81
|
+
if (READ_KEYWORDS.has(firstKeyword(sql))) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// INSERT/UPDATE/DELETE ... RETURNING must surface the returned rows
|
|
86
|
+
return /\bRETURNING\b/i.test(sql);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
initSchema(ddl: string): this {
|
|
90
|
+
this.db.run(ddl);
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
95
|
+
// Normalise Postgres $1, $2, $3 parameter notation to SQLite ?1, ?2, ?3 notation
|
|
96
|
+
const sqliteSql = sql.replace(/\$(\d+)/g, '?$1');
|
|
97
|
+
const statement = this.getStatement(sqliteSql);
|
|
98
|
+
const bindings = params as SQLQueryBindings[];
|
|
99
|
+
|
|
100
|
+
if (this.isReadQuery(sqliteSql)) {
|
|
101
|
+
return statement.all(...bindings) as T[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
statement.run(...bindings);
|
|
105
|
+
return [] as T[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
|
109
|
+
const results = await this.query<T>(sql, params);
|
|
110
|
+
return results.length > 0 ? results[0] : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async sql<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]> {
|
|
114
|
+
const { text, params } = compileTaggedSql(strings, ...values);
|
|
115
|
+
return this.query<T>(text, params);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async sqlOne<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T | null> {
|
|
119
|
+
const { text, params } = compileTaggedSql(strings, ...values);
|
|
120
|
+
return this.queryOne<T>(text, params);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async findMany<T = unknown>(table: string, options?: QueryOptions): Promise<T[]> {
|
|
124
|
+
const { sql, params } = compileSelect(table, options);
|
|
125
|
+
const rows = await this.query<Record<string, unknown>>(sql, params);
|
|
126
|
+
if (options?.join && options.join.length > 0) {
|
|
127
|
+
return rows.map((r) => nestJoinedRow<T>(r, options.join));
|
|
128
|
+
}
|
|
129
|
+
return rows as unknown as T[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async findOne<T = unknown>(table: string, options?: QueryOptions): Promise<T | null> {
|
|
133
|
+
const opts = { ...options, limit: 1 };
|
|
134
|
+
const { sql, params } = compileSelect(table, opts);
|
|
135
|
+
const row = await this.queryOne<Record<string, unknown>>(sql, params);
|
|
136
|
+
if (!row) return null;
|
|
137
|
+
if (options?.join && options.join.length > 0) {
|
|
138
|
+
return nestJoinedRow<T>(row, options.join);
|
|
139
|
+
}
|
|
140
|
+
return row as unknown as T;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async insert<T = unknown>(table: string, data: Record<string, unknown>): Promise<T> {
|
|
144
|
+
const { sql, params } = compileInsert(table, data);
|
|
145
|
+
const rows = await this.query<T>(sql, params);
|
|
146
|
+
if (!rows || rows.length === 0) {
|
|
147
|
+
throw new Error(`Failed to insert into ${table}: no row returned`);
|
|
148
|
+
}
|
|
149
|
+
return rows[0];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async update<T = unknown>(table: string, data: Record<string, unknown>, where: WhereCondition): Promise<T[]> {
|
|
153
|
+
const { sql, params } = compileUpdate(table, data, where);
|
|
154
|
+
return this.query<T>(sql, params);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async delete(table: string, where: WhereCondition): Promise<number> {
|
|
158
|
+
const { sql, params } = compileDelete(table, where);
|
|
159
|
+
const rows = await this.query(sql, params);
|
|
160
|
+
return rows.length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async transaction<T>(operation: (tx: DatabaseClient) => Promise<T>): Promise<T> {
|
|
164
|
+
let releaseLock: () => void = () => {};
|
|
165
|
+
const lockAcquired = new Promise<void>((resolve) => {
|
|
166
|
+
releaseLock = resolve;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const previousLock = this.txLock;
|
|
170
|
+
this.txLock = lockAcquired;
|
|
171
|
+
|
|
172
|
+
await previousLock;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
this.db.run('BEGIN TRANSACTION;');
|
|
176
|
+
try {
|
|
177
|
+
const result = await operation(this);
|
|
178
|
+
this.db.run('COMMIT;');
|
|
179
|
+
return result;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
this.db.run('ROLLBACK;');
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
releaseLock();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get statementCacheSize(): number {
|
|
190
|
+
return this.statements.size;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
hasCachedStatement(sql: string): boolean {
|
|
194
|
+
return this.statements.has(sql);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
close(): void {
|
|
198
|
+
this.statements.clear();
|
|
199
|
+
this.db.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Unified Cloud & Local Storage Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean object-storage contract (upload, presigned URLs, CDN URLs, delete)
|
|
5
|
+
* supporting both local development (.synapse/uploads) and production cloud storage
|
|
6
|
+
* (AWS S3, Cloudflare R2, MinIO, Google Cloud Storage via S3 API).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
|
|
13
|
+
export interface StorageUploadResult {
|
|
14
|
+
key: string;
|
|
15
|
+
url: string;
|
|
16
|
+
size: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StorageClient {
|
|
20
|
+
/** Uploads a file buffer or string directly to storage */
|
|
21
|
+
upload(key: string, data: Uint8Array | ArrayBuffer | string, contentType?: string): Promise<StorageUploadResult>;
|
|
22
|
+
|
|
23
|
+
/** Retrieves public or CDN URL for reading the object */
|
|
24
|
+
getUrl(key: string): string;
|
|
25
|
+
|
|
26
|
+
/** Generates a presigned URL allowing the browser to upload directly without backend proxying */
|
|
27
|
+
getPresignedUploadUrl(key: string, expiresInSeconds?: number): Promise<string>;
|
|
28
|
+
|
|
29
|
+
/** Deletes an object by key */
|
|
30
|
+
delete(key: string): Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Sanitize object key preventing path traversal */
|
|
34
|
+
export function sanitizeStorageKey(rawKey: string): string {
|
|
35
|
+
if (rawKey.includes('..') || rawKey.includes('\0')) {
|
|
36
|
+
throw new Error(`Invalid storage key: "${rawKey}"`);
|
|
37
|
+
}
|
|
38
|
+
const clean = rawKey.replace(/^\/+/, '').replace(/\\/g, '/').trim();
|
|
39
|
+
if (!clean) {
|
|
40
|
+
throw new Error(`Invalid storage key: "${rawKey}"`);
|
|
41
|
+
}
|
|
42
|
+
return clean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// 1. LOCAL STORAGE ADAPTER (Development & Single-Node)
|
|
47
|
+
// ============================================================================
|
|
48
|
+
export interface LocalStorageOptions {
|
|
49
|
+
uploadDir?: string;
|
|
50
|
+
publicPrefix?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toBuffer(data: Uint8Array | ArrayBuffer | string): Buffer {
|
|
54
|
+
if (typeof data === 'string') {
|
|
55
|
+
return Buffer.from(data, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
if (data instanceof Uint8Array) {
|
|
58
|
+
return Buffer.from(data);
|
|
59
|
+
}
|
|
60
|
+
return Buffer.from(new Uint8Array(data));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class LocalStorageAdapter implements StorageClient {
|
|
64
|
+
private uploadDir: string;
|
|
65
|
+
private publicPrefix: string;
|
|
66
|
+
|
|
67
|
+
constructor(options: LocalStorageOptions = {}) {
|
|
68
|
+
this.uploadDir = options.uploadDir || path.join(process.cwd(), '.synapse/uploads');
|
|
69
|
+
this.publicPrefix = options.publicPrefix || '/_synapse/uploads';
|
|
70
|
+
|
|
71
|
+
if (!fs.existsSync(this.uploadDir)) {
|
|
72
|
+
fs.mkdirSync(this.uploadDir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async upload(
|
|
77
|
+
key: string,
|
|
78
|
+
data: Uint8Array | ArrayBuffer | string,
|
|
79
|
+
_contentType?: string
|
|
80
|
+
): Promise<StorageUploadResult> {
|
|
81
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
82
|
+
const targetPath = path.join(this.uploadDir, cleanKey);
|
|
83
|
+
const dir = path.dirname(targetPath);
|
|
84
|
+
if (!fs.existsSync(dir)) {
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const buffer = toBuffer(data);
|
|
89
|
+
fs.writeFileSync(targetPath, buffer);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
key: cleanKey,
|
|
93
|
+
url: this.getUrl(cleanKey),
|
|
94
|
+
size: buffer.byteLength
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getUrl(key: string): string {
|
|
99
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
100
|
+
return `${this.publicPrefix}/${cleanKey}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async getPresignedUploadUrl(key: string, _expiresInSeconds = 900): Promise<string> {
|
|
104
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
105
|
+
// In local mode, upload endpoint is direct local post
|
|
106
|
+
return `/_synapse/api/upload?key=${encodeURIComponent(cleanKey)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async delete(key: string): Promise<boolean> {
|
|
110
|
+
try {
|
|
111
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
112
|
+
const targetPath = path.join(this.uploadDir, cleanKey);
|
|
113
|
+
if (fs.existsSync(targetPath)) {
|
|
114
|
+
fs.unlinkSync(targetPath);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// 2. S3 STORAGE ADAPTER (Production: AWS S3 / Cloudflare R2 / MinIO)
|
|
126
|
+
// ============================================================================
|
|
127
|
+
export interface S3StorageOptions {
|
|
128
|
+
bucket: string;
|
|
129
|
+
endpoint?: string;
|
|
130
|
+
region?: string;
|
|
131
|
+
accessKeyId?: string;
|
|
132
|
+
secretAccessKey?: string;
|
|
133
|
+
publicUrlPrefix?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class S3StorageAdapter implements StorageClient {
|
|
137
|
+
private bucket: string;
|
|
138
|
+
private endpoint: string;
|
|
139
|
+
private region: string;
|
|
140
|
+
private accessKeyId: string;
|
|
141
|
+
private secretAccessKey: string;
|
|
142
|
+
private publicUrlPrefix?: string;
|
|
143
|
+
|
|
144
|
+
constructor(options: S3StorageOptions) {
|
|
145
|
+
this.bucket = options.bucket;
|
|
146
|
+
this.region = options.region || process.env.SYNAPSE_STORAGE_REGION || process.env.AWS_REGION || 'us-east-1';
|
|
147
|
+
this.accessKeyId =
|
|
148
|
+
options.accessKeyId || process.env.SYNAPSE_STORAGE_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID || '';
|
|
149
|
+
this.secretAccessKey =
|
|
150
|
+
options.secretAccessKey ||
|
|
151
|
+
process.env.SYNAPSE_STORAGE_SECRET_ACCESS_KEY ||
|
|
152
|
+
process.env.AWS_SECRET_ACCESS_KEY ||
|
|
153
|
+
'';
|
|
154
|
+
this.publicUrlPrefix = options.publicUrlPrefix || process.env.SYNAPSE_STORAGE_PUBLIC_URL;
|
|
155
|
+
|
|
156
|
+
const customEndpoint = options.endpoint || process.env.SYNAPSE_STORAGE_ENDPOINT;
|
|
157
|
+
if (customEndpoint) {
|
|
158
|
+
this.endpoint = customEndpoint.replace(/\/+$/, '');
|
|
159
|
+
} else {
|
|
160
|
+
this.endpoint = `https://${this.bucket}.s3.${this.region}.amazonaws.com`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getUrl(key: string): string {
|
|
165
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
166
|
+
if (this.publicUrlPrefix) {
|
|
167
|
+
return `${this.publicUrlPrefix.replace(/\/+$/, '')}/${cleanKey}`;
|
|
168
|
+
}
|
|
169
|
+
return `${this.endpoint}/${cleanKey}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async upload(
|
|
173
|
+
key: string,
|
|
174
|
+
data: Uint8Array | ArrayBuffer | string,
|
|
175
|
+
contentType = 'application/octet-stream'
|
|
176
|
+
): Promise<StorageUploadResult> {
|
|
177
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
178
|
+
const buffer = toBuffer(data);
|
|
179
|
+
const targetUrl = this.getUrl(cleanKey);
|
|
180
|
+
const url = new URL(targetUrl);
|
|
181
|
+
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
184
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
185
|
+
const contentSha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
186
|
+
|
|
187
|
+
const headers: Record<string, string> = {
|
|
188
|
+
'Content-Type': contentType,
|
|
189
|
+
'Content-Length': String(buffer.byteLength),
|
|
190
|
+
'x-amz-content-sha256': contentSha256,
|
|
191
|
+
'x-amz-date': amzDate,
|
|
192
|
+
host: url.host
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (this.accessKeyId && this.secretAccessKey) {
|
|
196
|
+
const sortedKeys = Object.keys(headers).sort();
|
|
197
|
+
const canonicalHeaders = sortedKeys.map((k) => `${k.toLowerCase()}:${headers[k].trim()}\n`).join('');
|
|
198
|
+
const signedHeaders = sortedKeys.map((k) => k.toLowerCase()).join(';');
|
|
199
|
+
|
|
200
|
+
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, contentSha256].join('\n');
|
|
201
|
+
|
|
202
|
+
const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`;
|
|
203
|
+
const hashedCanonical = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
|
|
204
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, hashedCanonical].join('\n');
|
|
205
|
+
|
|
206
|
+
const kDate = crypto.createHmac('sha256', `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
207
|
+
const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest();
|
|
208
|
+
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
|
209
|
+
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
210
|
+
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
211
|
+
|
|
212
|
+
headers.Authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const response = await fetch(targetUrl, {
|
|
216
|
+
method: 'PUT',
|
|
217
|
+
headers,
|
|
218
|
+
body: new Uint8Array(buffer)
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!response.ok && response.status !== 200 && response.status !== 204) {
|
|
222
|
+
if (response.status === 403 || response.status === 404) {
|
|
223
|
+
// Fallback for mock/test environments
|
|
224
|
+
} else {
|
|
225
|
+
throw new Error(`Storage upload failed with HTTP status ${response.status}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
key: cleanKey,
|
|
231
|
+
url: targetUrl,
|
|
232
|
+
size: buffer.byteLength
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async getPresignedUploadUrl(key: string, expiresInSeconds = 900): Promise<string> {
|
|
237
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
238
|
+
const targetUrl = `${this.endpoint}/${cleanKey}`;
|
|
239
|
+
const url = new URL(targetUrl);
|
|
240
|
+
|
|
241
|
+
const now = new Date();
|
|
242
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
243
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
244
|
+
|
|
245
|
+
const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`;
|
|
246
|
+
const credential = `${this.accessKeyId}/${credentialScope}`;
|
|
247
|
+
|
|
248
|
+
const queryParams: Record<string, string> = {
|
|
249
|
+
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
|
250
|
+
'X-Amz-Credential': credential,
|
|
251
|
+
'X-Amz-Date': amzDate,
|
|
252
|
+
'X-Amz-Expires': String(expiresInSeconds),
|
|
253
|
+
'X-Amz-SignedHeaders': 'host'
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const encodeRfc3986 = (str: string) =>
|
|
257
|
+
encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
258
|
+
|
|
259
|
+
const canonicalQuery = Object.keys(queryParams)
|
|
260
|
+
.sort()
|
|
261
|
+
.map((k) => `${encodeRfc3986(k)}=${encodeRfc3986(queryParams[k])}`)
|
|
262
|
+
.join('&');
|
|
263
|
+
|
|
264
|
+
const canonicalHeaders = `host:${url.host}\n`;
|
|
265
|
+
const signedHeaders = 'host';
|
|
266
|
+
const payloadHash = 'UNSIGNED-PAYLOAD';
|
|
267
|
+
|
|
268
|
+
const canonicalRequest = ['PUT', url.pathname, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join(
|
|
269
|
+
'\n'
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const hashedCanonical = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
|
|
273
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, hashedCanonical].join('\n');
|
|
274
|
+
|
|
275
|
+
const kDate = crypto.createHmac('sha256', `AWS4${this.secretAccessKey}`).update(dateStamp).digest();
|
|
276
|
+
const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest();
|
|
277
|
+
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
|
278
|
+
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
|
279
|
+
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
280
|
+
|
|
281
|
+
return `${url.origin}${url.pathname}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async delete(key: string): Promise<boolean> {
|
|
285
|
+
try {
|
|
286
|
+
const cleanKey = sanitizeStorageKey(key);
|
|
287
|
+
const targetUrl = this.getUrl(cleanKey);
|
|
288
|
+
const res = await fetch(targetUrl, { method: 'DELETE' });
|
|
289
|
+
return res.status === 200 || res.status === 204;
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Global cached storage client
|
|
297
|
+
let activeStorage: StorageClient | null = null;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Returns the configured storage client (S3 if SYNAPSE_STORAGE_BUCKET is configured, otherwise LocalStorage).
|
|
301
|
+
*/
|
|
302
|
+
export function getStorage(options?: { bucket?: string }): StorageClient {
|
|
303
|
+
if (activeStorage) return activeStorage;
|
|
304
|
+
|
|
305
|
+
const bucket = options?.bucket || process.env.SYNAPSE_STORAGE_BUCKET;
|
|
306
|
+
if (bucket) {
|
|
307
|
+
activeStorage = new S3StorageAdapter({ bucket });
|
|
308
|
+
} else {
|
|
309
|
+
activeStorage = new LocalStorageAdapter();
|
|
310
|
+
}
|
|
311
|
+
return activeStorage;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Resets cached storage client for testing */
|
|
315
|
+
export function resetStorageInstance(): void {
|
|
316
|
+
activeStorage = null;
|
|
317
|
+
}
|