gruber 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +295 -18
  3. package/core/authentication.d.ts +51 -0
  4. package/core/authentication.d.ts.map +1 -0
  5. package/core/authentication.js +81 -0
  6. package/core/authentication.test.js +144 -0
  7. package/core/authentication.ts +150 -0
  8. package/core/authorization.d.ts +39 -0
  9. package/core/authorization.d.ts.map +1 -0
  10. package/core/authorization.js +84 -0
  11. package/core/authorization.test.js +173 -0
  12. package/core/authorization.ts +122 -0
  13. package/core/configuration.d.ts +47 -153
  14. package/core/configuration.d.ts.map +1 -0
  15. package/core/configuration.js +218 -347
  16. package/core/configuration.test.js +2 -2
  17. package/core/configuration.ts +313 -0
  18. package/core/fetch-router.d.ts +17 -39
  19. package/core/fetch-router.d.ts.map +1 -0
  20. package/core/fetch-router.js +54 -91
  21. package/core/fetch-router.test.js +2 -2
  22. package/core/fetch-router.ts +84 -0
  23. package/core/http.d.ts +40 -56
  24. package/core/http.d.ts.map +1 -0
  25. package/core/http.js +78 -111
  26. package/core/http.test.js +1 -1
  27. package/core/http.ts +143 -0
  28. package/core/migrator.d.ts +25 -57
  29. package/core/migrator.d.ts.map +1 -0
  30. package/core/migrator.js +45 -85
  31. package/core/migrator.test.js +1 -1
  32. package/core/migrator.ts +86 -0
  33. package/core/mod.d.ts +15 -7
  34. package/core/mod.d.ts.map +1 -0
  35. package/core/mod.js +7 -0
  36. package/core/mod.ts +14 -0
  37. package/core/postgres.d.ts +13 -42
  38. package/core/postgres.d.ts.map +1 -0
  39. package/core/postgres.js +41 -78
  40. package/core/postgres.ts +84 -0
  41. package/core/random.d.ts +7 -0
  42. package/core/random.d.ts.map +1 -0
  43. package/core/random.js +12 -0
  44. package/core/random.ts +19 -0
  45. package/core/store.d.ts +64 -0
  46. package/core/store.d.ts.map +1 -0
  47. package/core/store.js +115 -0
  48. package/core/store.ts +184 -0
  49. package/core/structures.d.ts +29 -89
  50. package/core/structures.d.ts.map +1 -0
  51. package/core/structures.js +244 -330
  52. package/core/structures.test.js +1 -1
  53. package/core/structures.ts +303 -0
  54. package/core/terminator.d.ts +20 -0
  55. package/core/terminator.d.ts.map +1 -0
  56. package/core/terminator.js +36 -0
  57. package/core/terminator.test.js +125 -0
  58. package/core/terminator.ts +56 -0
  59. package/core/test-deps.js +41 -0
  60. package/core/timers.d.ts +6 -0
  61. package/core/timers.d.ts.map +1 -0
  62. package/core/timers.ts +5 -0
  63. package/core/tokens.d.ts +28 -0
  64. package/core/tokens.d.ts.map +1 -0
  65. package/core/tokens.js +40 -0
  66. package/core/tokens.ts +68 -0
  67. package/core/types.d.ts +31 -18
  68. package/core/types.d.ts.map +1 -0
  69. package/core/types.js +2 -0
  70. package/core/types.ts +36 -34
  71. package/core/utilities.d.ts +5 -24
  72. package/core/utilities.d.ts.map +1 -0
  73. package/core/utilities.js +57 -90
  74. package/core/utilities.test.js +1 -1
  75. package/core/utilities.ts +86 -0
  76. package/package.json +2 -7
  77. package/source/configuration.d.ts +9 -14
  78. package/source/configuration.d.ts.map +1 -0
  79. package/source/configuration.js +34 -43
  80. package/source/configuration.ts +46 -0
  81. package/source/core.d.ts +2 -1
  82. package/source/core.d.ts.map +1 -0
  83. package/source/core.js +1 -0
  84. package/source/core.ts +2 -0
  85. package/source/express-router.d.ts +9 -12
  86. package/source/express-router.d.ts.map +1 -0
  87. package/source/express-router.js +33 -48
  88. package/source/express-router.ts +54 -0
  89. package/source/koa-router.d.ts +8 -15
  90. package/source/koa-router.d.ts.map +1 -0
  91. package/source/koa-router.js +45 -56
  92. package/source/koa-router.ts +62 -0
  93. package/source/mod.d.ts +6 -4
  94. package/source/mod.d.ts.map +1 -0
  95. package/source/mod.js +2 -1
  96. package/source/mod.ts +5 -0
  97. package/source/node-router.d.ts +30 -27
  98. package/source/node-router.d.ts.map +1 -0
  99. package/source/node-router.js +83 -76
  100. package/source/node-router.ts +143 -0
  101. package/source/package-lock.json +5 -176
  102. package/source/package.json +1 -6
  103. package/source/polyfill.d.ts +1 -0
  104. package/source/polyfill.d.ts.map +1 -0
  105. package/source/polyfill.js +3 -1
  106. package/source/polyfill.ts +2 -0
  107. package/source/postgres.d.ts +15 -28
  108. package/source/postgres.d.ts.map +1 -0
  109. package/source/postgres.js +38 -69
  110. package/source/postgres.ts +68 -0
  111. package/source/terminator.d.ts +8 -0
  112. package/source/terminator.d.ts.map +1 -0
  113. package/source/terminator.js +22 -0
  114. package/source/terminator.ts +31 -0
  115. package/tsconfig.json +5 -5
  116. package/core/fetch-router.test.d.ts +0 -1
  117. package/core/http.test.d.ts +0 -1
  118. package/core/migrator.test.d.ts +0 -1
  119. package/core/structures.test.d.ts +0 -1
  120. package/core/test-deps.d.ts +0 -1
  121. package/core/utilities.test.d.ts +0 -1
  122. /package/core/{configuration.test.d.ts → timers.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  This file documents notable changes to the project
4
4
 
5
+ ## 0.6.1
6
+
7
+ **new**
8
+
9
+ - Expose `includesScope` to check scopes
10
+
11
+ **fixes**
12
+
13
+ - Node & Express handle streams properly, they write the head then stream the response.
14
+ - Log HTTP errors in `ExpressRouter` and `KoaRoater`
15
+ - Took out type dependencies
16
+ - JWTs have the correct expiration time
17
+
18
+ **changed**
19
+
20
+ - Renamed `formatCode` to `formatAuthenticationCode`
21
+
22
+ **unstable**
23
+
24
+ - Renamed unstable `expireAfter` to `maxAge`
25
+ - Renamed unstable `JWTService` to `TokenService`
26
+ - Renamed unstable `JoseJwtService` to `JoseTokens`
27
+ - Renamed unstable `Authorization#getAuthorization` to `Authorization#assert`
28
+ - Added unstable `Authorization#getAuthorization`
29
+
30
+ ## 0.6.0
31
+
32
+ **new**
33
+
34
+ - Create the Terminator, an API like [@godaddy/terminus](https://github.com/godaddy/terminus) for cross-platform graceful HTTP shutdown.
35
+ - (unstable) Authorize & Authenticate requests
36
+ - (unstable) Store things in key-value pairs
37
+ - (unstable) `serveHTTP` for Node.js a la `Deno.serve`
38
+
39
+ **changed**
40
+
41
+ - Deprecated environment-specific functions in favour of simpler names
42
+ - `get{Node,Deno}ConfigOptions` → `getConfigurationOptions`
43
+ - `get{Node,Deno}Configuration` → `getConfiguration`
44
+ - `get{Node,Deno}PostgresMigratorOptions` → `getPostgresMigratorOptions`
45
+ - `get{Node,Deno}PostgresMigrator` → `getPostgresMigrator`
46
+
47
+ **fixes**
48
+
49
+ - NodeRouter sets the correct `duplex` option
50
+ - FetchRouter ignores the body for `OPTIONS` & `TRACE` methods
51
+
52
+ **internal**
53
+
54
+ - Gruber is now primarily TypeScript, with JavaScript tests
55
+
5
56
  ## 0.5.0
6
57
 
7
58
  **new**
package/README.md CHANGED
@@ -21,9 +21,6 @@ An isomorphic JavaScript library for creating web apps.
21
21
  This is very much a WIP library, it started out as a documentation-driven-development project and I've slowly been building it.
22
22
  The various ideas it's composed of have been floating around in my mind for a year or so and writing this has helped explore those ideas.
23
23
 
24
- I quite like this documentation-driven-design.
25
- It really helps to think through the concepts and ideas of something before spending lots of time building it.
26
-
27
24
  ## About
28
25
 
29
26
  Gruber is a library of composable utilities for creating isomorphic JavaScript applications,
@@ -100,12 +97,22 @@ npm install gruber
100
97
 
101
98
  **Deno**
102
99
 
103
- Gruber is available at `esm.r0b.io/gruber@VERSION/mod.ts`.
100
+ Gruber is available at `esm.r0b.io/gruber@VERSION/mod.ts`, add it to your _deno.json_:
104
101
 
105
102
  > Replace `VERSION` with the one you want to use, maybe see [Releases](https://github.com/robb-j/gruber/releases).
106
103
 
104
+ ```json
105
+ {
106
+ "imports": {
107
+ "gruber/": "https://esm.r0b.io/gruber@VERSION/"
108
+ }
109
+ }
110
+ ```
111
+
112
+ Then use it like this:
113
+
107
114
  ```js
108
- import { defineRoute } from "https://esm.r0b.io/gruber@VERSION/mod.ts";
115
+ import { defineRoute } from "gruber/mod.ts";
109
116
  ```
110
117
 
111
118
  ## HTTP server
@@ -130,14 +137,14 @@ export default defineRoute({
130
137
  });
131
138
  ```
132
139
 
133
- A route is a definition to handle a specific HTTP request by returning a response.
140
+ A route is a definition to handle a specific HTTP request with a response.
134
141
  It defines which method and path it is responding to and an asynchronous function to handle the request.
135
142
 
136
143
  Both the [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)
137
144
  and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)
138
145
  are from the web Fetch API.
139
146
 
140
- It also has a `url` (as a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL)) of the request and `params`.
147
+ There is also a `url` (as a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL)) of the request and `params`.
141
148
  The parameters, `params`, are matched from the pathname, part of the result of [URLPattern.exec](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/exec).
142
149
  In this example `name` is matched in the request URL and is used to process the request.
143
150
 
@@ -165,7 +172,7 @@ export async function runServer(options) {
165
172
  If you were using Deno, the same server would look like:
166
173
 
167
174
  ```js
168
- import { DenoRouter } from "@gruber/deno/mod.js";
175
+ import { DenoRouter } from "gruber/mod.js";
169
176
 
170
177
  import helloRoute from "./hello-route.js";
171
178
 
@@ -210,6 +217,57 @@ try {
210
217
  }
211
218
  ```
212
219
 
220
+ **a terminator**
221
+
222
+ For highly available deployments and/or where you want a zero downtime deployment,
223
+ you might run your server behind a load balancer.
224
+ When a deployment goes out you run both instances at the same time
225
+ and instantly switch traffic at the network level when the new deployment is up and running.
226
+
227
+ To help with this your app often implements a `/healthz`-type endpoint that returns when your app is accepting network connections
228
+ or if it is terminating existing connections ready to be descheduled.
229
+ Terminator is here to help with this use-case.
230
+
231
+ ```js
232
+ import { DenoRouter, getTerminator } from "gruber";
233
+
234
+ import { appConfig } from "./config.js";
235
+ import helloRoute from "./hello-route.js";
236
+
237
+ // 1. Create your Terminator, maybe call them arnie
238
+ export const terminator = getTerminator({
239
+ timeout: appConfig.env === "development" ? 0 : 5_000,
240
+ });
241
+
242
+ // 2. Define the health endpoint
243
+ const healthzRoute = defineRoute({
244
+ method: "GET",
245
+ pathname: "/healthz",
246
+ handler: () => terminator.getResponse(),
247
+ });
248
+
249
+ export const routes = [helloRoute, healthzRoute];
250
+
251
+ export async function runServer(options) {
252
+ const router = new DenoRouter({ routes });
253
+
254
+ const server = Deno.serve({ port: options.port }, router.forDenoServe());
255
+
256
+ // 3. Start the terminator and define the shutdown procedure
257
+ terminus.start(async () => {
258
+ await server.shutdown();
259
+ // Perform clean-up
260
+ // Close the database connection
261
+ // ...
262
+ });
263
+ }
264
+ ```
265
+
266
+ > You might want to have this in separate files, this is just to easily document it in one place.
267
+
268
+ By default Terminator waits 5 seconds to terminate and listens for `SIGINT` and `SIGTERM`.
269
+ A nice pattern is to skip waiting in development, shown above.
270
+
213
271
  ## Configuration
214
272
 
215
273
  In production, it's very useful to be able to configure how an app behaves without having to modify the code and redeploy the entire thing.
@@ -243,10 +301,10 @@ Building on the [HTTP server](#http-server) above, we'll setup configuration. St
243
301
 
244
302
  ```js
245
303
  import fs from "node:fs";
246
- import { getNodeConfiguration } from "gruber";
304
+ import { getConfiguration } from "gruber";
247
305
 
248
306
  const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
249
- const config = getNodeConfiguration();
307
+ const config = getConfiguration();
250
308
 
251
309
  const struct = config.object({
252
310
  env: config.string({ variable: "NODE_ENV", fallback: "development" }),
@@ -448,7 +506,7 @@ and we need to set up our database with **database.js**
448
506
  ```js
449
507
  import process from "node:process";
450
508
  import postgres from "postgres";
451
- import { loader, getNodePostgresMigrator } from "gruber";
509
+ import { loader, getPostgresMigrator } from "gruber";
452
510
  import { appConfig } from "./config.js";
453
511
 
454
512
  export const useDatabase = loader(() => {
@@ -457,7 +515,7 @@ export const useDatabase = loader(() => {
457
515
  });
458
516
 
459
517
  export async function getMigrator() {
460
- return getNodePostgresMigrator({
518
+ return getPostgresMigrator({
461
519
  directory: new URL("./migrations/", import.meta.url),
462
520
  sql: await useDatabase(),
463
521
  });
@@ -668,7 +726,9 @@ TODO: I'm not happy with this, will need to come back to it.
668
726
 
669
727
  ## Core library
670
728
 
671
- ### defineRoute
729
+ ### http
730
+
731
+ #### defineRoute
672
732
 
673
733
  `defineRoute` is the way of creating route primatives to be passed to your router to handle web traffic.
674
734
 
@@ -687,7 +747,7 @@ export const helloRoute = defineRoute({
687
747
  });
688
748
  ```
689
749
 
690
- ### HTTPError
750
+ #### HTTPError
691
751
 
692
752
  `HTTPError` is an Error subclass with specific information about HTTP errors.
693
753
  Gruber catches these errors and converts them into HTTP Responses.
@@ -770,7 +830,7 @@ class BadJSONRequest extends HTTPError {
770
830
  throw new BadJSONRequest({ message: "Something went wrong..." });
771
831
  ```
772
832
 
773
- ### FetchRouter
833
+ #### FetchRouter
774
834
 
775
835
  `FetchRouter` is a web-native router for routes defined with `defineRoute`.
776
836
 
@@ -807,11 +867,15 @@ Use it to get a `Response` from the provided request, based on the router's rout
807
867
  const response = await router.getResponse(new Request("http://localhost"));
808
868
  ```
809
869
 
870
+ #### unstable http
871
+
810
872
  There are some unstable internal methods too:
811
873
 
812
874
  - `findMatchingRoutes(request)` is a generator function to get the first route definition that matches the supplied request. It's a generator so as few routes are matched as possible and execution can be stopped if you like.
813
875
  - `processMatches(request, matches)` attempts to get a `Response` from a request and an Iterator of route definitions.
814
876
  - `handleError(error, request)` converts a error into a Response and triggers the `errorHandler`
877
+ - `getRequestBody(request)` Get the JSON of FormData body of a request
878
+ - `assertRequestBody(struct, body)` Assert the body matches a structure and return the parsed value
815
879
 
816
880
  ### Postgres
817
881
 
@@ -917,6 +981,208 @@ On the error, there are also methods to help use it:
917
981
 
918
982
  There is also the static method `StructError.chain(error, context)` which is useful for catching errors and applying a context to them (if they are not already a StructError).
919
983
 
984
+ ### Terminator
985
+
986
+ Terminator helps you gracefully deploy servers with zero downtime when using a load balancer.
987
+
988
+ ```js
989
+ import { Terminator } from "gruber/terminator.js";
990
+
991
+ // All options are optional, these are also the defaults
992
+ const arnie = getTerminator({
993
+ signals: ["SIGINT", "SIGTERM"],
994
+ timeout: 5_000, // perhaps: appConfig.env === 'development' ? 0 : 5_000
995
+ });
996
+
997
+ // Generate a HTTP response based on the current state of the Terminator
998
+ // A 200 if running or 503 if terminating
999
+ const response = arnie.getResponse();
1000
+
1001
+ // Get the current state of the Terminator, either 'running' or 'terminating'
1002
+ arnie.state;
1003
+
1004
+ // Start the Terminator process and define the shutdown procedure
1005
+ arnie.start(async () => {
1006
+ // shut down things like HTTP servers or database connections
1007
+ });
1008
+ ```
1009
+
1010
+ The block passed to `start` can be async and it handles errors by logging them and exiting with a non-zero status code.
1011
+
1012
+ ### Store
1013
+
1014
+ > UNSTABLE
1015
+
1016
+ The Store is for when you have values that you want to remember under certain keys.
1017
+ It is an abstract interface over that so there can be multiple implementations
1018
+ for different storage methods and so it can be inter-operated between different services.
1019
+
1020
+ While some implementations may be sync, the interface is based on async so both can co-exist.
1021
+
1022
+ There is a rough idea of using absolute paths, e.g. `/some/absolute/path`
1023
+ and some stores may offer a "prefix" option to allow multi-tennancy
1024
+ so the store internally could put it at `/v1/some/absolute/path`.
1025
+
1026
+ ```ts
1027
+ import { MemoryStore } from "gruber";
1028
+
1029
+ const store = new MemoryStore();
1030
+
1031
+ // Put geoff in the store
1032
+ await store.set("/some/key", { name: "Geoff Testington", age: 42 });
1033
+
1034
+ // Put something in the store, just for a bit
1035
+ await store.set(
1036
+ "/login/55",
1037
+ { token: "abcdef" },
1038
+ { maxAge: 30 * 1_000 /* 30 seconds */ },
1039
+ );
1040
+
1041
+ // Retrieve geoff, types optional
1042
+ const geoff = await store.get<GeoffRecord>("/some/key");
1043
+
1044
+ // Ok, time for geoff to go
1045
+ await store.delete("/some/key");
1046
+ ```
1047
+
1048
+ The store is meant for temporary resources, so its mostly meant to be called with the `maxAge` option
1049
+
1050
+ > The `MemoryStore` is also useful for testing, you can provide a TimerService to mock time
1051
+
1052
+ There are these experimental stores too:
1053
+
1054
+ ```ts
1055
+ import { PostgresStore, RedisStore } from "gruber";
1056
+ import { Sql } from "postgres";
1057
+ import { RedisClientType } from "redis";
1058
+
1059
+ const sql: Sql;
1060
+ const store = new PostgresStore(sql, { tableName: "cache" });
1061
+
1062
+ const redis: RedisClientType;
1063
+ const store = new RedisStore(redis, { prefix: "/v2" });
1064
+ ```
1065
+
1066
+ ### JWT
1067
+
1068
+ An abstraction around signing a JWT for a user with an access scope.
1069
+ There is currently one implementation using [jose](https://github.com/panva/jose).
1070
+
1071
+ ```ts
1072
+ import { JoseJwtService } from "gruber";
1073
+ import * as jose from "jose";
1074
+
1075
+ const jwt = new JoseJwtService(
1076
+ {
1077
+ secret: "top_secret",
1078
+ issuer: "myapp.io",
1079
+ audience: "myapp.io",
1080
+ },
1081
+ jose,
1082
+ );
1083
+
1084
+ // string
1085
+ const token = await jwt.sign("user:books:read", {
1086
+ userId: 1,
1087
+ maxAge: 30 * 24 * 60 * 60 * 1_000, // 30 days
1088
+ });
1089
+
1090
+ // { userId, scope } or null
1091
+ const parsed = await jwt.verify(token);
1092
+ ```
1093
+
1094
+ ### Authorization
1095
+
1096
+ > UNSTABLE
1097
+
1098
+ A module for checking Request objects have authorization to perform actions on the server
1099
+
1100
+ ```ts
1101
+ import { TokenService, AuthorizationService, includesScope } from "gruber";
1102
+
1103
+ const tokens: TokenService;
1104
+ const authz = new AuthorizationService({ cookieName: "my_session" }, tokens);
1105
+
1106
+ // string | null
1107
+ const token = authz.getAuthorization(
1108
+ new Request("https://example.com", {
1109
+ headers: { Authorization: "Bearer some-long-secure-token" },
1110
+ }),
1111
+ );
1112
+
1113
+ // { userId: number | undefined, scope: string }
1114
+ const { userId, scope } = await authz.assert(
1115
+ new Request("https://example.com", {
1116
+ headers: { Authorization: "Bearer some-long-secure-token" },
1117
+ }),
1118
+ );
1119
+
1120
+ // { userId: number, scope: string }
1121
+ const { userId, scope } = await authz.assertUser(
1122
+ new Request("https://example.com", {
1123
+ headers: { Cookie: "my_session=some-long-secure-token" },
1124
+ }),
1125
+ { scope: "user:books:read" },
1126
+ );
1127
+
1128
+ includesScope("user:books:read", "user:books:read"); // true
1129
+ includesScope("user:books", "user:books:read"); // true
1130
+ includesScope("user", "user:books:read"); // true
1131
+ includesScope("user", "user:podcasts"); // true
1132
+ includesScope("user:books", "user:podcasts"); // false
1133
+ ```
1134
+
1135
+ Any of these methods will throw a `HTTPError.unauthorized` (a 401) if the authorization is not present or invalid.
1136
+
1137
+ **scopes**
1138
+
1139
+ Scopes are abstract hierarchical access to things in your application.
1140
+ They are checked from left to right, so if the request has the top-level it allows access to scopes beneath it.
1141
+ There is also the special `admin` scope which has access to all resources.
1142
+
1143
+ The idea is you might check for `user:books:write` inside a request handler against the scope the request is authorized with. When the user signed in or created that access token, it might only have `user:bookes:read` so now we know they cannot perform this request.
1144
+
1145
+ ### Authentication
1146
+
1147
+ > UNSTABLE
1148
+
1149
+ Authentication provides a service to help users get authorization to use the application.
1150
+
1151
+ ```ts
1152
+ import {
1153
+ AuthenticationService,
1154
+ Store,
1155
+ RandomService,
1156
+ JWTService,
1157
+ } from "gruber";
1158
+
1159
+ const store: Store;
1160
+ const jwt: JWTService;
1161
+ const random: RandomService; // OR // = useRandom()
1162
+ const options = {
1163
+ allowedHosts: () => [new URL("https://example.com")],
1164
+ cookieName: "my_session",
1165
+ sessionDuration: 30 * 24 * 60 * 60 * 1_000, // 30 days
1166
+ loginDuration: 15 * 60 * 1_000, // 15 minutes
1167
+ };
1168
+
1169
+ const authn = new AuthenticationService(options, store, random, jwt);
1170
+
1171
+ // Get the token and code the user must match to complete the authentication
1172
+ // These could be sent in a magic link perhaps
1173
+ const { token, code } = await authn.start(userId, redirectUrl);
1174
+
1175
+ // Use a user-provided token & code to check if they are a valid log in
1176
+ const login = await authn.check(token, code);
1177
+
1178
+ // If valid, complete the authentication and get back their redirect,
1179
+ // headers to set the authz cookie and their raw token too
1180
+ const { token, headers, redirect } = await authn.finish(login);
1181
+ ```
1182
+
1183
+ These would obviously be spread accross multiple endpoints and you transfer
1184
+ the token / code combination to the user in a way that proves they are who they claim to be.
1185
+
920
1186
  ### Utilities
921
1187
 
922
1188
  #### loader
@@ -1009,11 +1275,11 @@ These are the options:
1009
1275
  For example, to override in Node:
1010
1276
 
1011
1277
  ```js
1012
- import { Configuration, getNodeConfigOptions } from "gruber";
1278
+ import { Configuration, getConfigurationOptions } from "gruber";
1013
1279
  import Yaml from "yaml";
1014
1280
 
1015
1281
  const config = new Configuration({
1016
- ...getNodeConfigOptions(),
1282
+ ...getConfigurationOptions(),
1017
1283
  getEnvionmentVariable: () => undefined,
1018
1284
  stringify: (v) => Yaml.stringify(v),
1019
1285
  parse: (v) => Yaml.parse(v),
@@ -1199,6 +1465,16 @@ const server = http.createServer((req) => {
1199
1465
  });
1200
1466
  ```
1201
1467
 
1468
+ #### getResponseReadable
1469
+
1470
+ `getResponseReadable` creates a [streams:Readable](https://nodejs.org/api/stream.html#class-streamreadable) from the body of a fetch Response.
1471
+
1472
+ ```js
1473
+ import { getResponseReadable } from "gruber/node-router.js";
1474
+
1475
+ const readable = getResponseReadable(new Response("some body"));
1476
+ ```
1477
+
1202
1478
  ## Development
1203
1479
 
1204
1480
  WIP stuff
@@ -1238,6 +1514,7 @@ export function loader<T>(handler: Loader<T>): Loader<T> {
1238
1514
 
1239
1515
  ```js
1240
1516
  async function retryWithBackoff({
1517
+ timers = window,
1241
1518
  maxRetries = 20,
1242
1519
  interval = 1_000,
1243
1520
  handler,
@@ -1247,7 +1524,7 @@ async function retryWithBackoff({
1247
1524
  const result = await handler();
1248
1525
  return result;
1249
1526
  } catch {
1250
- await new Promise((r) => setTimeout(r, i * interval));
1527
+ await new Promise((r) => timers.setTimeout(r, i * interval));
1251
1528
  }
1252
1529
  }
1253
1530
  console.error("Could not connect to database");
@@ -0,0 +1,51 @@
1
+ import { RandomService } from "./random.ts";
2
+ import { Store } from "./store.ts";
3
+ import { TokenService } from "./tokens.ts";
4
+ /**
5
+ * An in-progress authentication, being stored while the client completes their challenge
6
+ */
7
+ export interface AuthnRequest {
8
+ userId: number;
9
+ redirect: string;
10
+ code: number;
11
+ }
12
+ /**
13
+ * Intermediary parameters to present to the user to complete their authentication
14
+ */
15
+ export interface AuthnCheck {
16
+ token: string;
17
+ code: number;
18
+ }
19
+ /**
20
+ * Completed credentials after completing an authentication,
21
+ * including cookie+redirected headers and the redirect itself
22
+ */
23
+ export interface AuthnResult {
24
+ token: string;
25
+ headers: Headers;
26
+ redirect: string;
27
+ }
28
+ export interface AbstractAuthenticationService {
29
+ check(token: string | undefined | null, code: string | number | undefined | null): Promise<AuthnRequest | null>;
30
+ start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
31
+ finish(request: AuthnRequest): Promise<AuthnResult>;
32
+ }
33
+ export declare function formatAuthenticationCode(code: number): string;
34
+ export interface AuthenticationServiceOptions {
35
+ allowedHosts: () => URL[] | Promise<URL[]>;
36
+ cookieName: string;
37
+ /** milliseconds */ loginDuration: number;
38
+ /** milliseconds */ sessionDuration: number;
39
+ }
40
+ export declare class AuthenticationService implements AbstractAuthenticationService {
41
+ options: AuthenticationServiceOptions;
42
+ store: Store;
43
+ random: RandomService;
44
+ tokens: TokenService;
45
+ constructor(options: AuthenticationServiceOptions, store: Store, random: RandomService, tokens: TokenService);
46
+ _canRedirect(input: string | URL, hosts: URL[]): boolean;
47
+ check(token: string | undefined | null, code: string | number | undefined | null): Promise<AuthnRequest | null>;
48
+ start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
49
+ finish(request: AuthnRequest): Promise<AuthnResult>;
50
+ }
51
+ //# sourceMappingURL=authentication.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authentication.d.ts","sourceRoot":"","sources":["authentication.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,MAAM,CAAC;IAEf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACb;AAKD;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,6BAA6B;IAC7C,KAAK,CACJ,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EAChC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,GACtC,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAChC,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACtE,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CACpD;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,UAKpD;AAED,MAAM,WAAW,4BAA4B;IAC5C,YAAY,EAAE,MAAM,GAAG,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,aAAa,EAAE,MAAM,CAAC;IAC1C,mBAAmB,CAAC,eAAe,EAAE,MAAM,CAAC;CAC5C;AAED,qBAAa,qBAAsB,YAAW,6BAA6B;IAElE,OAAO,EAAE,4BAA4B;IACrC,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,aAAa;IACrB,MAAM,EAAE,YAAY;gBAHpB,OAAO,EAAE,4BAA4B,EACrC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,YAAY;IAO5B,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE;IAcxC,KAAK,CACV,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EAChC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,GACtC,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAkBzB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAoBrE,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;CAuBzD"}
@@ -0,0 +1,81 @@
1
+ import { HTTPError } from "./http.js";
2
+ export function formatAuthenticationCode(code) {
3
+ return [
4
+ code.toString().padStart(6, "0").slice(0, 3),
5
+ code.toString().padStart(6, "0").slice(3, 6),
6
+ ].join(" ");
7
+ }
8
+ export class AuthenticationService {
9
+ options;
10
+ store;
11
+ random;
12
+ tokens;
13
+ constructor(options, store, random, tokens) {
14
+ this.options = options;
15
+ this.store = store;
16
+ this.random = random;
17
+ this.tokens = tokens;
18
+ }
19
+ //
20
+ // Internal
21
+ //
22
+ _canRedirect(input, hosts) {
23
+ const url = new URL(input);
24
+ for (const allowed of hosts) {
25
+ if (url.protocol !== allowed.protocol)
26
+ continue;
27
+ if (url.host !== allowed.host)
28
+ continue;
29
+ if (url.pathname.startsWith(allowed.pathname))
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ //
35
+ // Public
36
+ //
37
+ async check(token, code) {
38
+ if (typeof code === "string")
39
+ code = parseInt(code);
40
+ if (typeof token !== "string" ||
41
+ typeof code !== "number" ||
42
+ Number.isNaN(code)) {
43
+ return null;
44
+ }
45
+ const loginRequest = await this.store.get(`/authn/request/${token}`);
46
+ if (!loginRequest || code !== loginRequest.code)
47
+ return null;
48
+ return loginRequest;
49
+ }
50
+ async start(userId, redirectUrl) {
51
+ const allowedHosts = await this.options.allowedHosts();
52
+ if (!this._canRedirect(redirectUrl, allowedHosts)) {
53
+ throw HTTPError.badRequest("invalid redirect_uri");
54
+ }
55
+ const token = this.random.uuid();
56
+ const request = {
57
+ userId,
58
+ redirect: new URL(redirectUrl).toString(),
59
+ code: this.random.number(0, 999_999),
60
+ };
61
+ await this.store.set(`/authn/request/${token}`, request, {
62
+ maxAge: this.options.loginDuration,
63
+ });
64
+ return { token, code: request.code };
65
+ }
66
+ async finish(request) {
67
+ const headers = new Headers();
68
+ headers.set("Location", request.redirect);
69
+ const token = await this.tokens.sign("user", {
70
+ userId: request.userId,
71
+ maxAge: this.options.sessionDuration,
72
+ });
73
+ const duration = Math.floor(this.options.sessionDuration / 1000);
74
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
75
+ headers.append("Set-Cookie", `${this.options.cookieName}=${token}; Max-Age=${duration}; Path=/; HttpOnly`);
76
+ // TODO: Microsoft "safe links" opens URLs, generates auth then throws it away
77
+ // Maybe it should be a counter? like 3 you get uses
78
+ // await cache.delete(`/authn/request/${request.token}`)
79
+ return { token, headers, redirect: request.redirect };
80
+ }
81
+ }