@shaferllc/keel 0.58.0 → 0.66.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.
@@ -2,7 +2,7 @@
2
2
  export { Container } from "./container.js";
3
3
  export { Application } from "./application.js";
4
4
  export { Config, env } from "./config.js";
5
- export { app, config, view, bind, singleton, instance, make, bound, events, emit, listen, cache, logger, onReady, onShutdown, terminate, } from "./helpers.js";
5
+ export { app, config, view, bind, singleton, instance, make, bound, alias, swap, restore, events, emit, listen, cache, logger, onReady, onShutdown, terminate, } from "./helpers.js";
6
6
  export { Logger } from "./logger.js";
7
7
  export { requestLogger, requestLog } from "./request-logger.js";
8
8
  export { Events } from "./events.js";
@@ -10,9 +10,12 @@ export { Cache, MemoryStore } from "./cache.js";
10
10
  export { serveStatic } from "./static.js";
11
11
  export { Storage, MemoryDisk, storage, setDisk } from "./storage.js";
12
12
  export { dump, dd } from "./debug.js";
13
- export { hash, encryption } from "./crypto.js";
13
+ export { hash, encryption, jwt } from "./crypto.js";
14
14
  export { rateLimiter } from "./rate-limit.js";
15
- export { db, setConnection, QueryBuilder } from "./database.js";
15
+ export { cors } from "./cors.js";
16
+ export { securityHeaders } from "./shield.js";
17
+ export { csrf, csrfToken, csrfField } from "./csrf.js";
18
+ export { db, connection, setConnection, addConnection, setDefaultConnection, connectionNames, clearConnections, QueryBuilder, } from "./database.js";
16
19
  export { Model } from "./model.js";
17
20
  export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany } from "./relations.js";
18
21
  export { Faker, Factory as ModelFactory, factory, Seeder, seed } from "./factory.js";
@@ -35,8 +38,10 @@ export { TestClient, TestResponse, testClient } from "./testing.js";
35
38
  export { HttpException, BadRequestException, UnauthorizedException, PaymentRequiredException, ForbiddenException, NotFoundException, MethodNotAllowedException, NotAcceptableException, RequestTimeoutException, ConflictException, LengthRequiredException, ValidationException, TooManyRequestsException, ServerErrorException, NotImplementedException, BadGatewayException, ServiceUnavailableException, createError, STATUS_TEXT, } from "./exceptions.js";
36
39
  export { validate, validateRequest, validated } from "./validation.js";
37
40
  export { Session, session, sessionMiddleware } from "./session.js";
38
- export { Auth, auth, authGuard, setUserProvider } from "./auth.js";
39
- export { define, policy, gateBefore, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
41
+ export { Auth, auth, authGuard, bearerAuth, basicAuth, tokenAuth, token, tokenCan, setUserProvider } from "./auth.js";
42
+ export { createToken, verifyToken, revokeToken, revokeTokens, listTokens, tokenAllows, tokenDenies, setTokensTable, } from "./tokens.js";
43
+ export { social, github, google, discord, oauthDriver, oauthState, OAuthDriver, OAuthError, twitter, oauth1Driver, oauth1Signature, OAuth1Driver, } from "./social.js";
44
+ export { define, policy, gateBefore, gateAfter, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
40
45
  export { Transformer } from "./transformer.js";
41
46
  export { Broker, Service, LocalTransporter, ServiceNotFoundError, RequestTimeoutError, broker, setBroker, } from "./broker.js";
42
47
  export { Vite, viteTags, viteAsset, viteReactRefresh } from "./vite.js";
@@ -22,6 +22,8 @@ type ModelClass<T extends Model> = (new (attributes?: Row) => T) & typeof Model;
22
22
  export declare class Model {
23
23
  static table: string;
24
24
  static primaryKey: string;
25
+ /** Which registered connection this model uses; the default when unset. */
26
+ static connection: string | undefined;
25
27
  /** Columns mass-assignable via `create`/`fill` (allowlist). */
26
28
  static fillable: string[];
27
29
  /** Columns NOT mass-assignable (denylist). Ignored when `fillable` is set. */
@@ -34,6 +34,8 @@ function serialize(value) {
34
34
  export class Model {
35
35
  static table = "";
36
36
  static primaryKey = "id";
37
+ /** Which registered connection this model uses; the default when unset. */
38
+ static connection = undefined;
37
39
  /** Columns mass-assignable via `create`/`fill` (allowlist). */
38
40
  static fillable = [];
39
41
  /** Columns NOT mass-assignable (denylist). Ignored when `fillable` is set. */
@@ -51,7 +53,7 @@ export class Model {
51
53
  /* ------------------------------ static -------------------------------- */
52
54
  /** A raw query builder scoped to this model's table. */
53
55
  static query() {
54
- return db(this.table);
56
+ return db(this.table, this.connection);
55
57
  }
56
58
  /** Keep only the attributes mass-assignment allows (fillable / guarded). */
57
59
  static filterFillable(attributes) {
@@ -86,11 +88,11 @@ export class Model {
86
88
  return out;
87
89
  }
88
90
  static async all() {
89
- const rows = await db(this.table).get();
91
+ const rows = await db(this.table, this.connection).get();
90
92
  return rows.map((row) => new this(row));
91
93
  }
92
94
  static async find(id) {
93
- const row = await db(this.table).where(this.primaryKey, id).first();
95
+ const row = await db(this.table, this.connection).where(this.primaryKey, id).first();
94
96
  return row ? new this(row) : null;
95
97
  }
96
98
  static async findOrFail(id) {
@@ -100,18 +102,18 @@ export class Model {
100
102
  return model;
101
103
  }
102
104
  static async first() {
103
- const row = await db(this.table).first();
105
+ const row = await db(this.table, this.connection).first();
104
106
  return row ? new this(row) : null;
105
107
  }
106
108
  /** Fetch models matching a simple equality condition. */
107
109
  static async where(column, value) {
108
- const rows = await db(this.table).where(column, value).get();
110
+ const rows = await db(this.table, this.connection).where(column, value).get();
109
111
  return rows.map((row) => new this(row));
110
112
  }
111
113
  static async create(attributes) {
112
114
  const filtered = this.filterFillable(attributes);
113
115
  const write = this.stampTimestamps(this.toDatabase(filtered), true);
114
- const id = await db(this.table).insertGetId(write);
116
+ const id = await db(this.table, this.connection).insertGetId(write);
115
117
  const model = new this(filtered);
116
118
  if (this.timestamps) {
117
119
  model[this.createdAtColumn] = write[this.createdAtColumn];
@@ -123,7 +125,7 @@ export class Model {
123
125
  }
124
126
  /** A page of models plus pagination metadata. */
125
127
  static async paginate(page = 1, perPage = 15) {
126
- const result = await db(this.table).paginate(page, perPage);
128
+ const result = await db(this.table, this.connection).paginate(page, perPage);
127
129
  return { ...result, data: result.data.map((row) => new this(row)) };
128
130
  }
129
131
  /** Find the first row matching `match`, or create one from `{ ...match, ...values }`. */
@@ -145,7 +147,7 @@ export class Model {
145
147
  }
146
148
  /** A query scoped to every column/value in `match`. */
147
149
  static matching(match) {
148
- let q = db(this.table);
150
+ let q = db(this.table, this.connection);
149
151
  for (const [column, value] of Object.entries(match))
150
152
  q = q.where(column, value);
151
153
  return q;
@@ -209,7 +211,7 @@ export class Model {
209
211
  /** Insert (no primary key) or update (has one). */
210
212
  async save() {
211
213
  const ctor = this.ctor();
212
- const { table, primaryKey } = ctor;
214
+ const { table, primaryKey, connection } = ctor;
213
215
  const idValue = this[primaryKey];
214
216
  const forInsert = idValue == null;
215
217
  const data = ctor.stampTimestamps(ctor.toDatabase({ ...this }), forInsert);
@@ -221,10 +223,10 @@ export class Model {
221
223
  }
222
224
  delete data[primaryKey];
223
225
  if (!forInsert) {
224
- await db(table).where(primaryKey, idValue).update(data);
226
+ await db(table, connection).where(primaryKey, idValue).update(data);
225
227
  }
226
228
  else {
227
- const id = await db(table).insertGetId(data);
229
+ const id = await db(table, connection).insertGetId(data);
228
230
  if (id != null)
229
231
  this[primaryKey] = id;
230
232
  }
@@ -237,14 +239,14 @@ export class Model {
237
239
  /** Reload this model's columns from the database. */
238
240
  async refresh() {
239
241
  const ctor = this.ctor();
240
- const row = await db(ctor.table).where(ctor.primaryKey, this[ctor.primaryKey]).first();
242
+ const row = await db(ctor.table, ctor.connection).where(ctor.primaryKey, this[ctor.primaryKey]).first();
241
243
  if (row)
242
244
  Object.assign(this, applyCasts(row, ctor.casts, castGet));
243
245
  return this;
244
246
  }
245
247
  async delete() {
246
- const { table, primaryKey } = this.ctor();
247
- await db(table).where(primaryKey, this[primaryKey]).delete();
248
+ const { table, primaryKey, connection } = this.ctor();
249
+ await db(table, connection).where(primaryKey, this[primaryKey]).delete();
248
250
  }
249
251
  /** Merge mass-assignable attributes into the model (cast, not saved). */
250
252
  fill(attributes) {
@@ -6,6 +6,11 @@
6
6
  * here — nothing is guaranteed to be registered yet.
7
7
  * boot(): called after every provider has registered. Safe to resolve
8
8
  * and wire things together here.
9
+ * ready(): called once the whole app has booted (after every provider's
10
+ * boot() and any onReady hooks). For work that needs a fully-live
11
+ * app — attaching to the running server, warming a cache.
12
+ * shutdown(): called during graceful termination, in reverse registration
13
+ * order (LIFO). Close connections, flush queues, cancel timers.
9
14
  *
10
15
  * Register with options to make a provider reusable:
11
16
  *
@@ -21,5 +26,7 @@ export declare abstract class ServiceProvider<O = Record<string, unknown>> {
21
26
  constructor(app: Application, options?: O);
22
27
  register(): void | Promise<void>;
23
28
  boot(): void | Promise<void>;
29
+ ready(): void | Promise<void>;
30
+ shutdown(): void | Promise<void>;
24
31
  }
25
32
  export type ProviderClass = new (app: Application, options?: any) => ServiceProvider;
@@ -6,6 +6,11 @@
6
6
  * here — nothing is guaranteed to be registered yet.
7
7
  * boot(): called after every provider has registered. Safe to resolve
8
8
  * and wire things together here.
9
+ * ready(): called once the whole app has booted (after every provider's
10
+ * boot() and any onReady hooks). For work that needs a fully-live
11
+ * app — attaching to the running server, warming a cache.
12
+ * shutdown(): called during graceful termination, in reverse registration
13
+ * order (LIFO). Close connections, flush queues, cancel timers.
9
14
  *
10
15
  * Register with options to make a provider reusable:
11
16
  *
@@ -23,4 +28,6 @@ export class ServiceProvider {
23
28
  }
24
29
  register() { }
25
30
  boot() { }
31
+ ready() { }
32
+ shutdown() { }
26
33
  }
@@ -35,10 +35,12 @@ export function rateLimiter(options = {}) {
35
35
  }
36
36
  bucket.count++;
37
37
  const remaining = Math.max(0, max - bucket.count);
38
+ const resetSeconds = Math.ceil(bucket.reset / 1000);
38
39
  if (bucket.count > max) {
39
40
  return c.json({ error: options.message ?? "Too Many Requests", status: 429 }, 429, {
40
41
  "X-RateLimit-Limit": String(max),
41
42
  "X-RateLimit-Remaining": "0",
43
+ "X-RateLimit-Reset": String(resetSeconds),
42
44
  "Retry-After": String(Math.ceil((bucket.reset - now) / 1000)),
43
45
  });
44
46
  }
@@ -46,5 +48,6 @@ export function rateLimiter(options = {}) {
46
48
  // Set headers on the final response (survives any handler type).
47
49
  c.header("X-RateLimit-Limit", String(max));
48
50
  c.header("X-RateLimit-Remaining", String(remaining));
51
+ c.header("X-RateLimit-Reset", String(resetSeconds));
49
52
  };
50
53
  }
@@ -56,7 +56,7 @@ export class HasMany extends Relation {
56
56
  return this.parent[this.localKey];
57
57
  }
58
58
  query() {
59
- return db(this.related.table).where(this.foreignKey, this.localValue());
59
+ return db(this.related.table, this.related.connection).where(this.foreignKey, this.localValue());
60
60
  }
61
61
  async get() {
62
62
  return this.hydrate(await this.query().get());
@@ -64,7 +64,7 @@ export class HasMany extends Relation {
64
64
  async eager(models, name) {
65
65
  const keys = unique(models.map((m) => m[this.localKey]).filter((v) => v != null));
66
66
  const rows = keys.length
67
- ? await db(this.related.table).whereIn(this.foreignKey, keys).get()
67
+ ? await db(this.related.table, this.related.connection).whereIn(this.foreignKey, keys).get()
68
68
  : [];
69
69
  const grouped = new Map();
70
70
  for (const row of rows) {
@@ -90,7 +90,7 @@ export class HasOne extends Relation {
90
90
  return this.parent[this.localKey];
91
91
  }
92
92
  query() {
93
- return db(this.related.table).where(this.foreignKey, this.localValue());
93
+ return db(this.related.table, this.related.connection).where(this.foreignKey, this.localValue());
94
94
  }
95
95
  async get() {
96
96
  const row = await this.query().first();
@@ -99,7 +99,7 @@ export class HasOne extends Relation {
99
99
  async eager(models, name) {
100
100
  const keys = unique(models.map((m) => m[this.localKey]).filter((v) => v != null));
101
101
  const rows = keys.length
102
- ? await db(this.related.table).whereIn(this.foreignKey, keys).get()
102
+ ? await db(this.related.table, this.related.connection).whereIn(this.foreignKey, keys).get()
103
103
  : [];
104
104
  const byKey = new Map();
105
105
  for (const row of rows) {
@@ -124,7 +124,7 @@ export class BelongsTo extends Relation {
124
124
  return this.parent[this.foreignKey];
125
125
  }
126
126
  query() {
127
- return db(this.related.table).where(this.ownerKey, this.foreignValue());
127
+ return db(this.related.table, this.related.connection).where(this.ownerKey, this.foreignValue());
128
128
  }
129
129
  async get() {
130
130
  if (this.foreignValue() == null)
@@ -135,7 +135,7 @@ export class BelongsTo extends Relation {
135
135
  async eager(models, name) {
136
136
  const keys = unique(models.map((m) => m[this.foreignKey]).filter((v) => v != null));
137
137
  const rows = keys.length
138
- ? await db(this.related.table).whereIn(this.ownerKey, keys).get()
138
+ ? await db(this.related.table, this.related.connection).whereIn(this.ownerKey, keys).get()
139
139
  : [];
140
140
  const byKey = new Map();
141
141
  for (const row of rows)
@@ -165,18 +165,18 @@ export class BelongsToMany extends Relation {
165
165
  }
166
166
  /** The query against the related table, once pivot rows are known. */
167
167
  query() {
168
- return db(this.related.table);
168
+ return db(this.related.table, this.related.connection);
169
169
  }
170
170
  async get() {
171
171
  if (this.parentValue() == null)
172
172
  return [];
173
- const pivots = await db(this.pivotTable)
173
+ const pivots = await db(this.pivotTable, this.related.connection)
174
174
  .where(this.foreignPivotKey, this.parentValue())
175
175
  .get();
176
176
  const relatedIds = unique(pivots.map((p) => p[this.relatedPivotKey]));
177
177
  if (!relatedIds.length)
178
178
  return [];
179
- const rows = await db(this.related.table).whereIn(this.relatedKey, relatedIds).get();
179
+ const rows = await db(this.related.table, this.related.connection).whereIn(this.relatedKey, relatedIds).get();
180
180
  return this.hydrate(rows);
181
181
  }
182
182
  async eager(models, name) {
@@ -186,10 +186,10 @@ export class BelongsToMany extends Relation {
186
186
  m.setRelation(name, []);
187
187
  return;
188
188
  }
189
- const pivots = await db(this.pivotTable).whereIn(this.foreignPivotKey, parentIds).get();
189
+ const pivots = await db(this.pivotTable, this.related.connection).whereIn(this.foreignPivotKey, parentIds).get();
190
190
  const relatedIds = unique(pivots.map((p) => p[this.relatedPivotKey]));
191
191
  const rows = relatedIds.length
192
- ? await db(this.related.table).whereIn(this.relatedKey, relatedIds).get()
192
+ ? await db(this.related.table, this.related.connection).whereIn(this.relatedKey, relatedIds).get()
193
193
  : [];
194
194
  const relatedById = new Map(rows.map((row) => [row[this.relatedKey], row]));
195
195
  const grouped = new Map();
@@ -207,7 +207,7 @@ export class BelongsToMany extends Relation {
207
207
  }
208
208
  /** Attach a related row by linking it through the pivot table. */
209
209
  async attach(id, extra = {}) {
210
- await db(this.pivotTable).insert({
210
+ await db(this.pivotTable, this.related.connection).insert({
211
211
  [this.foreignPivotKey]: this.parentValue(),
212
212
  [this.relatedPivotKey]: id,
213
213
  ...extra,
@@ -215,7 +215,7 @@ export class BelongsToMany extends Relation {
215
215
  }
216
216
  /** Detach one related row (or all, when no id is given). */
217
217
  async detach(id) {
218
- let q = db(this.pivotTable).where(this.foreignPivotKey, this.parentValue());
218
+ let q = db(this.pivotTable, this.related.connection).where(this.foreignPivotKey, this.parentValue());
219
219
  if (id !== undefined)
220
220
  q = q.where(this.relatedPivotKey, id);
221
221
  await q.delete();
@@ -33,6 +33,8 @@ interface ResponseHelper {
33
33
  text(body: string, status?: number): Response;
34
34
  html(body: string, status?: number): Response;
35
35
  redirect(location: string, status?: number): Response;
36
+ /** Redirect to the `Referer` header, or `fallback` (default "/") if absent. */
37
+ back(fallback?: string, status?: number): Response;
36
38
  /** Send a value — objects become JSON, everything else becomes text. */
37
39
  send(data: unknown, status?: number): Response;
38
40
  /** Set the response status (chainable). */
@@ -47,6 +49,8 @@ interface ResponseHelper {
47
49
  hasHeader(name: string): boolean;
48
50
  /** Set the Content-Type (chainable). */
49
51
  type(mime: string): ResponseHelper;
52
+ /** Mark the response as a downloadable attachment via Content-Disposition (chainable). */
53
+ attachment(filename?: string): ResponseHelper;
50
54
  /** Append a value to a (possibly multi-value) header (chainable). */
51
55
  append(name: string, value: string): ResponseHelper;
52
56
  /** Remove a response header (chainable). */
@@ -74,6 +78,20 @@ export declare const request: {
74
78
  readonly method: string;
75
79
  readonly path: string;
76
80
  readonly url: string;
81
+ /** The request protocol — "https" or "http" — honoring X-Forwarded-Proto. */
82
+ readonly protocol: string;
83
+ /** Whether the request came in over HTTPS. */
84
+ readonly secure: boolean;
85
+ /** The host with port, honoring X-Forwarded-Host (e.g. "example.com:443"). */
86
+ readonly host: string;
87
+ /** The host without port (e.g. "example.com"). */
88
+ readonly hostname: string;
89
+ /** Scheme + host — "https://example.com" — with no trailing slash. */
90
+ readonly origin: string;
91
+ /** The absolute request URL, rebuilt from the (proxy-aware) origin. */
92
+ readonly fullUrl: string;
93
+ /** The raw query string without the leading "?" (empty when none). */
94
+ readonly querystring: string;
77
95
  /** The response status (useful after `await next()` in middleware). */
78
96
  readonly status: number;
79
97
  header(name: string): string | undefined;
@@ -128,6 +146,14 @@ export declare const request: {
128
146
  language(languages: string[]): string | null;
129
147
  /** Accepted languages, ordered by preference. */
130
148
  languages(): string[];
149
+ /** The best of the offered content encodings per Accept-Encoding, or null. */
150
+ encoding(encodings: string[]): string | null;
151
+ /** Accepted content encodings, ordered by preference. */
152
+ encodings(): string[];
153
+ /** The best of the offered charsets per Accept-Charset, or null. */
154
+ charset(charsets: string[]): string | null;
155
+ /** Accepted charsets, ordered by preference. */
156
+ charsets(): string[];
131
157
  /** A single input (from query or body), with an optional fallback (async). */
132
158
  input<T = unknown>(key: string, fallback?: T): Promise<T>;
133
159
  /** Only the named inputs (async). */
@@ -67,6 +67,20 @@ function negotiate(headerName, offered) {
67
67
  return a;
68
68
  return null;
69
69
  }
70
+ /* ------------------------------ url / host ----------------------------- */
71
+ /* Proxy-aware URL introspection: X-Forwarded-* wins over the raw URL, so an
72
+ * app behind a TLS-terminating proxy sees the client's protocol and host. */
73
+ /** The first value of a (possibly comma-listed) header. */
74
+ function firstForwarded(name) {
75
+ const v = ctx().req.header(name);
76
+ return v ? v.split(",")[0].trim() : undefined;
77
+ }
78
+ function requestProtocol() {
79
+ return firstForwarded("x-forwarded-proto") ?? new URL(ctx().req.url).protocol.replace(/:$/, "");
80
+ }
81
+ function requestHost() {
82
+ return firstForwarded("x-forwarded-host") ?? ctx().req.header("host") ?? new URL(ctx().req.url).host;
83
+ }
70
84
  /* ------------------------------ responses ------------------------------ */
71
85
  /* These work inside a handler AND standalone (e.g. as a static route value).
72
86
  * Inside a request they build on the context (merging any headers); outside a
@@ -98,6 +112,9 @@ export function html(body, status) {
98
112
  }
99
113
  export function redirect(location, status) {
100
114
  const c = maybeCtx();
115
+ // Koa-style `redirect("back")`: bounce to the Referer, or "/" if there isn't one.
116
+ if (location === "back")
117
+ location = c?.req.header("referer") ?? "/";
101
118
  return c
102
119
  ? c.redirect(location, status)
103
120
  : new Response(null, { status: status ?? 302, headers: { location } });
@@ -115,6 +132,9 @@ export const response = {
115
132
  redirect(location, status) {
116
133
  return redirect(location, status);
117
134
  },
135
+ back(fallback = "/", status) {
136
+ return redirect(ctx().req.header("referer") ?? fallback, status);
137
+ },
118
138
  send(data, status) {
119
139
  return typeof data === "object" && data !== null
120
140
  ? json(data, status)
@@ -143,6 +163,18 @@ export const response = {
143
163
  ctx().header("content-type", mime);
144
164
  return response;
145
165
  },
166
+ attachment(filename) {
167
+ if (filename === undefined) {
168
+ ctx().header("content-disposition", "attachment");
169
+ }
170
+ else {
171
+ // Quote the ASCII-safe name; add RFC 5987 filename* for anything else.
172
+ const ascii = filename.replace(/[^\x20-\x7e]/g, "?").replace(/["\\]/g, "_");
173
+ const encoded = encodeURIComponent(filename);
174
+ ctx().header("content-disposition", `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`);
175
+ }
176
+ return response;
177
+ },
146
178
  append(name, value) {
147
179
  ctx().header(name, value, { append: true });
148
180
  return response;
@@ -189,6 +221,35 @@ export const request = {
189
221
  get url() {
190
222
  return ctx().req.url;
191
223
  },
224
+ /** The request protocol — "https" or "http" — honoring X-Forwarded-Proto. */
225
+ get protocol() {
226
+ return requestProtocol();
227
+ },
228
+ /** Whether the request came in over HTTPS. */
229
+ get secure() {
230
+ return requestProtocol() === "https";
231
+ },
232
+ /** The host with port, honoring X-Forwarded-Host (e.g. "example.com:443"). */
233
+ get host() {
234
+ return requestHost();
235
+ },
236
+ /** The host without port (e.g. "example.com"). */
237
+ get hostname() {
238
+ return requestHost().split(":")[0];
239
+ },
240
+ /** Scheme + host — "https://example.com" — with no trailing slash. */
241
+ get origin() {
242
+ return `${requestProtocol()}://${requestHost()}`;
243
+ },
244
+ /** The absolute request URL, rebuilt from the (proxy-aware) origin. */
245
+ get fullUrl() {
246
+ const u = new URL(ctx().req.url);
247
+ return `${requestProtocol()}://${requestHost()}${u.pathname}${u.search}`;
248
+ },
249
+ /** The raw query string without the leading "?" (empty when none). */
250
+ get querystring() {
251
+ return new URL(ctx().req.url).search.replace(/^\?/, "");
252
+ },
192
253
  /** The response status (useful after `await next()` in middleware). */
193
254
  get status() {
194
255
  return ctx().res.status;
@@ -332,6 +393,22 @@ export const request = {
332
393
  languages() {
333
394
  return parseAccept(ctx().req.header("accept-language"));
334
395
  },
396
+ /** The best of the offered content encodings per Accept-Encoding, or null. */
397
+ encoding(encodings) {
398
+ return negotiate("accept-encoding", encodings);
399
+ },
400
+ /** Accepted content encodings, ordered by preference. */
401
+ encodings() {
402
+ return parseAccept(ctx().req.header("accept-encoding"));
403
+ },
404
+ /** The best of the offered charsets per Accept-Charset, or null. */
405
+ charset(charsets) {
406
+ return negotiate("accept-charset", charsets);
407
+ },
408
+ /** Accepted charsets, ordered by preference. */
409
+ charsets() {
410
+ return parseAccept(ctx().req.header("accept-charset"));
411
+ },
335
412
  /** A single input (from query or body), with an optional fallback (async). */
336
413
  async input(key, fallback) {
337
414
  const all = await this.all();
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Security headers — the "shield" for server-rendered apps. One middleware that
3
+ * sets the defensive HTTP headers browsers act on: a Content-Security-Policy,
4
+ * HSTS, clickjacking and MIME-sniffing guards, and a referrer policy.
5
+ *
6
+ * this.use(securityHeaders()); // sensible defaults
7
+ *
8
+ * this.use(securityHeaders({
9
+ * csp: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.example.com"] },
10
+ * hsts: { maxAge: 15552000, includeSubDomains: true },
11
+ * frameGuard: "DENY",
12
+ * }));
13
+ *
14
+ * Each header can be turned off with `false`. CSP takes a ready-made string or a
15
+ * directives object whose camelCase keys become `kebab-case` (`defaultSrc` →
16
+ * `default-src`). Pair with [`csrf()`](./csrf.ts) for form protection.
17
+ */
18
+ import type { MiddlewareHandler } from "hono";
19
+ export interface HstsOptions {
20
+ /** Max-age in seconds. Default 180 days. */
21
+ maxAge?: number;
22
+ /** Apply to subdomains too. Default true. */
23
+ includeSubDomains?: boolean;
24
+ /** Add `preload` (only if you've submitted to the HSTS preload list). Default false. */
25
+ preload?: boolean;
26
+ }
27
+ export interface SecurityHeadersOptions {
28
+ /** Content-Security-Policy — a raw string, a directives object, or `false` to omit. */
29
+ csp?: string | Record<string, string[]> | false;
30
+ /** Strict-Transport-Security. `true` for defaults, an object to tune, `false` to omit. Default off unless set. */
31
+ hsts?: boolean | HstsOptions;
32
+ /** X-Frame-Options clickjacking guard. Default `"SAMEORIGIN"`; `false` to omit. */
33
+ frameGuard?: false | "DENY" | "SAMEORIGIN";
34
+ /** X-Content-Type-Options: nosniff. Default true. */
35
+ noSniff?: boolean;
36
+ /** Referrer-Policy. Default `"strict-origin-when-cross-origin"`; `false` to omit. */
37
+ referrerPolicy?: string | false;
38
+ }
39
+ export declare function securityHeaders(options?: SecurityHeadersOptions): MiddlewareHandler;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Security headers — the "shield" for server-rendered apps. One middleware that
3
+ * sets the defensive HTTP headers browsers act on: a Content-Security-Policy,
4
+ * HSTS, clickjacking and MIME-sniffing guards, and a referrer policy.
5
+ *
6
+ * this.use(securityHeaders()); // sensible defaults
7
+ *
8
+ * this.use(securityHeaders({
9
+ * csp: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.example.com"] },
10
+ * hsts: { maxAge: 15552000, includeSubDomains: true },
11
+ * frameGuard: "DENY",
12
+ * }));
13
+ *
14
+ * Each header can be turned off with `false`. CSP takes a ready-made string or a
15
+ * directives object whose camelCase keys become `kebab-case` (`defaultSrc` →
16
+ * `default-src`). Pair with [`csrf()`](./csrf.ts) for form protection.
17
+ */
18
+ /** camelCase directive names → the kebab-case CSP spelling. */
19
+ function buildCsp(directives) {
20
+ return Object.entries(directives)
21
+ .map(([key, values]) => {
22
+ const name = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
23
+ return values.length ? `${name} ${values.join(" ")}` : name;
24
+ })
25
+ .join("; ");
26
+ }
27
+ export function securityHeaders(options = {}) {
28
+ const frame = options.frameGuard === undefined ? "SAMEORIGIN" : options.frameGuard;
29
+ const referrer = options.referrerPolicy === undefined ? "strict-origin-when-cross-origin" : options.referrerPolicy;
30
+ const noSniff = options.noSniff !== false;
31
+ // Precompute the static header values once.
32
+ const csp = options.csp === false || options.csp === undefined
33
+ ? null
34
+ : typeof options.csp === "string"
35
+ ? options.csp
36
+ : buildCsp(options.csp);
37
+ let hstsValue = null;
38
+ if (options.hsts) {
39
+ const h = options.hsts === true ? {} : options.hsts;
40
+ const parts = [`max-age=${h.maxAge ?? 15552000}`];
41
+ if (h.includeSubDomains !== false)
42
+ parts.push("includeSubDomains");
43
+ if (h.preload)
44
+ parts.push("preload");
45
+ hstsValue = parts.join("; ");
46
+ }
47
+ return async (c, next) => {
48
+ await next();
49
+ if (csp)
50
+ c.header("Content-Security-Policy", csp);
51
+ if (hstsValue)
52
+ c.header("Strict-Transport-Security", hstsValue);
53
+ if (frame)
54
+ c.header("X-Frame-Options", frame);
55
+ if (noSniff)
56
+ c.header("X-Content-Type-Options", "nosniff");
57
+ if (referrer)
58
+ c.header("Referrer-Policy", referrer);
59
+ };
60
+ }