effortless-aws 0.33.0 → 0.33.1

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.
@@ -233,9 +233,6 @@ var createTableClient = (tableName, options) => {
233
233
  };
234
234
  };
235
235
 
236
- // src/handlers/auth.ts
237
- import * as crypto from "crypto";
238
-
239
236
  // src/handlers/handler-options.ts
240
237
  var toSeconds = (d) => {
241
238
  if (typeof d === "number") return d;
@@ -250,10 +247,35 @@ var toSeconds = (d) => {
250
247
  };
251
248
 
252
249
  // src/handlers/auth.ts
250
+ import * as crypto from "crypto";
251
+ var cfBase64Encode = (buffer) => buffer.toString("base64").replace(/\+/g, "-").replace(/=/g, "_").replace(/\//g, "~");
252
+ var signCfCookies = (policy, config) => {
253
+ const ttlSeconds = toSeconds(policy.ttl);
254
+ const expireTime = Math.floor(Date.now() / 1e3) + ttlSeconds;
255
+ const resource = config.domain === "*" ? `https://*${policy.path}` : `https://${config.domain}${policy.path}`;
256
+ const policyJson = JSON.stringify({
257
+ Statement: [{
258
+ Resource: resource,
259
+ Condition: {
260
+ DateLessThan: { "AWS:EpochTime": expireTime }
261
+ }
262
+ }]
263
+ });
264
+ const policyBase64 = cfBase64Encode(Buffer.from(policyJson, "utf-8"));
265
+ const signature = cfBase64Encode(
266
+ crypto.sign("sha1", Buffer.from(policyJson, "utf-8"), config.privateKey)
267
+ );
268
+ const cookieAttrs = `; Secure; SameSite=Lax; Path=/; Max-Age=${ttlSeconds}`;
269
+ return [
270
+ `CloudFront-Policy=${policyBase64}${cookieAttrs}`,
271
+ `CloudFront-Signature=${signature}${cookieAttrs}`,
272
+ `CloudFront-Key-Pair-Id=${config.keyPairId}${cookieAttrs}`
273
+ ];
274
+ };
253
275
  var AUTH_COOKIE_NAME = "__eff_session";
254
- var createAuthRuntime = (secret, defaultExpiresIn, apiTokenVerify, apiTokenHeader, apiTokenCacheTtlSeconds) => {
276
+ var createAuthRuntime = (secret, defaultExpiresIn, apiTokenVerify, apiTokenHeader, apiTokenCacheTtlSeconds, cfSigningConfig) => {
255
277
  const tokenCache = apiTokenCacheTtlSeconds ? /* @__PURE__ */ new Map() : void 0;
256
- const sign = (payload) => crypto.createHmac("sha256", secret).update(payload).digest("base64url");
278
+ const sign2 = (payload) => crypto.createHmac("sha256", secret).update(payload).digest("base64url");
257
279
  const cookieBase = `${AUTH_COOKIE_NAME}=`;
258
280
  const cookieAttrs = "; HttpOnly; Secure; SameSite=Lax; Path=/";
259
281
  const decodeSession = (cookieValue) => {
@@ -262,7 +284,7 @@ var createAuthRuntime = (secret, defaultExpiresIn, apiTokenVerify, apiTokenHeade
262
284
  if (dot === -1) return void 0;
263
285
  const payload = cookieValue.slice(0, dot);
264
286
  const sig = cookieValue.slice(dot + 1);
265
- if (sign(payload) !== sig) return void 0;
287
+ if (sign2(payload) !== sig) return void 0;
266
288
  try {
267
289
  const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
268
290
  if (parsed.exp <= Math.floor(Date.now() / 1e3)) return void 0;
@@ -284,13 +306,16 @@ var createAuthRuntime = (secret, defaultExpiresIn, apiTokenVerify, apiTokenHeade
284
306
  const seconds = options?.expiresIn ? toSeconds(options.expiresIn) : defaultExpiresIn;
285
307
  const exp = Math.floor(Date.now() / 1e3) + seconds;
286
308
  const payload = Buffer.from(JSON.stringify({ exp, ...data }), "utf-8").toString("base64url");
287
- const sig = sign(payload);
309
+ const sig = sign2(payload);
310
+ const sessionCookie = `${cookieBase}${payload}.${sig}${cookieAttrs}; Max-Age=${seconds}`;
311
+ const cfCookies = options?.cdnPolicy && cfSigningConfig ? signCfCookies(options.cdnPolicy, cfSigningConfig) : void 0;
288
312
  return {
289
313
  status: 200,
290
314
  body: { ok: true },
291
315
  headers: {
292
- "set-cookie": `${cookieBase}${payload}.${sig}${cookieAttrs}; Max-Age=${seconds}`
293
- }
316
+ "set-cookie": sessionCookie
317
+ },
318
+ ...cfCookies ? { cookies: [sessionCookie, ...cfCookies] } : {}
294
319
  };
295
320
  },
296
321
  clearSession() {
@@ -327,34 +352,91 @@ var createAuthRuntime = (secret, defaultExpiresIn, apiTokenVerify, apiTokenHeade
327
352
 
328
353
  // src/runtime/bucket-client.ts
329
354
  import { S3 } from "@aws-sdk/client-s3";
330
- var createBucketClient = (bucketName) => {
331
- let client2 = null;
332
- const getClient2 = () => client2 ??= new S3({});
355
+ var createBucketMethods = (getClient2, bucketName) => ({
356
+ bucketName,
357
+ async put(key, body, options) {
358
+ await getClient2().putObject({
359
+ Bucket: bucketName,
360
+ Key: key,
361
+ Body: typeof body === "string" ? Buffer.from(body) : body,
362
+ ...options?.contentType ? { ContentType: options.contentType } : {}
363
+ });
364
+ },
365
+ async get(key) {
366
+ try {
367
+ const result = await getClient2().getObject({
368
+ Bucket: bucketName,
369
+ Key: key
370
+ });
371
+ const chunks = [];
372
+ const stream = result.Body;
373
+ for await (const chunk of stream) {
374
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
375
+ }
376
+ return {
377
+ body: Buffer.concat(chunks),
378
+ contentType: result.ContentType
379
+ };
380
+ } catch (error) {
381
+ if (error instanceof Error && (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404)) {
382
+ return void 0;
383
+ }
384
+ throw error;
385
+ }
386
+ },
387
+ async delete(key) {
388
+ await getClient2().deleteObject({
389
+ Bucket: bucketName,
390
+ Key: key
391
+ });
392
+ },
393
+ async list(prefix) {
394
+ const items = [];
395
+ let continuationToken;
396
+ do {
397
+ const result = await getClient2().listObjectsV2({
398
+ Bucket: bucketName,
399
+ ...prefix ? { Prefix: prefix } : {},
400
+ ...continuationToken ? { ContinuationToken: continuationToken } : {}
401
+ });
402
+ for (const obj of result.Contents ?? []) {
403
+ if (obj.Key) {
404
+ items.push({
405
+ key: obj.Key,
406
+ size: obj.Size ?? 0,
407
+ lastModified: obj.LastModified
408
+ });
409
+ }
410
+ }
411
+ continuationToken = result.IsTruncated ? result.NextContinuationToken : void 0;
412
+ } while (continuationToken);
413
+ return items;
414
+ }
415
+ });
416
+ var createEntityClient = (getClient2, bucketName, entityName, cacheSeconds) => {
417
+ const entityKey = (id) => `${entityName}/${id}.json`;
333
418
  return {
334
- bucketName,
335
- async put(key, body, options) {
419
+ async put(id, data) {
336
420
  await getClient2().putObject({
337
421
  Bucket: bucketName,
338
- Key: key,
339
- Body: typeof body === "string" ? Buffer.from(body) : body,
340
- ...options?.contentType ? { ContentType: options.contentType } : {}
422
+ Key: entityKey(id),
423
+ Body: JSON.stringify(data),
424
+ ContentType: "application/json",
425
+ ...cacheSeconds != null ? { CacheControl: `public, max-age=${cacheSeconds}` } : {}
341
426
  });
342
427
  },
343
- async get(key) {
428
+ async get(id) {
344
429
  try {
345
430
  const result = await getClient2().getObject({
346
431
  Bucket: bucketName,
347
- Key: key
432
+ Key: entityKey(id)
348
433
  });
349
434
  const chunks = [];
350
435
  const stream = result.Body;
351
436
  for await (const chunk of stream) {
352
437
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
353
438
  }
354
- return {
355
- body: Buffer.concat(chunks),
356
- contentType: result.ContentType
357
- };
439
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
358
440
  } catch (error) {
359
441
  if (error instanceof Error && (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404)) {
360
442
  return void 0;
@@ -362,28 +444,37 @@ var createBucketClient = (bucketName) => {
362
444
  throw error;
363
445
  }
364
446
  },
365
- async delete(key) {
447
+ async delete(id) {
366
448
  await getClient2().deleteObject({
367
449
  Bucket: bucketName,
368
- Key: key
450
+ Key: entityKey(id)
369
451
  });
370
452
  },
371
- async list(prefix) {
453
+ async list() {
372
454
  const items = [];
373
455
  let continuationToken;
456
+ const prefix = `${entityName}/`;
374
457
  do {
375
458
  const result = await getClient2().listObjectsV2({
376
459
  Bucket: bucketName,
377
- ...prefix ? { Prefix: prefix } : {},
460
+ Prefix: prefix,
378
461
  ...continuationToken ? { ContinuationToken: continuationToken } : {}
379
462
  });
380
463
  for (const obj of result.Contents ?? []) {
381
- if (obj.Key) {
382
- items.push({
383
- key: obj.Key,
384
- size: obj.Size ?? 0,
385
- lastModified: obj.LastModified
464
+ if (!obj.Key || !obj.Key.endsWith(".json")) continue;
465
+ const id = obj.Key.slice(prefix.length, -".json".length);
466
+ try {
467
+ const getResult = await getClient2().getObject({
468
+ Bucket: bucketName,
469
+ Key: obj.Key
386
470
  });
471
+ const chunks = [];
472
+ const stream = getResult.Body;
473
+ for await (const chunk of stream) {
474
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
475
+ }
476
+ items.push({ id, data: JSON.parse(Buffer.concat(chunks).toString("utf-8")) });
477
+ } catch {
387
478
  }
388
479
  }
389
480
  continuationToken = result.IsTruncated ? result.NextContinuationToken : void 0;
@@ -392,6 +483,21 @@ var createBucketClient = (bucketName) => {
392
483
  }
393
484
  };
394
485
  };
486
+ var createBucketClient = (bucketName) => {
487
+ let client2 = null;
488
+ const getClient2 = () => client2 ??= new S3({});
489
+ return createBucketMethods(getClient2, bucketName);
490
+ };
491
+ var createBucketClientWithEntities = (bucketName, entitiesConfig) => {
492
+ let client2 = null;
493
+ const getClient2 = () => client2 ??= new S3({});
494
+ const base = createBucketMethods(getClient2, bucketName);
495
+ const result = { ...base };
496
+ for (const [entityName, config] of Object.entries(entitiesConfig)) {
497
+ result[entityName] = createEntityClient(getClient2, bucketName, entityName, config.cacheSeconds);
498
+ }
499
+ return result;
500
+ };
395
501
 
396
502
  // src/runtime/handler-utils.ts
397
503
  import { readFileSync } from "fs";
@@ -566,7 +672,17 @@ var DEP_FACTORIES = {
566
672
  const tagField = depHandler?.__spec?.tagField;
567
673
  return createTableClient(name, tagField ? { tagField } : void 0);
568
674
  },
569
- bucket: (name) => createBucketClient(name),
675
+ bucket: (name, depHandler) => {
676
+ const entities = depHandler?.__spec?.entities;
677
+ if (entities && Object.keys(entities).length > 0) {
678
+ const config = {};
679
+ for (const [entityName, entityOpts] of Object.entries(entities)) {
680
+ config[entityName] = entityOpts.cache ? { cacheSeconds: toSeconds(entityOpts.cache) } : {};
681
+ }
682
+ return createBucketClientWithEntities(name, config);
683
+ }
684
+ return createBucketClient(name);
685
+ },
570
686
  mailer: () => createEmailClient(),
571
687
  queue: (name) => createQueueClient(name),
572
688
  worker: (name) => createWorkerClient(name)
@@ -630,6 +746,25 @@ var createHandlerRuntime = (handler, handlerType, logLevel = "info", extraSetupA
630
746
  resolvedParams = await buildParams(handler.config);
631
747
  return resolvedParams;
632
748
  };
749
+ let resolvedCfSigningConfig = null;
750
+ const getCfSigningConfig = async () => {
751
+ if (resolvedCfSigningConfig !== null) return resolvedCfSigningConfig;
752
+ const cfSigningKeySsmPath = process.env.EFF_CF_SIGNING_KEY;
753
+ const cfKeyPairId = process.env.EFF_CF_KEY_PAIR_ID;
754
+ const cfDomain = process.env.EFF_CF_DOMAIN;
755
+ if (!cfSigningKeySsmPath || !cfKeyPairId || !cfDomain) {
756
+ resolvedCfSigningConfig = void 0;
757
+ return void 0;
758
+ }
759
+ const values = await getParameters([cfSigningKeySsmPath]);
760
+ const privateKey = values.get(cfSigningKeySsmPath);
761
+ if (!privateKey) {
762
+ resolvedCfSigningConfig = void 0;
763
+ return void 0;
764
+ }
765
+ resolvedCfSigningConfig = { privateKey, keyPairId: cfKeyPairId, domain: cfDomain };
766
+ return resolvedCfSigningConfig;
767
+ };
633
768
  const getAuthRuntime = async (setupCtx) => {
634
769
  if (resolvedAuthRuntime !== null) return resolvedAuthRuntime;
635
770
  const authOpts = setupCtx && typeof setupCtx === "object" && "auth" in setupCtx ? setupCtx.auth : void 0;
@@ -644,12 +779,14 @@ var createHandlerRuntime = (handler, handlerType, logLevel = "info", extraSetupA
644
779
  const cacheTtlSeconds = apiToken?.cacheTtl ? toSeconds(apiToken.cacheTtl) : void 0;
645
780
  const rawVerify = apiToken?.verify;
646
781
  const wrappedVerify = rawVerify ? (args) => rawVerify(args.value) : void 0;
782
+ const cfSigningConfig = await getCfSigningConfig();
647
783
  resolvedAuthRuntime = createAuthRuntime(
648
784
  secret,
649
785
  defaultExpires,
650
786
  wrappedVerify,
651
787
  apiToken?.header,
652
- cacheTtlSeconds
788
+ cacheTtlSeconds,
789
+ cfSigningConfig
653
790
  );
654
791
  return resolvedAuthRuntime;
655
792
  };
@@ -733,8 +870,10 @@ var createHandlerRuntime = (handler, handlerType, logLevel = "info", extraSetupA
733
870
 
734
871
  export {
735
872
  createTableClient,
873
+ toSeconds,
736
874
  AUTH_COOKIE_NAME,
737
875
  createBucketClient,
876
+ createBucketClientWithEntities,
738
877
  buildDeps,
739
878
  buildParams,
740
879
  createHandlerRuntime
package/dist/index.d.ts CHANGED
@@ -263,6 +263,12 @@ type HttpResponse = {
263
263
  contentType?: ContentType;
264
264
  /** Response headers (use for custom content-types not covered by contentType) */
265
265
  headers?: Record<string, string>;
266
+ /**
267
+ * Multiple Set-Cookie values. Used by Lambda Function URLs to set multiple cookies
268
+ * in a single response (e.g., session cookie + CloudFront signed cookies).
269
+ * When present, takes precedence over `set-cookie` in `headers`.
270
+ */
271
+ cookies?: string[];
266
272
  /**
267
273
  * Set to `true` to return binary data.
268
274
  * When enabled, `body` must be a base64-encoded string and the response
@@ -317,7 +323,37 @@ type BucketClient = {
317
323
  /** The underlying S3 bucket name */
318
324
  bucketName: string;
319
325
  };
326
+ /**
327
+ * Typed client for a single entity stored as JSON in a bucket.
328
+ * Objects are stored at `{entityName}/{id}.json`.
329
+ */
330
+ type StoreEntityClient<T> = {
331
+ /** Store a JSON document by id */
332
+ put(id: string, data: T): Promise<void>;
333
+ /** Retrieve a JSON document by id. Returns undefined if not found. */
334
+ get(id: string): Promise<T | undefined>;
335
+ /** Delete a document by id */
336
+ delete(id: string): Promise<void>;
337
+ /** List all documents for this entity */
338
+ list(): Promise<{
339
+ id: string;
340
+ data: T;
341
+ }[]>;
342
+ };
343
+ /**
344
+ * BucketClient extended with typed entity clients.
345
+ */
346
+ type BucketClientWithEntities<Entities extends Record<string, any>> = BucketClient & {
347
+ [K in keyof Entities]: StoreEntityClient<Entities[K]>;
348
+ };
320
349
 
350
+ /**
351
+ * Per-entity configuration for typed JSON key-value storage within a bucket.
352
+ */
353
+ type BucketEntityConfig = {
354
+ /** Cache duration for CloudFront/browser caching (e.g., "10s", "5m", "1h"). No caching if omitted. */
355
+ cache?: Duration;
356
+ };
321
357
  /**
322
358
  * Configuration options for defineBucket.
323
359
  */
@@ -333,6 +369,8 @@ type BucketConfig = {
333
369
  prefix?: string;
334
370
  /** S3 key suffix filter for event notifications (e.g., ".jpg") */
335
371
  suffix?: string;
372
+ /** Typed JSON entity definitions for key-value storage */
373
+ entities?: Record<string, BucketEntityConfig>;
336
374
  };
337
375
  /**
338
376
  * S3 event record passed to onObjectCreated/onObjectRemoved callbacks.
@@ -354,8 +392,8 @@ type BucketEvent = {
354
392
  /** Spread ctx into callback args (empty when no setup) */
355
393
  type SpreadCtx$6<C> = [C] extends [undefined] ? {} : C & {};
356
394
  /** Setup factory — receives bucket/deps/config/files based on what was declared */
357
- type SetupArgs$6<D, P, HasFiles extends boolean> = {
358
- bucket: BucketClient;
395
+ type SetupArgs$6<D, P, HasFiles extends boolean, Entities extends Record<string, any> = {}> = {
396
+ bucket: {} extends Entities ? BucketClient : BucketClientWithEntities<Entities>;
359
397
  } & ([D] extends [undefined] ? {} : {
360
398
  deps: ResolveDeps<D>;
361
399
  }) & ([P] extends [undefined] ? {} : {
@@ -379,7 +417,7 @@ type BucketObjectRemovedFn<C = undefined> = (args: {
379
417
  * Internal handler object created by defineBucket
380
418
  * @internal
381
419
  */
382
- type BucketHandler$1<C = any> = {
420
+ type BucketHandler$1<C = any, Entities extends Record<string, any> = {}> = {
383
421
  readonly __brand: "effortless-bucket";
384
422
  readonly __spec: BucketConfig;
385
423
  readonly onError?: (...args: any[]) => any;
@@ -398,31 +436,35 @@ type BucketOptions = {
398
436
  /** S3 key suffix filter for event notifications (e.g., ".jpg") */
399
437
  suffix?: string;
400
438
  };
401
- interface BucketBuilder<D = undefined, P = undefined, C = undefined, HasFiles extends boolean = false> {
439
+ interface BucketBuilder<D = undefined, P = undefined, C = undefined, HasFiles extends boolean = false, Entities extends Record<string, any> = {}> {
402
440
  /** Declare handler dependencies */
403
- deps<D2 extends Record<string, AnyDepHandler>>(fn: () => D2): BucketBuilder<D2, P, C, HasFiles>;
441
+ deps<D2 extends Record<string, AnyDepHandler>>(fn: () => D2): BucketBuilder<D2, P, C, HasFiles, Entities>;
404
442
  /** Declare SSM secrets */
405
- config<P2 extends Record<string, AnySecretRef>>(fn: ConfigFactory<P2>): BucketBuilder<D, P2, C, HasFiles>;
443
+ config<P2 extends Record<string, AnySecretRef>>(fn: ConfigFactory<P2>): BucketBuilder<D, P2, C, HasFiles, Entities>;
406
444
  /** Include static files in the Lambda ZIP */
407
- include(glob: string): BucketBuilder<D, P, C, true>;
445
+ include(glob: string): BucketBuilder<D, P, C, true, Entities>;
446
+ /** Register a typed JSON entity stored as `{name}/{id}.json` in the bucket */
447
+ entity<N extends string, T>(name: N, options?: BucketEntityConfig): BucketBuilder<D, P, C, HasFiles, Entities & {
448
+ [K in N]: T;
449
+ }>;
408
450
  /** Initialize shared state on cold start with lambda options */
409
- setup(lambda: LambdaOptions): BucketBuilder<D, P, C, HasFiles>;
451
+ setup(lambda: LambdaOptions): BucketBuilder<D, P, C, HasFiles, Entities>;
410
452
  /** Initialize shared state on cold start. Receives bucket (self-client), deps, config, files. */
411
- setup<C2>(fn: (args: SetupArgs$6<D, P, HasFiles>) => C2 | Promise<C2>): BucketBuilder<D, P, C2, HasFiles>;
453
+ setup<C2>(fn: (args: SetupArgs$6<D, P, HasFiles, Entities>) => C2 | Promise<C2>): BucketBuilder<D, P, C2, HasFiles, Entities>;
412
454
  /** Initialize shared state on cold start with lambda options. Receives bucket (self-client), deps, config, files. */
413
- setup<C2>(fn: (args: SetupArgs$6<D, P, HasFiles>) => C2 | Promise<C2>, lambda: LambdaOptions): BucketBuilder<D, P, C2, HasFiles>;
455
+ setup<C2>(fn: (args: SetupArgs$6<D, P, HasFiles, Entities>) => C2 | Promise<C2>, lambda: LambdaOptions): BucketBuilder<D, P, C2, HasFiles, Entities>;
414
456
  /** Handle errors thrown by callbacks */
415
457
  onError(fn: (args: {
416
458
  error: unknown;
417
- } & SpreadCtx$6<C>) => void | Promise<void>): BucketBuilder<D, P, C, HasFiles>;
459
+ } & SpreadCtx$6<C>) => void | Promise<void>): BucketBuilder<D, P, C, HasFiles, Entities>;
418
460
  /** Cleanup callback — runs after each invocation, before Lambda freezes */
419
- onCleanup(fn: (args: SpreadCtx$6<C>) => void | Promise<void>): BucketBuilder<D, P, C, HasFiles>;
461
+ onCleanup(fn: (args: SpreadCtx$6<C>) => void | Promise<void>): BucketBuilder<D, P, C, HasFiles, Entities>;
420
462
  /** Handle S3 ObjectCreated events (terminal — returns finalized handler) */
421
- onObjectCreated(fn: BucketObjectCreatedFn<C>): BucketHandler$1<C>;
463
+ onObjectCreated(fn: BucketObjectCreatedFn<C>): BucketHandler$1<C, Entities>;
422
464
  /** Handle S3 ObjectRemoved events (terminal — returns finalized handler) */
423
- onObjectRemoved(fn: BucketObjectRemovedFn<C>): BucketHandler$1<C>;
465
+ onObjectRemoved(fn: BucketObjectRemovedFn<C>): BucketHandler$1<C, Entities>;
424
466
  /** Finalize as resource-only bucket (no Lambda) */
425
- build(): BucketHandler$1<C>;
467
+ build(): BucketHandler$1<C, Entities>;
426
468
  }
427
469
  /**
428
470
  * Define an S3 bucket with optional event handlers.
@@ -926,10 +968,10 @@ type WorkerClient<T = unknown> = {
926
968
  };
927
969
 
928
970
  /** Dep value types supported by the deps declaration */
929
- type AnyDepHandler = TableHandler$1<any, any> | BucketHandler$1<any> | MailerHandler | FifoQueueHandler$1<any, any> | WorkerHandler$1<any, any>;
971
+ type AnyDepHandler = TableHandler$1<any, any> | BucketHandler$1<any, any> | MailerHandler | FifoQueueHandler$1<any, any> | WorkerHandler$1<any, any>;
930
972
  /** Maps a deps declaration to resolved runtime client types */
931
973
  type ResolveDeps<D> = {
932
- [K in keyof D]: D[K] extends TableHandler$1<infer T> ? TableClient<T> : D[K] extends BucketHandler$1 ? BucketClient : D[K] extends MailerHandler ? EmailClient : D[K] extends FifoQueueHandler$1<infer T> ? QueueClient<T> : D[K] extends WorkerHandler$1<infer T> ? WorkerClient<T> : never;
974
+ [K in keyof D]: D[K] extends TableHandler$1<infer T> ? TableClient<T> : D[K] extends BucketHandler$1<any, infer E> ? ({} extends E ? BucketClient : BucketClientWithEntities<E>) : D[K] extends MailerHandler ? EmailClient : D[K] extends FifoQueueHandler$1<infer T> ? QueueClient<T> : D[K] extends WorkerHandler$1<infer T> ? WorkerClient<T> : never;
933
975
  };
934
976
 
935
977
  /** DynamoDB Streams view type - determines what data is captured in stream records */
@@ -1160,6 +1202,18 @@ declare const defineApp: () => (options: AppConfig) => AppHandler;
1160
1202
  type AnyRoutableHandler = {
1161
1203
  readonly __brand: string;
1162
1204
  };
1205
+ /** Route configuration for serving bucket content through CloudFront */
1206
+ type BucketRouteConfig = {
1207
+ bucket: {
1208
+ readonly __brand: "effortless-bucket";
1209
+ };
1210
+ /** Access control: "private" requires CloudFront signed cookies, "public" serves openly. Default: "public" */
1211
+ access?: "private" | "public";
1212
+ };
1213
+ /** A route value: either an API handler or a bucket route config */
1214
+ type RouteValue = AnyRoutableHandler | BucketRouteConfig;
1215
+ /** Type guard for bucket route entries */
1216
+ declare const isBucketRoute: (v: unknown) => v is BucketRouteConfig;
1163
1217
  /** Simplified request object passed to middleware */
1164
1218
  type MiddlewareRequest = {
1165
1219
  uri: string;
@@ -1206,10 +1260,10 @@ type StaticSiteConfig = {
1206
1260
  build?: string;
1207
1261
  /** Custom domain name. Accepts a string (same domain for all stages) or a Record mapping stage names to domains (e.g., `{ prod: "example.com", dev: "dev.example.com" }`). Requires an ACM certificate in us-east-1. If the cert also covers www, a 301 redirect from www to non-www is set up automatically. */
1208
1262
  domain?: string | Record<string, string>;
1209
- /** CloudFront route overrides: path patterns forwarded to API Gateway instead of S3.
1210
- * Keys are CloudFront path patterns (e.g., "/api/*"), values are HTTP handlers.
1211
- * Example: `routes: { "/api/*": api }` */
1212
- routes?: Record<string, AnyRoutableHandler>;
1263
+ /** CloudFront route overrides: path patterns forwarded to API Gateway or S3 bucket origins.
1264
+ * Keys are CloudFront path patterns (e.g., "/api/*"), values are HTTP handlers or bucket route configs.
1265
+ * Example: `routes: { "/api/*": api, "/files/*": { bucket: storage, access: "private" } }` */
1266
+ routes?: Record<string, RouteValue>;
1213
1267
  /** Custom 404 error page path relative to `dir` (e.g. "404.html").
1214
1268
  * For non-SPA sites only. If not set, a default page is generated automatically. */
1215
1269
  errorPage?: string;
@@ -1236,17 +1290,27 @@ type StaticSiteHandler = {
1236
1290
  */
1237
1291
  declare const defineStaticSite: () => (options: StaticSiteConfig) => StaticSiteHandler;
1238
1292
 
1293
+ /** Options for CloudFront signed cookie policy */
1294
+ type CdnPolicyOptions = {
1295
+ /** Path pattern to grant access to (e.g., "/files/users/123/*"). Supports `*` and `?` wildcards. */
1296
+ path: string;
1297
+ /** How long the CDN access is valid (e.g., "1h", "30m") */
1298
+ ttl: Duration;
1299
+ };
1239
1300
  /** Options for creating a session */
1240
1301
  type SessionOptions = {
1241
1302
  expiresIn?: Duration;
1303
+ /** CloudFront signed cookie policy for CDN-level access control */
1304
+ cdnPolicy?: CdnPolicyOptions;
1242
1305
  };
1243
- /** Session response with Set-Cookie header */
1306
+ /** Session response with Set-Cookie headers */
1244
1307
  type SessionResponse = {
1245
1308
  status: 200;
1246
1309
  body: {
1247
1310
  ok: true;
1248
1311
  };
1249
1312
  headers: Record<string, string>;
1313
+ cookies?: string[];
1250
1314
  };
1251
1315
  /**
1252
1316
  * Auth helpers injected into API handler callback args when `auth` is configured.
@@ -1300,12 +1364,28 @@ type OkHelper = (body?: unknown, status?: number) => HttpResponse;
1300
1364
  /** Error response helper: `fail("message")` → `{ status: 400, body: { error: "message" } }` */
1301
1365
  type FailHelper = (message: string, status?: number) => HttpResponse;
1302
1366
  type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
1367
+ /** Cache options for a GET route. Duration shorthand (e.g. "30s", "5m") or object for fine-grained control. */
1368
+ type CacheOptions = Duration | {
1369
+ ttl: Duration;
1370
+ swr?: Duration;
1371
+ scope?: "public" | "private";
1372
+ };
1373
+ /** Resolved cache config with numeric seconds */
1374
+ type ResolvedCache = {
1375
+ private?: false;
1376
+ ttl: number;
1377
+ swr: number;
1378
+ } | {
1379
+ private: true;
1380
+ ttl: number;
1381
+ };
1303
1382
  /** Parsed route definition stored at runtime */
1304
1383
  type RouteEntry = {
1305
1384
  method: HttpMethod;
1306
1385
  path: string;
1307
1386
  onRequest: (...args: any[]) => any;
1308
1387
  public?: boolean;
1388
+ cache?: ResolvedCache;
1309
1389
  };
1310
1390
  /** Spread ctx into route args: Omit auth config, add AuthHelpers if present */
1311
1391
  type SpreadCtx$2<C> = ([C] extends [undefined] ? {} : Omit<C & {}, 'auth'>) & ([ExtractAuth<C>] extends [undefined] ? {} : {
@@ -1322,10 +1402,14 @@ type RouteArgs<C, ST> = SpreadCtx$2<C> & {
1322
1402
  } : {});
1323
1403
  /** Route handler function */
1324
1404
  type RouteHandler<C, ST> = (args: RouteArgs<C, ST>) => Promise<HttpResponse | void> | HttpResponse | void;
1325
- /** Route options (e.g. public) */
1405
+ /** Route options for all methods */
1326
1406
  type RouteOptions = {
1327
1407
  public?: boolean;
1328
1408
  };
1409
+ /** Route options for GET — supports caching */
1410
+ type GetRouteOptions = RouteOptions & {
1411
+ cache?: CacheOptions;
1412
+ };
1329
1413
  /** Setup factory — receives deps/config/files/enableAuth based on what was declared */
1330
1414
  type SetupArgs$2<D, P, HasFiles extends boolean> = {
1331
1415
  enableAuth: EnableAuth;
@@ -1375,7 +1459,7 @@ type ApiOptions = {
1375
1459
  * Has `__brand` so CLI discovers it. Each `.get()/.post()` adds a route and returns self.
1376
1460
  */
1377
1461
  interface ApiRoutes<C = undefined, ST extends boolean = false> extends ApiHandler<C> {
1378
- get(path: `/${string}`, handler: RouteHandler<C, ST>, options?: RouteOptions): ApiRoutes<C, ST>;
1462
+ get(path: `/${string}`, handler: RouteHandler<C, ST>, options?: GetRouteOptions): ApiRoutes<C, ST>;
1379
1463
  post(path: `/${string}`, handler: RouteHandler<C, ST>, options?: RouteOptions): ApiRoutes<C, ST>;
1380
1464
  put(path: `/${string}`, handler: RouteHandler<C, ST>, options?: RouteOptions): ApiRoutes<C, ST>;
1381
1465
  patch(path: `/${string}`, handler: RouteHandler<C, ST>, options?: RouteOptions): ApiRoutes<C, ST>;
@@ -1410,7 +1494,7 @@ interface ApiBuilder<D = undefined, P = undefined, C = undefined, ST extends boo
1410
1494
  /** Cleanup callback — runs after each invocation, before Lambda freezes */
1411
1495
  onCleanup(fn: (args: SpreadCtx$2<C>) => void | Promise<void>): ApiBuilder<D, P, C, ST, HasFiles>;
1412
1496
  /** Add a GET route (terminal — returns finalized handler with route methods) */
1413
- get(path: `/${string}`, handler: RouteHandler<C, ST>, options?: RouteOptions): ApiRoutes<C, ST>;
1497
+ get(path: `/${string}`, handler: RouteHandler<C, ST>, options?: GetRouteOptions): ApiRoutes<C, ST>;
1414
1498
  /** Add a POST route (terminal) */
1415
1499
  post(path: `/${string}`, handler: RouteHandler<C, ST>, options?: RouteOptions): ApiRoutes<C, ST>;
1416
1500
  /** Add a PUT route (terminal) */
@@ -1685,9 +1769,9 @@ declare function defineMcp(options: McpOptions): McpBuilder;
1685
1769
 
1686
1770
  type TableHandler<T = Record<string, unknown>> = TableHandler$1<T, any>;
1687
1771
  type FifoQueueHandler<T = unknown> = FifoQueueHandler$1<T, any>;
1688
- type BucketHandler = BucketHandler$1<any>;
1772
+ type BucketHandler<Entities extends Record<string, any> = {}> = BucketHandler$1<any, Entities>;
1689
1773
  type CronHandler = CronHandler$1<any>;
1690
1774
  type WorkerHandler<T = any> = WorkerHandler$1<T, any>;
1691
1775
  type McpHandler = McpHandler$1<any>;
1692
1776
 
1693
- export { type ApiAuthConfig, type ApiConfig, type ApiHandler, type ApiRoutes, type AppConfig, type AppHandler, type AuthHelpers, type BucketClient, type BucketConfig, type BucketEvent, type BucketHandler, type ConfigHelpers, type ContentType, type CronConfig, type CronHandler, type DefineSecretFn, type Duration, type EffortlessConfig, type EmailClient, type FifoQueueConfig, type FifoQueueHandler, type FifoQueueMessage, type HttpMethod$1 as HttpMethod, type HttpRequest, type HttpResponse, type LogLevel, type MailerConfig, type MailerHandler, type McpConfig, type McpHandler, type McpInputSchema, type McpToolContent, type McpToolDef, type McpToolResult, type MiddlewareDeny, type MiddlewareHandler, type MiddlewareRedirect, type MiddlewareRequest, type MiddlewareResult, type ParamRef, type Permission, type PutInput, type PutOptions, type QueryByTagParams, type QueryParams, type QueueClient, type ResponseStream, type SecretRef, type SendEmailOptions, type SendMessageInput, type SkCondition, type StaticFiles, type StaticSiteConfig, type StaticSiteHandler, type StaticSiteSeo, type StreamView, type TableClient, type TableConfig, type TableHandler, type TableItem, type TableKey, type TableRecord, type Timezone, type UpdateActions, type WorkerClient, type WorkerConfig, type WorkerHandler, type WorkerSendOptions, defineApi, defineApp, defineBucket, defineConfig, defineCron, defineFifoQueue, defineMailer, defineMcp, defineSecret, defineStaticSite, defineTable, defineWorker, generateBase64, generateHex, generateUuid, param, secret, toSeconds };
1777
+ export { type ApiAuthConfig, type ApiConfig, type ApiHandler, type ApiRoutes, type AppConfig, type AppHandler, type AuthHelpers, type BucketClient, type BucketClientWithEntities, type BucketConfig, type BucketEntityConfig, type BucketEvent, type BucketHandler, type BucketRouteConfig, type CacheOptions, type CdnPolicyOptions, type ConfigHelpers, type ContentType, type CronConfig, type CronHandler, type DefineSecretFn, type Duration, type EffortlessConfig, type EmailClient, type FifoQueueConfig, type FifoQueueHandler, type FifoQueueMessage, type HttpMethod$1 as HttpMethod, type HttpRequest, type HttpResponse, type LogLevel, type MailerConfig, type MailerHandler, type McpConfig, type McpHandler, type McpInputSchema, type McpToolContent, type McpToolDef, type McpToolResult, type MiddlewareDeny, type MiddlewareHandler, type MiddlewareRedirect, type MiddlewareRequest, type MiddlewareResult, type ParamRef, type Permission, type PutInput, type PutOptions, type QueryByTagParams, type QueryParams, type QueueClient, type ResponseStream, type SecretRef, type SendEmailOptions, type SendMessageInput, type SkCondition, type StaticFiles, type StaticSiteConfig, type StaticSiteHandler, type StaticSiteSeo, type StoreEntityClient, type StreamView, type TableClient, type TableConfig, type TableHandler, type TableItem, type TableKey, type TableRecord, type Timezone, type UpdateActions, type WorkerClient, type WorkerConfig, type WorkerHandler, type WorkerSendOptions, defineApi, defineApp, defineBucket, defineConfig, defineCron, defineFifoQueue, defineMailer, defineMcp, defineSecret, defineStaticSite, defineTable, defineWorker, generateBase64, generateHex, generateUuid, isBucketRoute, param, secret, toSeconds };
package/dist/index.js CHANGED
@@ -116,6 +116,7 @@ var defineApp = () => (options) => ({
116
116
  });
117
117
 
118
118
  // src/handlers/define-static-site.ts
119
+ var isBucketRoute = (v) => v != null && typeof v === "object" && "bucket" in v && v.bucket != null && typeof v.bucket === "object" && v.bucket.__brand === "effortless-bucket";
119
120
  var defineStaticSite = () => (options) => ({
120
121
  __brand: "effortless-static-site",
121
122
  __spec: options
@@ -231,6 +232,13 @@ function defineBucket(options) {
231
232
  state.static = [...state.static ?? [], glob];
232
233
  return builder;
233
234
  },
235
+ entity(name, options2) {
236
+ state.spec = {
237
+ ...state.spec,
238
+ entities: { ...state.spec.entities, [name]: options2 ?? {} }
239
+ };
240
+ return builder;
241
+ },
234
242
  setup(fnOrLambda, maybeLambda) {
235
243
  if (typeof fnOrLambda === "function") {
236
244
  state.setup = fnOrLambda;
@@ -270,6 +278,20 @@ var defineMailer = () => (options) => ({
270
278
  });
271
279
 
272
280
  // src/handlers/define-api.ts
281
+ var resolveCache = (cache) => {
282
+ if (typeof cache === "number" || typeof cache === "string") {
283
+ const ttl2 = toSeconds(cache);
284
+ return { ttl: ttl2, swr: ttl2 * 2 };
285
+ }
286
+ const ttl = toSeconds(cache.ttl);
287
+ if (cache.scope === "private") {
288
+ return { private: true, ttl };
289
+ }
290
+ return {
291
+ ttl,
292
+ swr: cache.swr != null ? toSeconds(cache.swr) : ttl * 2
293
+ };
294
+ };
273
295
  function defineApi(options) {
274
296
  const { basePath, stream } = options;
275
297
  const state = {
@@ -280,11 +302,13 @@ function defineApi(options) {
280
302
  routes: []
281
303
  };
282
304
  const addRoute = (method, path, handler, opts) => {
305
+ const routeCache = opts?.cache != null ? resolveCache(opts.cache) : void 0;
283
306
  state.routes.push({
284
307
  method,
285
308
  path,
286
309
  onRequest: handler,
287
- ...opts?.public ? { public: true } : {}
310
+ ...opts?.public ? { public: true } : {},
311
+ ...routeCache ? { cache: routeCache } : {}
288
312
  });
289
313
  };
290
314
  const applyLambdaOptions = (lambda) => {
@@ -567,6 +591,7 @@ export {
567
591
  generateBase64,
568
592
  generateHex,
569
593
  generateUuid,
594
+ isBucketRoute,
570
595
  param,
571
596
  secret,
572
597
  toSeconds