@kelpie/server 0.2.0 → 0.3.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/app.d.ts +11 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +27 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +5 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +3 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/ids.d.ts +5 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +5 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/lib/rateLimit.d.ts +26 -0
- package/dist/lib/rateLimit.d.ts.map +1 -0
- package/dist/lib/rateLimit.js +26 -0
- package/dist/lib/rateLimit.js.map +1 -0
- package/dist/lib/securityHeaders.d.ts +3 -0
- package/dist/lib/securityHeaders.d.ts.map +1 -0
- package/dist/lib/securityHeaders.js +41 -0
- package/dist/lib/securityHeaders.js.map +1 -0
- package/dist/modules/rate-limit/middleware.d.ts +42 -0
- package/dist/modules/rate-limit/middleware.d.ts.map +1 -0
- package/dist/modules/rate-limit/middleware.js +127 -0
- package/dist/modules/rate-limit/middleware.js.map +1 -0
- package/dist/modules/rate-limit/repository.d.ts +32 -0
- package/dist/modules/rate-limit/repository.d.ts.map +1 -0
- package/dist/modules/rate-limit/repository.js +48 -0
- package/dist/modules/rate-limit/repository.js.map +1 -0
- package/dist/modules/rate-limit/schema.d.ts +125 -0
- package/dist/modules/rate-limit/schema.d.ts.map +1 -0
- package/dist/modules/rate-limit/schema.js +29 -0
- package/dist/modules/rate-limit/schema.js.map +1 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/testing/app.d.ts +5 -0
- package/dist/testing/app.d.ts.map +1 -1
- package/dist/testing/app.js +14 -0
- package/dist/testing/app.js.map +1 -1
- package/dist/webBundle.d.ts +37 -0
- package/dist/webBundle.d.ts.map +1 -0
- package/dist/webBundle.js +75 -0
- package/dist/webBundle.js.map +1 -0
- package/migrations/0016_misty_mentor.sql +11 -0
- package/migrations/meta/0016_snapshot.json +5154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -2
- package/src/app.ts +46 -0
- package/src/index.ts +4 -1
- package/src/lib/config.ts +13 -0
- package/src/lib/errors.ts +4 -0
- package/src/lib/ids.ts +5 -0
- package/src/lib/rateLimit.ts +45 -0
- package/src/lib/securityHeaders.ts +44 -0
- package/src/modules/rate-limit/middleware.ts +177 -0
- package/src/modules/rate-limit/repository.ts +64 -0
- package/src/modules/rate-limit/schema.ts +34 -0
- package/src/schema/index.ts +1 -0
- package/src/testing/app.ts +21 -0
- package/src/webBundle.ts +110 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../../src/modules/rate-limit/middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Database } from '../../lib/database.ts';
|
|
2
|
+
import type { Transaction } from '../../runtime/transaction.ts';
|
|
3
|
+
import { rateLimitBuckets } from './schema.ts';
|
|
4
|
+
/** Queries for `rate_limit_buckets`. The middleware decides; these read and write. */
|
|
5
|
+
export type Queryable = Database | Transaction;
|
|
6
|
+
export type RateLimitBucketRecord = typeof rateLimitBuckets.$inferSelect;
|
|
7
|
+
/**
|
|
8
|
+
* Increments the counter for `(scope, key, windowStart)`, creating the row on
|
|
9
|
+
* the first request of a window.
|
|
10
|
+
*
|
|
11
|
+
* One atomic upsert. Postgres's own row lock under `ON CONFLICT` is what makes
|
|
12
|
+
* concurrent requests from the same caller count correctly with no read
|
|
13
|
+
* followed by a separate write to race against — the same shape
|
|
14
|
+
* `reserveIdempotencyKey` uses, and for the same reason: two statements can't
|
|
15
|
+
* be made atomic against each other, but one can.
|
|
16
|
+
*/
|
|
17
|
+
export declare function incrementRateLimitBucket(db: Queryable, params: {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly scope: string;
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly windowStart: Date;
|
|
22
|
+
}): Promise<number>;
|
|
23
|
+
/**
|
|
24
|
+
* Sweeps buckets from windows that have fully elapsed.
|
|
25
|
+
*
|
|
26
|
+
* Enforced here rather than by a schedule, the same call `idempotency_keys`
|
|
27
|
+
* and `webhook_deliveries` make about their own growth: there is no scheduler
|
|
28
|
+
* in the service, so the middleware prunes on every increment
|
|
29
|
+
* (`middleware.ts`) and the table stays bounded by the traffic that grows it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pruneExpiredRateLimitBuckets(db: Queryable, before: Date): Promise<number>;
|
|
32
|
+
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../../src/modules/rate-limit/repository.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAA;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAA;AAC/D,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAE9C,sFAAsF;AAEtF,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAA;AAE9C,MAAM,MAAM,qBAAqB,GAAG,OAAO,gBAAgB,CAAC,YAAY,CAAA;AAExE;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,SAAS,EACb,MAAM,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAA;CAAE,GACxG,OAAO,CAAC,MAAM,CAAC,CAqBjB;AAED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAO/F"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { lt, sql } from 'drizzle-orm';
|
|
2
|
+
import { rateLimitBuckets } from './schema.js';
|
|
3
|
+
/**
|
|
4
|
+
* Increments the counter for `(scope, key, windowStart)`, creating the row on
|
|
5
|
+
* the first request of a window.
|
|
6
|
+
*
|
|
7
|
+
* One atomic upsert. Postgres's own row lock under `ON CONFLICT` is what makes
|
|
8
|
+
* concurrent requests from the same caller count correctly with no read
|
|
9
|
+
* followed by a separate write to race against — the same shape
|
|
10
|
+
* `reserveIdempotencyKey` uses, and for the same reason: two statements can't
|
|
11
|
+
* be made atomic against each other, but one can.
|
|
12
|
+
*/
|
|
13
|
+
export async function incrementRateLimitBucket(db, params) {
|
|
14
|
+
const [row] = await db
|
|
15
|
+
.insert(rateLimitBuckets)
|
|
16
|
+
.values({
|
|
17
|
+
id: params.id,
|
|
18
|
+
scope: params.scope,
|
|
19
|
+
key: params.key,
|
|
20
|
+
windowStart: params.windowStart,
|
|
21
|
+
count: 1,
|
|
22
|
+
})
|
|
23
|
+
.onConflictDoUpdate({
|
|
24
|
+
target: [rateLimitBuckets.scope, rateLimitBuckets.key, rateLimitBuckets.windowStart],
|
|
25
|
+
set: { count: sql `${rateLimitBuckets.count} + 1` },
|
|
26
|
+
})
|
|
27
|
+
.returning({ count: rateLimitBuckets.count });
|
|
28
|
+
if (row === undefined) {
|
|
29
|
+
throw new Error('rate limit bucket upsert returned no row');
|
|
30
|
+
}
|
|
31
|
+
return row.count;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sweeps buckets from windows that have fully elapsed.
|
|
35
|
+
*
|
|
36
|
+
* Enforced here rather than by a schedule, the same call `idempotency_keys`
|
|
37
|
+
* and `webhook_deliveries` make about their own growth: there is no scheduler
|
|
38
|
+
* in the service, so the middleware prunes on every increment
|
|
39
|
+
* (`middleware.ts`) and the table stays bounded by the traffic that grows it.
|
|
40
|
+
*/
|
|
41
|
+
export async function pruneExpiredRateLimitBuckets(db, before) {
|
|
42
|
+
const deleted = await db
|
|
43
|
+
.delete(rateLimitBuckets)
|
|
44
|
+
.where(lt(rateLimitBuckets.windowStart, before))
|
|
45
|
+
.returning({ id: rateLimitBuckets.id });
|
|
46
|
+
return deleted.length;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=repository.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.js","sourceRoot":"","sources":["../../../src/modules/rate-limit/repository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAIrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAQ9C;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC5C,EAAa,EACb,MAAyG;IAEzG,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE;SACnB,MAAM,CAAC,gBAAgB,CAAC;SACxB,MAAM,CAAC;QACN,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,GAAG,EAAE,MAAM,CAAC,GAAG;QACf,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,KAAK,EAAE,CAAC;KACT,CAAC;SACD,kBAAkB,CAAC;QAClB,MAAM,EAAE,CAAC,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,CAAC,WAAW,CAAC;QACpF,GAAG,EAAE,EAAE,KAAK,EAAE,GAAG,CAAA,GAAG,gBAAgB,CAAC,KAAK,MAAM,EAAE;KACnD,CAAC;SACD,SAAS,CAAC,EAAE,KAAK,EAAE,gBAAgB,CAAC,KAAK,EAAE,CAAC,CAAA;IAE/C,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAA;IAC7D,CAAC;IAED,OAAO,GAAG,CAAC,KAAK,CAAA;AAClB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAAC,EAAa,EAAE,MAAY;IAC5E,MAAM,OAAO,GAAG,MAAM,EAAE;SACrB,MAAM,CAAC,gBAAgB,CAAC;SACxB,KAAK,CAAC,EAAE,CAAC,gBAAgB,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;SAC/C,SAAS,CAAC,EAAE,EAAE,EAAE,gBAAgB,CAAC,EAAE,EAAE,CAAC,CAAA;IAEzC,OAAO,OAAO,CAAC,MAAM,CAAA;AACvB,CAAC"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-window counters backing the rate limiter (`app.ts`). Not
|
|
3
|
+
* workspace-owned, unlike every other core table: a public form submission or
|
|
4
|
+
* an unauthenticated auth attempt has no workspace to scope to, which is
|
|
5
|
+
* exactly the traffic this table exists to police. `scope` names the budget
|
|
6
|
+
* (`forms`, `auth`, `api`) and `key` is the caller within it — an IP address
|
|
7
|
+
* or an API key id.
|
|
8
|
+
*
|
|
9
|
+
* `(scope, key, window_start)` is unique rather than `(scope, key)` alone
|
|
10
|
+
* because the window boundary lives in the key: a new window is a new row, so
|
|
11
|
+
* incrementing is one atomic upsert (`repository.ts`) with no read before the
|
|
12
|
+
* write to race against. A row is left for `pruneExpiredRateLimitBuckets` to
|
|
13
|
+
* sweep once its window has elapsed rather than deleted eagerly, since nothing
|
|
14
|
+
* reads a bucket after that.
|
|
15
|
+
*/
|
|
16
|
+
export declare const rateLimitBuckets: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
17
|
+
name: "rate_limit_buckets";
|
|
18
|
+
schema: undefined;
|
|
19
|
+
columns: {
|
|
20
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
21
|
+
name: "id";
|
|
22
|
+
tableName: "rate_limit_buckets";
|
|
23
|
+
dataType: "string";
|
|
24
|
+
columnType: "PgText";
|
|
25
|
+
data: string;
|
|
26
|
+
driverParam: string;
|
|
27
|
+
notNull: true;
|
|
28
|
+
hasDefault: false;
|
|
29
|
+
isPrimaryKey: true;
|
|
30
|
+
isAutoincrement: false;
|
|
31
|
+
hasRuntimeDefault: false;
|
|
32
|
+
enumValues: [string, ...string[]];
|
|
33
|
+
baseColumn: never;
|
|
34
|
+
identity: undefined;
|
|
35
|
+
generated: undefined;
|
|
36
|
+
}, {}, {}>;
|
|
37
|
+
scope: import("drizzle-orm/pg-core").PgColumn<{
|
|
38
|
+
name: "scope";
|
|
39
|
+
tableName: "rate_limit_buckets";
|
|
40
|
+
dataType: "string";
|
|
41
|
+
columnType: "PgText";
|
|
42
|
+
data: string;
|
|
43
|
+
driverParam: string;
|
|
44
|
+
notNull: true;
|
|
45
|
+
hasDefault: false;
|
|
46
|
+
isPrimaryKey: false;
|
|
47
|
+
isAutoincrement: false;
|
|
48
|
+
hasRuntimeDefault: false;
|
|
49
|
+
enumValues: [string, ...string[]];
|
|
50
|
+
baseColumn: never;
|
|
51
|
+
identity: undefined;
|
|
52
|
+
generated: undefined;
|
|
53
|
+
}, {}, {}>;
|
|
54
|
+
key: import("drizzle-orm/pg-core").PgColumn<{
|
|
55
|
+
name: "key";
|
|
56
|
+
tableName: "rate_limit_buckets";
|
|
57
|
+
dataType: "string";
|
|
58
|
+
columnType: "PgText";
|
|
59
|
+
data: string;
|
|
60
|
+
driverParam: string;
|
|
61
|
+
notNull: true;
|
|
62
|
+
hasDefault: false;
|
|
63
|
+
isPrimaryKey: false;
|
|
64
|
+
isAutoincrement: false;
|
|
65
|
+
hasRuntimeDefault: false;
|
|
66
|
+
enumValues: [string, ...string[]];
|
|
67
|
+
baseColumn: never;
|
|
68
|
+
identity: undefined;
|
|
69
|
+
generated: undefined;
|
|
70
|
+
}, {}, {}>;
|
|
71
|
+
windowStart: import("drizzle-orm/pg-core").PgColumn<{
|
|
72
|
+
name: string;
|
|
73
|
+
tableName: "rate_limit_buckets";
|
|
74
|
+
dataType: "date";
|
|
75
|
+
columnType: "PgTimestamp";
|
|
76
|
+
data: Date;
|
|
77
|
+
driverParam: string;
|
|
78
|
+
notNull: true;
|
|
79
|
+
hasDefault: false;
|
|
80
|
+
isPrimaryKey: false;
|
|
81
|
+
isAutoincrement: false;
|
|
82
|
+
hasRuntimeDefault: false;
|
|
83
|
+
enumValues: undefined;
|
|
84
|
+
baseColumn: never;
|
|
85
|
+
identity: undefined;
|
|
86
|
+
generated: undefined;
|
|
87
|
+
}, {}, {}>;
|
|
88
|
+
count: import("drizzle-orm/pg-core").PgColumn<{
|
|
89
|
+
name: "count";
|
|
90
|
+
tableName: "rate_limit_buckets";
|
|
91
|
+
dataType: "number";
|
|
92
|
+
columnType: "PgInteger";
|
|
93
|
+
data: number;
|
|
94
|
+
driverParam: string | number;
|
|
95
|
+
notNull: true;
|
|
96
|
+
hasDefault: true;
|
|
97
|
+
isPrimaryKey: false;
|
|
98
|
+
isAutoincrement: false;
|
|
99
|
+
hasRuntimeDefault: false;
|
|
100
|
+
enumValues: undefined;
|
|
101
|
+
baseColumn: never;
|
|
102
|
+
identity: undefined;
|
|
103
|
+
generated: undefined;
|
|
104
|
+
}, {}, {}>;
|
|
105
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
106
|
+
name: "created_at";
|
|
107
|
+
tableName: "rate_limit_buckets";
|
|
108
|
+
dataType: "date";
|
|
109
|
+
columnType: "PgTimestamp";
|
|
110
|
+
data: Date;
|
|
111
|
+
driverParam: string;
|
|
112
|
+
notNull: true;
|
|
113
|
+
hasDefault: true;
|
|
114
|
+
isPrimaryKey: false;
|
|
115
|
+
isAutoincrement: false;
|
|
116
|
+
hasRuntimeDefault: false;
|
|
117
|
+
enumValues: undefined;
|
|
118
|
+
baseColumn: never;
|
|
119
|
+
identity: undefined;
|
|
120
|
+
generated: undefined;
|
|
121
|
+
}, {}, {}>;
|
|
122
|
+
};
|
|
123
|
+
dialect: 'pg';
|
|
124
|
+
}>;
|
|
125
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/modules/rate-limit/schema.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAc5B,CAAA"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { index, integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { createdAt, moment, primaryId } from '../../lib/columns.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fixed-window counters backing the rate limiter (`app.ts`). Not
|
|
5
|
+
* workspace-owned, unlike every other core table: a public form submission or
|
|
6
|
+
* an unauthenticated auth attempt has no workspace to scope to, which is
|
|
7
|
+
* exactly the traffic this table exists to police. `scope` names the budget
|
|
8
|
+
* (`forms`, `auth`, `api`) and `key` is the caller within it — an IP address
|
|
9
|
+
* or an API key id.
|
|
10
|
+
*
|
|
11
|
+
* `(scope, key, window_start)` is unique rather than `(scope, key)` alone
|
|
12
|
+
* because the window boundary lives in the key: a new window is a new row, so
|
|
13
|
+
* incrementing is one atomic upsert (`repository.ts`) with no read before the
|
|
14
|
+
* write to race against. A row is left for `pruneExpiredRateLimitBuckets` to
|
|
15
|
+
* sweep once its window has elapsed rather than deleted eagerly, since nothing
|
|
16
|
+
* reads a bucket after that.
|
|
17
|
+
*/
|
|
18
|
+
export const rateLimitBuckets = pgTable('rate_limit_buckets', {
|
|
19
|
+
id: primaryId(),
|
|
20
|
+
scope: text('scope').notNull(),
|
|
21
|
+
key: text('key').notNull(),
|
|
22
|
+
windowStart: moment('window_start').notNull(),
|
|
23
|
+
count: integer('count').notNull().default(1),
|
|
24
|
+
createdAt: createdAt(),
|
|
25
|
+
}, (table) => [
|
|
26
|
+
unique('rate_limit_buckets_scope_key_window_key').on(table.scope, table.key, table.windowStart),
|
|
27
|
+
index('rate_limit_buckets_window_start_idx').on(table.windowStart),
|
|
28
|
+
]);
|
|
29
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/modules/rate-limit/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE3E,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAEnE;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CACrC,oBAAoB,EACpB;IACE,EAAE,EAAE,SAAS,EAAE;IACf,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE;IAC9B,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE;IAC1B,WAAW,EAAE,MAAM,CAAC,cAAc,CAAC,CAAC,OAAO,EAAE;IAC7C,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;IAC5C,SAAS,EAAE,SAAS,EAAE;CACvB,EACD,CAAC,KAAK,EAAE,EAAE,CAAC;IACT,MAAM,CAAC,yCAAyC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,WAAW,CAAC;IAC/F,KAAK,CAAC,qCAAqC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC;CACnE,CACF,CAAA"}
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -27,4 +27,5 @@ export * from '../modules/import-export/schema.ts';
|
|
|
27
27
|
export * from '../modules/agent-tasks/schema.ts';
|
|
28
28
|
export * from '../modules/webhooks/schema.ts';
|
|
29
29
|
export * from '../modules/integrations/schema.ts';
|
|
30
|
+
export * from '../modules/rate-limit/schema.ts';
|
|
30
31
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schema/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,cAAc,2BAA2B,CAAA;AACzC,cAAc,gCAAgC,CAAA;AAC9C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,mCAAmC,CAAA;AACjD,cAAc,6BAA6B,CAAA;AAC3C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,iCAAiC,CAAA;AAC/C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,kCAAkC,CAAA;AAChD,cAAc,+BAA+B,CAAA;AAC7C,cAAc,mCAAmC,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schema/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,cAAc,2BAA2B,CAAA;AACzC,cAAc,gCAAgC,CAAA;AAC9C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,mCAAmC,CAAA;AACjD,cAAc,6BAA6B,CAAA;AAC3C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,iCAAiC,CAAA;AAC/C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,kCAAkC,CAAA;AAChD,cAAc,+BAA+B,CAAA;AAC7C,cAAc,mCAAmC,CAAA;AACjD,cAAc,iCAAiC,CAAA"}
|
package/dist/schema/index.js
CHANGED
|
@@ -27,4 +27,5 @@ export * from '../modules/import-export/schema.js';
|
|
|
27
27
|
export * from '../modules/agent-tasks/schema.js';
|
|
28
28
|
export * from '../modules/webhooks/schema.js';
|
|
29
29
|
export * from '../modules/integrations/schema.js';
|
|
30
|
+
export * from '../modules/rate-limit/schema.js';
|
|
30
31
|
//# sourceMappingURL=index.js.map
|
package/dist/schema/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/schema/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,cAAc,2BAA2B,CAAA;AACzC,cAAc,gCAAgC,CAAA;AAC9C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,mCAAmC,CAAA;AACjD,cAAc,6BAA6B,CAAA;AAC3C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,iCAAiC,CAAA;AAC/C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,kCAAkC,CAAA;AAChD,cAAc,+BAA+B,CAAA;AAC7C,cAAc,mCAAmC,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/schema/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,cAAc,2BAA2B,CAAA;AACzC,cAAc,gCAAgC,CAAA;AAC9C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,mCAAmC,CAAA;AACjD,cAAc,6BAA6B,CAAA;AAC3C,cAAc,6BAA6B,CAAA;AAC3C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,gCAAgC,CAAA;AAC9C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,iCAAiC,CAAA;AAC/C,cAAc,+BAA+B,CAAA;AAC7C,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oCAAoC,CAAA;AAClD,cAAc,kCAAkC,CAAA;AAChD,cAAc,+BAA+B,CAAA;AAC7C,cAAc,mCAAmC,CAAA;AACjD,cAAc,iCAAiC,CAAA"}
|
package/dist/testing/app.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AppBindings } from '../app.ts';
|
|
|
3
3
|
import type { Actor } from '../lib/actor.ts';
|
|
4
4
|
import type { Environment } from '../lib/config.ts';
|
|
5
5
|
import type { DatabaseProbe } from '../lib/database.ts';
|
|
6
|
+
import type { RateLimitConfig } from '../lib/rateLimit.ts';
|
|
6
7
|
import type { KelpieModule } from '../runtime/module.ts';
|
|
7
8
|
import type { ModuleContributions } from '../runtime/registry.ts';
|
|
8
9
|
import type { EntitlementRegistry } from '../runtime/entitlements.ts';
|
|
@@ -30,6 +31,10 @@ export interface TestAppOptions {
|
|
|
30
31
|
* route behaves as it did before module toggling existed.
|
|
31
32
|
*/
|
|
32
33
|
readonly resolveActor?: (context: Context) => Promise<Actor>;
|
|
34
|
+
/** Defaults to the same numbers `loadConfig` would, from an empty environment. */
|
|
35
|
+
readonly rateLimit?: RateLimitConfig;
|
|
36
|
+
/** Defaults to reading `X-Forwarded-For`, so a test can simulate distinct callers. */
|
|
37
|
+
readonly resolveClientIp?: (context: Context) => string;
|
|
33
38
|
}
|
|
34
39
|
export interface TestApp {
|
|
35
40
|
readonly app: Hono<AppBindings>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/testing/app.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAGzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/testing/app.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAGzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAGvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAC1D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAA;AACjE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAIrE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAejD;;;;GAIG;AAEH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,YAAY,EAAE,CAAA;IAC1C,wFAAwF;IACxF,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAA;IAClC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAA;IACrD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,MAAM,CAAA;IACzC,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAA;IAChC,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAA;IAC3C,sFAAsF;IACtF,QAAQ,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACzD;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,KAAK,CAAC,CAAA;IAC5D,kFAAkF;IAClF,QAAQ,CAAC,SAAS,CAAC,EAAE,eAAe,CAAA;IACpC,sFAAsF;IACtF,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAA;CACxD;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,WAAW,CAAC,CAAA;IAC/B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IAC/B,QAAQ,CAAC,aAAa,EAAE,mBAAmB,CAAA;IAC3C,mDAAmD;IACnD,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;CACrC;AAID,wBAAsB,aAAa,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,OAAO,CAAC,CA4BlF"}
|
package/dist/testing/app.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { createApp } from '../app.js';
|
|
2
2
|
import { createLogger } from '../lib/logger.js';
|
|
3
|
+
import { rateLimitConfigFrom, rateLimitConfigSchema } from '../lib/rateLimit.js';
|
|
3
4
|
import { registerModules } from '../runtime/registry.js';
|
|
4
5
|
import { TEST_ENVIRONMENT } from './environment.js';
|
|
5
6
|
import { createTestServices } from './services.js';
|
|
7
|
+
/** The same defaults `loadConfig` produces from an empty environment: one source of numbers for both. */
|
|
8
|
+
const DEFAULT_TEST_RATE_LIMIT = rateLimitConfigFrom(rateLimitConfigSchema.parse({}));
|
|
9
|
+
/**
|
|
10
|
+
* A caller's IP, for tests. Real entry points resolve this from the socket
|
|
11
|
+
* (`apps/kelpie/src/server.ts`); a test using `app.request()` has no socket, so
|
|
12
|
+
* it reads `X-Forwarded-For` when a test sets one to simulate distinct
|
|
13
|
+
* callers, falling back to a fixed address for everything else.
|
|
14
|
+
*/
|
|
15
|
+
function testClientIp(context) {
|
|
16
|
+
return context.req.header('X-Forwarded-For') ?? '203.0.113.1';
|
|
17
|
+
}
|
|
6
18
|
const reachableDatabase = () => Promise.resolve({ reachable: true });
|
|
7
19
|
export async function createTestApp(options = {}) {
|
|
8
20
|
const logLines = [];
|
|
@@ -25,6 +37,8 @@ export async function createTestApp(options = {}) {
|
|
|
25
37
|
credentials: { db: services.db, now: services.now },
|
|
26
38
|
generateRequestId: options.generateRequestId ?? (() => 'req-test'),
|
|
27
39
|
createId: services.createId,
|
|
40
|
+
rateLimit: options.rateLimit ?? DEFAULT_TEST_RATE_LIMIT,
|
|
41
|
+
resolveClientIp: options.resolveClientIp ?? testClientIp,
|
|
28
42
|
});
|
|
29
43
|
return { app, contributions, logLines, services };
|
|
30
44
|
}
|
package/dist/testing/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/testing/app.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAKrC,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/testing/app.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAKrC,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAA;AAKhF,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAGlD,yGAAyG;AACzG,MAAM,uBAAuB,GAAoB,mBAAmB,CAAC,qBAAqB,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAA;AAErG;;;;;GAKG;AACH,SAAS,YAAY,CAAC,OAAgB;IACpC,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,iBAAiB,CAAC,IAAI,aAAa,CAAA;AAC/D,CAAC;AAwCD,MAAM,iBAAiB,GAAG,GAA2B,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;AAE5F,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAO,GAAmB,EAAE;IAC9D,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,MAAM,MAAM,GAAG,YAAY,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;IAEnE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,kBAAkB,EAAE,CAAA;IACzD,MAAM,aAAa,GAAG,MAAM,eAAe,CAAC;QAC1C,OAAO,EAAE,OAAO,CAAC,OAAO,IAAI,EAAE;QAC9B,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,gBAAgB;QACpD,MAAM;QACN,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,GAAG,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;QACrF,GAAG,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;QACrF,GAAG,CAAC,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;QACrF,QAAQ;KACT,CAAC,CAAA;IAEF,MAAM,GAAG,GAAG,SAAS,CAAC;QACpB,MAAM;QACN,aAAa,EAAE,OAAO,CAAC,aAAa,IAAI,iBAAiB;QACzD,aAAa;QACb,WAAW,EAAE,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE;QACnD,iBAAiB,EAAE,OAAO,CAAC,iBAAiB,IAAI,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC;QAClE,QAAQ,EAAE,QAAQ,CAAC,QAAQ;QAC3B,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,uBAAuB;QACvD,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,YAAY;KACzD,CAAC,CAAA;IAEF,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAA;AACnD,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import type { AppBindings } from './app.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Serves a built web bundle from the same origin as the API.
|
|
5
|
+
*
|
|
6
|
+
* `createApp` answers data and nothing else. In development the Vite dev server
|
|
7
|
+
* builds the pages and proxies `/v1` through to the API, so one address serves
|
|
8
|
+
* both. That proxy is doing two jobs: rebuilding on edit, which is development
|
|
9
|
+
* only, and putting the pages and the API on one origin, which is permanent.
|
|
10
|
+
* Nothing did the second job in production, so a deployed assembly answered API
|
|
11
|
+
* calls and served no pages at all.
|
|
12
|
+
*
|
|
13
|
+
* An assembly opts in by calling this after `createApp`, which is where the
|
|
14
|
+
* three assemblies (`apps/kelpie`, a scaffolded project, and `kelpie-cloud`)
|
|
15
|
+
* would otherwise each grow their own copy of the fallback rule below.
|
|
16
|
+
*/
|
|
17
|
+
export interface WebBundleOptions {
|
|
18
|
+
/** Directory holding the built `index.html` and its assets. */
|
|
19
|
+
readonly directory: string;
|
|
20
|
+
}
|
|
21
|
+
/** The bundle directory does not hold a build. Thrown at boot, never per request. */
|
|
22
|
+
export declare class WebBundleError extends Error {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mounts the bundle on an app built by `createApp`.
|
|
27
|
+
*
|
|
28
|
+
* Call it after `createApp` and before serving. Hono composes a request's
|
|
29
|
+
* handlers in registration order and stops at the first one that answers, so
|
|
30
|
+
* mounting last leaves every API route matching ahead of these two.
|
|
31
|
+
*
|
|
32
|
+
* @throws WebBundleError if the directory holds no `index.html`. A deployment
|
|
33
|
+
* whose build did not run should stop at boot rather than serve an API with
|
|
34
|
+
* invisible pages, which is the failure this function exists to remove.
|
|
35
|
+
*/
|
|
36
|
+
export declare function serveWebBundle(app: Hono<AppBindings>, options: WebBundleOptions): void;
|
|
37
|
+
//# sourceMappingURL=webBundle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webBundle.d.ts","sourceRoot":"","sources":["../src/webBundle.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,IAAI,EAAqB,MAAM,MAAM,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAG3C;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,qFAAqF;AACrF,qBAAa,cAAe,SAAQ,KAAK;IACvC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAuCD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAwBtF"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
4
|
+
import { MCP_ROUTE_PREFIX } from './modules/mcp/index.js';
|
|
5
|
+
/** The bundle directory does not hold a build. Thrown at boot, never per request. */
|
|
6
|
+
export class WebBundleError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'WebBundleError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The prefixes `createApp` answers on.
|
|
14
|
+
*
|
|
15
|
+
* `/v1/public` needs no entry of its own: it sits under `/v1`. `MCP_ROUTE_PREFIX`
|
|
16
|
+
* is imported rather than written out, so moving the endpoint moves this with it.
|
|
17
|
+
*/
|
|
18
|
+
const API_PREFIXES = ['/v1', MCP_ROUTE_PREFIX, '/healthz'];
|
|
19
|
+
function isApiRequest(path) {
|
|
20
|
+
return API_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Restricts a handler to requests for the web app.
|
|
24
|
+
*
|
|
25
|
+
* The API exclusion is the point of the whole file. `app.notFound` renders
|
|
26
|
+
* `api.md`'s JSON 404, and it only fires when no route matched, so a bare
|
|
27
|
+
* catch-all registered after `createApp` would answer `GET /v1/typo` with the
|
|
28
|
+
* app shell and a 200. A client asking for data would get a web page and no
|
|
29
|
+
* indication it had misspelled anything.
|
|
30
|
+
*
|
|
31
|
+
* Methods are filtered for the same reason: `POST /v1/typo` is a wrong endpoint,
|
|
32
|
+
* not a page request, and only a `GET` or a `HEAD` can sensibly be answered with
|
|
33
|
+
* a document.
|
|
34
|
+
*/
|
|
35
|
+
function webRequestsOnly(handler) {
|
|
36
|
+
return async (context, next) => {
|
|
37
|
+
const isDocumentRequest = context.req.method === 'GET' || context.req.method === 'HEAD';
|
|
38
|
+
if (!isDocumentRequest || isApiRequest(context.req.path)) {
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
return handler(context, next);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Mounts the bundle on an app built by `createApp`.
|
|
46
|
+
*
|
|
47
|
+
* Call it after `createApp` and before serving. Hono composes a request's
|
|
48
|
+
* handlers in registration order and stops at the first one that answers, so
|
|
49
|
+
* mounting last leaves every API route matching ahead of these two.
|
|
50
|
+
*
|
|
51
|
+
* @throws WebBundleError if the directory holds no `index.html`. A deployment
|
|
52
|
+
* whose build did not run should stop at boot rather than serve an API with
|
|
53
|
+
* invisible pages, which is the failure this function exists to remove.
|
|
54
|
+
*/
|
|
55
|
+
export function serveWebBundle(app, options) {
|
|
56
|
+
const directory = resolve(options.directory);
|
|
57
|
+
const indexHtml = join(directory, 'index.html');
|
|
58
|
+
if (!existsSync(indexHtml)) {
|
|
59
|
+
throw new WebBundleError(`No index.html in ${directory}. Point WEB_BUNDLE_DIR at a built web bundle, or run the web build first.`);
|
|
60
|
+
}
|
|
61
|
+
// Two registrations rather than one: `serveStatic` calls `next()` when it
|
|
62
|
+
// finds no file, which is exactly the signal the fallback needs. A request
|
|
63
|
+
// for a real asset is answered by the first and never reaches the second.
|
|
64
|
+
app.use('*', webRequestsOnly(serveStatic({ root: directory })));
|
|
65
|
+
// The single-page fallback. The app decides what to draw from the address, so
|
|
66
|
+
// a deep link to `/people/per_01J…` has to return the same `index.html` even
|
|
67
|
+
// though no file sits at that path.
|
|
68
|
+
//
|
|
69
|
+
// A missing asset gets the shell too, which is what every static SPA server
|
|
70
|
+
// does by default. Narrowing this by `Accept` would turn a stale asset
|
|
71
|
+
// reference into a clean 404, and would also turn `curl /people/per_01J…`
|
|
72
|
+
// into one, so it is left alone.
|
|
73
|
+
app.use('*', webRequestsOnly(serveStatic({ path: indexHtml })));
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=webBundle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webBundle.js","sourceRoot":"","sources":["../src/webBundle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAEzC,OAAO,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAA;AAI5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAsBzD,qFAAqF;AACrF,MAAM,OAAO,cAAe,SAAQ,KAAK;IACvC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAA;IAC9B,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,YAAY,GAAsB,CAAC,KAAK,EAAE,gBAAgB,EAAE,UAAU,CAAC,CAAA;AAE7E,SAAS,YAAY,CAAC,IAAY;IAChC,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,CAAC,CAAC,CAAA;AACxF,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,eAAe,CAAC,OAA0B;IACjD,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;QAC7B,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,MAAM,CAAA;QAEvF,IAAI,CAAC,iBAAiB,IAAI,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YACzD,OAAO,IAAI,EAAE,CAAA;QACf,CAAC;QAED,OAAO,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAC/B,CAAC,CAAA;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,GAAsB,EAAE,OAAyB;IAC9E,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,CAAA;IAE/C,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,cAAc,CACtB,oBAAoB,SAAS,2EAA2E,CACzG,CAAA;IACH,CAAC;IAED,0EAA0E;IAC1E,2EAA2E;IAC3E,0EAA0E;IAC1E,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,eAAe,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,CAAA;IAE/D,8EAA8E;IAC9E,6EAA6E;IAC7E,oCAAoC;IACpC,EAAE;IACF,4EAA4E;IAC5E,uEAAuE;IACvE,0EAA0E;IAC1E,iCAAiC;IACjC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,eAAe,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,CAAA;AACjE,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE "rate_limit_buckets" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"scope" text NOT NULL,
|
|
4
|
+
"key" text NOT NULL,
|
|
5
|
+
"window_start" timestamp with time zone NOT NULL,
|
|
6
|
+
"count" integer DEFAULT 1 NOT NULL,
|
|
7
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
CONSTRAINT "rate_limit_buckets_scope_key_window_key" UNIQUE("scope","key","window_start")
|
|
9
|
+
);
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
CREATE INDEX "rate_limit_buckets_window_start_idx" ON "rate_limit_buckets" USING btree ("window_start");
|