@everystack/server 0.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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Built-in CFF features. Each factory returns a Feature suitable for
3
+ * composeViewerRequest.
4
+ *
5
+ * Ordering convention (lower runs first):
6
+ * - cache-key: 10 (normalize URI/querystring before anything else uses them)
7
+ * - geo: 50 (set headers / block by country before auth gates)
8
+ * - auth: 80 (last gate before forwarding to origin)
9
+ */
10
+
11
+ import { generateCffVerifier, generateCompactCffVerifier } from '@everystack/auth/cff';
12
+ import type { Feature } from './compose';
13
+
14
+ // ---------- auth ----------
15
+
16
+ export type Role = string;
17
+
18
+ export interface AuthRoleEntry {
19
+ /** Read role required (GET/HEAD). */
20
+ r: Role;
21
+ /** Write role required (POST/PUT/PATCH/DELETE). */
22
+ w: Role;
23
+ }
24
+
25
+ export interface AuthFeatureOptions {
26
+ /** HS256 secret — same secret your Lambda mints with. */
27
+ secret: string;
28
+ /**
29
+ * Path prefixes that bypass auth entirely. Default:
30
+ * ['/api/auth/', '/api/health', '/api/updates', '/api/storage/public'].
31
+ */
32
+ publicPaths?: string[];
33
+ /**
34
+ * Optional role table. Keyed by table name extracted from /api/<table>/...
35
+ * Use '*' for default. Requires `tableFromPath: true`.
36
+ */
37
+ roles?: Record<string, AuthRoleEntry>;
38
+ /** Role hierarchy, weakest first. Required when `roles` is set. */
39
+ roleHierarchy?: Role[];
40
+ /**
41
+ * Extract table name from `/api/<table>/...` for role lookup. Default false.
42
+ */
43
+ tableFromPath?: boolean;
44
+ /** Allowed clock skew (seconds). Default 30. */
45
+ clockSkewSec?: number;
46
+ /**
47
+ * Cookie name to check as fallback when no Authorization header is present.
48
+ * Enables SSR cookie-based auth alongside mobile Bearer tokens.
49
+ * Set to `false` to disable cookie fallback. Default: `'__es_token'`.
50
+ */
51
+ cookieName?: string | false;
52
+ /**
53
+ * Use compact verifier (~1.1KB vs ~2.3KB minified). ASCII-only claim values.
54
+ * Safe for standard JWT claims (sub, role, email, exp, iat). Default false.
55
+ */
56
+ compact?: boolean;
57
+ /** Override `order`. Default 80 (last). */
58
+ order?: number;
59
+ }
60
+
61
+ const DEFAULT_AUTH_PUBLIC = [
62
+ '/api/auth/',
63
+ '/api/health',
64
+ '/api/updates',
65
+ '/api/storage/public',
66
+ ];
67
+
68
+ export function authFeature(opts: AuthFeatureOptions): Feature {
69
+ const publicPaths = opts.publicPaths ?? DEFAULT_AUTH_PUBLIC;
70
+ const roles = opts.roles ?? null;
71
+ const hierarchy = opts.roleHierarchy ?? null;
72
+ const tableFromPath = opts.tableFromPath === true;
73
+ const cookieName = opts.cookieName !== false ? (opts.cookieName ?? '__es_token') : '';
74
+
75
+ if (roles && !hierarchy) {
76
+ throw new Error('authFeature: roleHierarchy is required when roles is set');
77
+ }
78
+
79
+ const verifierSrc = opts.compact
80
+ ? generateCompactCffVerifier({ clockSkewSec: opts.clockSkewSec })
81
+ : generateCffVerifier({ clockSkewSec: opts.clockSkewSec });
82
+
83
+ // Cookie fallback block — only emitted when cookieName is set.
84
+ const cookieBlock = cookieName
85
+ ? `
86
+ if (!__auth_h || __auth_h.indexOf('Bearer ') !== 0) {
87
+ var __auth_ck = __auth_req.cookies && __auth_req.cookies[__auth_cookieName];
88
+ if (__auth_ck) __auth_h = 'Bearer ' + __auth_ck.value;
89
+ }`
90
+ : '';
91
+
92
+ return {
93
+ name: 'auth',
94
+ order: opts.order ?? 80,
95
+ helpers: {
96
+ // The whole verifier source emits multiple top-level fns; key it as
97
+ // 'jwt' so re-registration with identical source is idempotent.
98
+ jwt: verifierSrc,
99
+ },
100
+ state: {
101
+ secret: opts.secret,
102
+ publicPaths,
103
+ roles: roles ?? {},
104
+ hierarchy: hierarchy ?? [],
105
+ tableFromPath,
106
+ cookieName,
107
+ },
108
+ body: `
109
+ function __auth_isPublic(uri) {
110
+ for (var i = 0; i < __auth_publicPaths.length; i++) {
111
+ var p = __auth_publicPaths[i];
112
+ if (uri === p || uri.indexOf(p) === 0) return true;
113
+ }
114
+ return false;
115
+ }
116
+ function __auth_roleLevel(role) {
117
+ for (var i = 0; i < __auth_hierarchy.length; i++) {
118
+ if (__auth_hierarchy[i] === role) return i;
119
+ }
120
+ return -1;
121
+ }
122
+ function __auth_required(uri, method) {
123
+ if (!__auth_tableFromPath) return null;
124
+ var m = uri.match(/^\\/api\\/([^\\/?]+)/);
125
+ if (!m) return 'public';
126
+ var entry = __auth_roles[m[1]] || __auth_roles['*'];
127
+ if (!entry) return null;
128
+ return (method === 'GET' || method === 'HEAD') ? entry.r : entry.w;
129
+ }
130
+ function __auth_reject(status, message) {
131
+ return { statusCode: status, statusDescription: status === 401 ? 'Unauthorized' : 'Forbidden', headers: { 'content-type': { value: 'application/json' }, 'cache-control': { value: 'no-store' } }, body: { encoding: 'text', data: JSON.stringify({ message: message }) } };
132
+ }
133
+ var __auth_req = event.request;
134
+ var __auth_uri = __auth_req.uri;
135
+ var __auth_method = __auth_req.method;
136
+ var __auth_reqRole = __auth_required(__auth_uri, __auth_method);
137
+ if (!__auth_isPublic(__auth_uri) && __auth_reqRole !== 'public') {
138
+ var __auth_h = __auth_req.headers.authorization && __auth_req.headers.authorization.value;${cookieBlock}
139
+ if (!__auth_h || __auth_h.indexOf('Bearer ') !== 0) return __auth_reject(401, 'Authentication required');
140
+ var __auth_claims = verifyJwt(__auth_h.slice(7), __auth_secret);
141
+ if (!__auth_claims) return __auth_reject(401, 'Invalid or expired token');
142
+ if (__auth_reqRole && __auth_reqRole !== 'authenticated') {
143
+ var __auth_userRole = __auth_claims.role || 'authenticated';
144
+ if (__auth_roleLevel(__auth_userRole) < __auth_roleLevel(__auth_reqRole)) return __auth_reject(403, 'Insufficient role');
145
+ }
146
+ }
147
+ `,
148
+ };
149
+ }
150
+
151
+ // ---------- geo ----------
152
+
153
+ export interface GeoFeatureOptions {
154
+ /** ISO 3166-1 alpha-2 codes to block with HTTP 451. Default: none. */
155
+ blockedCountries?: string[];
156
+ /** Override `order`. Default 50. */
157
+ order?: number;
158
+ }
159
+
160
+ export function geoFeature(opts: GeoFeatureOptions = {}): Feature {
161
+ const blocked = opts.blockedCountries ?? [];
162
+ return {
163
+ name: 'geo',
164
+ order: opts.order ?? 50,
165
+ state: { blocked },
166
+ body: `
167
+ var __geo_h = event.request.headers;
168
+ var __geo_country = __geo_h['cloudfront-viewer-country'] && __geo_h['cloudfront-viewer-country'].value;
169
+ var __geo_region = __geo_h['cloudfront-viewer-country-region'] && __geo_h['cloudfront-viewer-country-region'].value;
170
+ var __geo_city = __geo_h['cloudfront-viewer-city'] && __geo_h['cloudfront-viewer-city'].value;
171
+ if (__geo_country) __geo_h['x-geo-country'] = { value: __geo_country };
172
+ if (__geo_region) __geo_h['x-geo-region'] = { value: __geo_region };
173
+ if (__geo_city) __geo_h['x-geo-city'] = { value: __geo_city };
174
+ if (__geo_country && __geo_blocked.length) {
175
+ for (var __geo_i = 0; __geo_i < __geo_blocked.length; __geo_i++) {
176
+ if (__geo_blocked[__geo_i] === __geo_country) {
177
+ return { statusCode: 451, statusDescription: 'Unavailable For Legal Reasons', headers: { 'content-type': { value: 'application/json' } }, body: { encoding: 'text', data: JSON.stringify({ message: 'Service not available in your region' }) } };
178
+ }
179
+ }
180
+ }
181
+ `,
182
+ };
183
+ }
184
+
185
+ // ---------- cache-key ----------
186
+
187
+ export interface CacheKeyFeatureOptions {
188
+ /**
189
+ * Allowlist of querystring params to retain in the cache key. All other
190
+ * params are stripped. When omitted, all params are kept (no-op).
191
+ */
192
+ allowedQueryParams?: string[];
193
+ /** Override `order`. Default 10. */
194
+ order?: number;
195
+ }
196
+
197
+ export function cacheKeyFeature(opts: CacheKeyFeatureOptions = {}): Feature {
198
+ const allowed = opts.allowedQueryParams;
199
+ if (!allowed || allowed.length === 0) {
200
+ return {
201
+ name: 'cacheKey',
202
+ order: opts.order ?? 10,
203
+ body: '/* cache-key: no allowlist, no-op */',
204
+ };
205
+ }
206
+ return {
207
+ name: 'cacheKey',
208
+ order: opts.order ?? 10,
209
+ state: { allowed },
210
+ body: `
211
+ var __ck_qs = event.request.querystring;
212
+ var __ck_kept = {};
213
+ for (var __ck_i = 0; __ck_i < __cacheKey_allowed.length; __ck_i++) {
214
+ var __ck_k = __cacheKey_allowed[__ck_i];
215
+ if (__ck_qs[__ck_k]) __ck_kept[__ck_k] = __ck_qs[__ck_k];
216
+ }
217
+ event.request.querystring = __ck_kept;
218
+ `,
219
+ };
220
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @everystack/server/cdn — CloudFront edge composition.
3
+ *
4
+ * Two surfaces:
5
+ * 1. `createAuthFunction(...)` — single-feature shortcut (back-compat).
6
+ * Generates an `injection` for sst.aws.Router edge.viewerRequest that
7
+ * verifies HS256 JWTs at the edge.
8
+ * 2. `composeViewerRequest({ features: [...] })` — multi-feature composer.
9
+ * Combine `authFeature`, `geoFeature`, `cacheKeyFeature` (or your own)
10
+ * into a single CFF source with shared helpers, namespaced state, a
11
+ * fail-closed try/catch wrapper, safe minification, and a 10 KB budget.
12
+ *
13
+ * The emitted code uses ONLY ES2019 + crypto.createHmac — no Buffer,
14
+ * TextEncoder, or any Node API. Runs unmodified inside CloudFront Functions
15
+ * runtime 2.0.
16
+ */
17
+
18
+ import { composeViewerRequest } from './compose';
19
+ import { authFeature, type AuthFeatureOptions, type AuthRoleEntry, type Role } from './features';
20
+
21
+ export {
22
+ composeViewerRequest,
23
+ type ComposeOptions,
24
+ type ComposeResult,
25
+ type Feature,
26
+ minifyCff,
27
+ } from './compose';
28
+ export {
29
+ authFeature,
30
+ geoFeature,
31
+ cacheKeyFeature,
32
+ type AuthFeatureOptions,
33
+ type AuthRoleEntry,
34
+ type GeoFeatureOptions,
35
+ type CacheKeyFeatureOptions,
36
+ type Role,
37
+ } from './features';
38
+
39
+ // ---------- Back-compat: createAuthFunction ----------
40
+
41
+ export interface RoleEntry extends AuthRoleEntry {}
42
+
43
+ export interface AuthFunctionConfig {
44
+ /** HS256 secret — same secret your Lambda uses to mint tokens. */
45
+ secret: string;
46
+ publicPaths?: string[];
47
+ roles?: Record<string, RoleEntry>;
48
+ roleHierarchy?: Role[];
49
+ tableFromPath?: boolean;
50
+ clockSkewSec?: number;
51
+ /** Cookie name for SSR auth fallback. Default: '__es_token'. Set false to disable. */
52
+ cookieName?: string | false;
53
+ }
54
+
55
+ export interface AuthFunctionResult {
56
+ /** Inject body for sst.aws.Router edge.viewerRequest.injection. */
57
+ injection: string;
58
+ }
59
+
60
+ /**
61
+ * Single-feature auth shortcut. Equivalent to
62
+ * composeViewerRequest({ features: [authFeature(config)] }).code
63
+ * Kept for back-compat with existing SST configs.
64
+ */
65
+ export function createAuthFunction(config: AuthFunctionConfig): AuthFunctionResult {
66
+ const opts: AuthFeatureOptions = {
67
+ secret: config.secret,
68
+ publicPaths: config.publicPaths,
69
+ roles: config.roles,
70
+ roleHierarchy: config.roleHierarchy,
71
+ tableFromPath: config.tableFromPath,
72
+ clockSkewSec: config.clockSkewSec,
73
+ cookieName: config.cookieName,
74
+ };
75
+ const { code } = composeViewerRequest({ features: [authFeature(opts)] });
76
+ return { injection: code };
77
+ }
package/src/db.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @everystack/server/db — SST Resource-linked database helpers.
3
+ *
4
+ * Provides a lazy singleton Drizzle connection using SST Resource linking
5
+ * for database credentials and secrets.
6
+ */
7
+
8
+ import { Resource } from 'sst';
9
+ import { drizzle } from 'drizzle-orm/postgres-js';
10
+ import postgres from 'postgres';
11
+
12
+ export function getDatabaseUrl(): string {
13
+ return `postgresql://${Resource.Database.username}:${Resource.Database.password}@${Resource.Database.host}:${Resource.Database.port}/${Resource.Database.database}`;
14
+ }
15
+
16
+ export function getJwtSecret(): Uint8Array {
17
+ return new TextEncoder().encode(Resource.JwtSecret.value);
18
+ }
19
+
20
+ // Lazy singleton DB connection
21
+ let dbInstance: ReturnType<typeof drizzle> | null = null;
22
+ let sqlClient: ReturnType<typeof postgres> | null = null;
23
+
24
+ export function createDb<T extends Record<string, unknown>>(
25
+ schema: T,
26
+ options?: { maxConnections?: number }
27
+ ): {
28
+ db: ReturnType<typeof drizzle>;
29
+ schema: T;
30
+ } {
31
+ if (dbInstance) return { db: dbInstance, schema };
32
+
33
+ const url = getDatabaseUrl();
34
+ sqlClient = postgres(url, {
35
+ max: options?.maxConnections ?? 1,
36
+ idle_timeout: 60,
37
+ connect_timeout: 5,
38
+ max_lifetime: 60 * 5,
39
+ ssl: 'require',
40
+ });
41
+ dbInstance = drizzle(sqlClient, { schema });
42
+ return { db: dbInstance, schema };
43
+ }
44
+
45
+ /**
46
+ * Get the raw postgres.js sql client.
47
+ *
48
+ * Use this for calling PostgreSQL functions directly via tagged templates,
49
+ * bypassing drizzle-orm's SQL builder (which expands arrays into individual
50
+ * params instead of PostgreSQL arrays).
51
+ *
52
+ * Must be called after createDb().
53
+ */
54
+ export function getSql(): ReturnType<typeof postgres> {
55
+ if (!sqlClient) {
56
+ throw new Error('getSql() called before createDb() — no database connection');
57
+ }
58
+ return sqlClient;
59
+ }
package/src/image.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @everystack/server/image — On-demand image processing Lambda.
3
+ *
4
+ * CloudFront → this Lambda → Sharp → return processed image
5
+ * CloudFront caches the result. Subsequent requests never hit Lambda.
6
+ *
7
+ * URL format:
8
+ * /media/{key}?w=400&h=300&fit=cover&fm=webp&q=80&dpr=2
9
+ *
10
+ * No params = EXIF-corrected webp at q80 (never exposes S3 URLs).
11
+ */
12
+
13
+ import type {
14
+ APIGatewayProxyEventV2,
15
+ APIGatewayProxyStructuredResultV2,
16
+ } from 'aws-lambda';
17
+ import { log } from './index.js';
18
+
19
+ export interface ImageHandlerConfig {
20
+ /** S3 bucket name for media storage */
21
+ bucket: string;
22
+ /** AWS region (defaults to process.env.AWS_REGION) */
23
+ region?: string;
24
+ /** URL path prefix to strip (defaults to '/media/') */
25
+ pathPrefix?: string;
26
+ /** Cache-Control for image responses.
27
+ * Default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5' */
28
+ cacheControl?: string;
29
+ }
30
+
31
+ export interface TransformParams {
32
+ w?: number;
33
+ h?: number;
34
+ fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
35
+ fm?: 'webp' | 'jpeg' | 'png' | 'avif';
36
+ q?: number;
37
+ dpr?: number;
38
+ }
39
+
40
+ const VALID_FITS = ['cover', 'contain', 'fill', 'inside', 'outside'] as const;
41
+ const VALID_FORMATS = ['webp', 'jpeg', 'png', 'avif'] as const;
42
+ const FORMAT_CONTENT_TYPES: Record<string, string> = {
43
+ webp: 'image/webp',
44
+ jpeg: 'image/jpeg',
45
+ png: 'image/png',
46
+ avif: 'image/avif',
47
+ };
48
+ const MAX_DIMENSION = 4096;
49
+
50
+ export function parseParams(query: Record<string, string | undefined>): TransformParams {
51
+ const params: TransformParams = {};
52
+
53
+ if (query.w) {
54
+ const w = parseInt(query.w, 10);
55
+ if (w > 0 && w <= MAX_DIMENSION) params.w = w;
56
+ }
57
+ if (query.h) {
58
+ const h = parseInt(query.h, 10);
59
+ if (h > 0 && h <= MAX_DIMENSION) params.h = h;
60
+ }
61
+ if (query.fit && VALID_FITS.includes(query.fit as any)) {
62
+ params.fit = query.fit as TransformParams['fit'];
63
+ }
64
+ if (query.fm && VALID_FORMATS.includes(query.fm as any)) {
65
+ params.fm = query.fm as TransformParams['fm'];
66
+ }
67
+ if (query.q) {
68
+ const q = parseInt(query.q, 10);
69
+ if (q >= 1 && q <= 100) params.q = q;
70
+ }
71
+ if (query.dpr) {
72
+ const dpr = parseFloat(query.dpr);
73
+ if (dpr >= 1 && dpr <= 3) params.dpr = dpr;
74
+ }
75
+
76
+ // Apply DPR multiplier
77
+ if (params.dpr && params.dpr > 1) {
78
+ if (params.w) params.w = Math.round(params.w * params.dpr);
79
+ if (params.h) params.h = Math.round(params.h * params.dpr);
80
+ }
81
+
82
+ return params;
83
+ }
84
+
85
+ function isNotFound(error: any): boolean {
86
+ return (
87
+ error.name === 'NotFound' ||
88
+ error.name === 'NoSuchKey' ||
89
+ error.$metadata?.httpStatusCode === 404 ||
90
+ (error.name === 'AccessDenied' && error.$metadata?.httpStatusCode === 403)
91
+ );
92
+ }
93
+
94
+ export async function processImage(
95
+ data: Buffer,
96
+ params: TransformParams
97
+ ): Promise<{ data: Buffer; contentType: string }> {
98
+ // Sharp is provided via Lambda layer
99
+ const sharp = (await import('sharp')).default;
100
+
101
+ let pipeline = sharp(data);
102
+
103
+ // Auto-orient based on EXIF metadata (fixes rotated portrait thumbnails)
104
+ pipeline = pipeline.rotate();
105
+
106
+ // Resize
107
+ if (params.w || params.h) {
108
+ pipeline = pipeline.resize({
109
+ width: params.w,
110
+ height: params.h,
111
+ fit: params.fit || 'cover',
112
+ withoutEnlargement: true,
113
+ });
114
+ }
115
+
116
+ // Format conversion
117
+ const format = params.fm || 'webp';
118
+ const quality = params.q || 80;
119
+
120
+ switch (format) {
121
+ case 'webp':
122
+ pipeline = pipeline.webp({ quality });
123
+ break;
124
+ case 'jpeg':
125
+ pipeline = pipeline.jpeg({ quality });
126
+ break;
127
+ case 'png':
128
+ pipeline = pipeline.png();
129
+ break;
130
+ case 'avif':
131
+ pipeline = pipeline.avif({ quality });
132
+ break;
133
+ }
134
+
135
+ const result = await pipeline.toBuffer();
136
+ return {
137
+ data: result,
138
+ contentType: FORMAT_CONTENT_TYPES[format],
139
+ };
140
+ }
141
+
142
+ export function createImageHandler(
143
+ config: ImageHandlerConfig
144
+ ): (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyStructuredResultV2> {
145
+ const { bucket, pathPrefix = '/media/' } = config;
146
+ const region = config.region || process.env.AWS_REGION;
147
+
148
+ return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
149
+ try {
150
+ // Extract key from path: /media/uploads/abc.jpg → uploads/abc.jpg
151
+ const path = event.rawPath;
152
+ const key = path.replace(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), '');
153
+
154
+ if (!key) {
155
+ return {
156
+ statusCode: 400,
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({ error: 'Missing image key' }),
159
+ };
160
+ }
161
+
162
+ const params = parseParams(event.queryStringParameters || {});
163
+
164
+ const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
165
+ const client = new S3Client({ region });
166
+
167
+ // Fetch original from S3 for processing
168
+ let originalData: Buffer;
169
+ try {
170
+ const result = await client.send(
171
+ new GetObjectCommand({ Bucket: bucket, Key: key })
172
+ );
173
+ if (!result.Body) {
174
+ return {
175
+ statusCode: 404,
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ error: 'Image not found' }),
178
+ };
179
+ }
180
+ const bytes = await result.Body.transformToByteArray();
181
+ originalData = Buffer.from(bytes);
182
+ } catch (error: any) {
183
+ if (isNotFound(error)) {
184
+ return {
185
+ statusCode: 404,
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ error: 'Image not found' }),
188
+ };
189
+ }
190
+ throw error;
191
+ }
192
+
193
+ // Process with Sharp
194
+ const processed = await processImage(originalData, params);
195
+
196
+ return {
197
+ statusCode: 200,
198
+ headers: {
199
+ 'Content-Type': processed.contentType,
200
+ 'Cache-Control': config.cacheControl ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5',
201
+ 'Vary': 'Accept',
202
+ },
203
+ body: processed.data.toString('base64'),
204
+ isBase64Encoded: true,
205
+ };
206
+ } catch (error) {
207
+ log('error', 'Image processing error', { error: String(error), path: event.rawPath });
208
+ return {
209
+ statusCode: 500,
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ error: 'Image processing failed' }),
212
+ };
213
+ }
214
+ };
215
+ }