@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.
- package/dist/core/application.js +12 -0
- package/dist/core/auth.d.ts +68 -2
- package/dist/core/auth.js +115 -3
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +90 -4
- package/dist/core/crypto.js +150 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/index.d.ts +19 -8
- package/dist/core/index.js +10 -5
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/package.json +13 -1
package/dist/core/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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";
|
package/dist/core/model.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/model.js
CHANGED
|
@@ -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) {
|
package/dist/core/provider.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/provider.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/rate-limit.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/relations.js
CHANGED
|
@@ -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();
|
package/dist/core/request.d.ts
CHANGED
|
@@ -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). */
|
package/dist/core/request.js
CHANGED
|
@@ -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
|
+
}
|