@grest-ts/http 0.0.47 → 0.0.49

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 (128) hide show
  1. package/README.md +321 -128
  2. package/dist/src/_dedupCheck.js +1 -1
  3. package/dist/src/client/GGContextKeySynchronizer.d.ts +11 -0
  4. package/dist/src/client/GGContextKeySynchronizer.d.ts.map +1 -0
  5. package/dist/src/client/GGContextKeySynchronizer.js +26 -0
  6. package/dist/src/client/GGContextKeySynchronizer.js.map +1 -0
  7. package/dist/src/client/GGHttpSchema.createClient.d.ts +8 -0
  8. package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -1
  9. package/dist/src/client/GGHttpSchema.createClient.js +2 -1
  10. package/dist/src/client/GGHttpSchema.createClient.js.map +1 -1
  11. package/dist/src/index-browser.d.ts +4 -0
  12. package/dist/src/index-browser.d.ts.map +1 -1
  13. package/dist/src/index-browser.js +4 -0
  14. package/dist/src/index-browser.js.map +1 -1
  15. package/dist/src/index-node.d.ts +9 -1
  16. package/dist/src/index-node.d.ts.map +1 -1
  17. package/dist/src/index-node.js +17 -5
  18. package/dist/src/index-node.js.map +1 -1
  19. package/dist/src/rpc/GGHttpRouteRPC.d.ts +4 -0
  20. package/dist/src/rpc/GGHttpRouteRPC.d.ts.map +1 -1
  21. package/dist/src/rpc/GGHttpRouteRPC.js +6 -0
  22. package/dist/src/rpc/GGHttpRouteRPC.js.map +1 -1
  23. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts +4 -3
  24. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -1
  25. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +9 -2
  26. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -1
  27. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts +3 -3
  28. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts.map +1 -1
  29. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js +13 -16
  30. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js.map +1 -1
  31. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts +1 -0
  32. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts.map +1 -1
  33. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js +4 -0
  34. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js.map +1 -1
  35. package/dist/src/schema/GGCookie.d.ts +50 -0
  36. package/dist/src/schema/GGCookie.d.ts.map +1 -0
  37. package/dist/src/schema/GGCookie.js +130 -0
  38. package/dist/src/schema/GGCookie.js.map +1 -0
  39. package/dist/src/schema/GGHeader.d.ts +23 -0
  40. package/dist/src/schema/GGHeader.d.ts.map +1 -0
  41. package/dist/src/schema/GGHeader.js +41 -0
  42. package/dist/src/schema/GGHeader.js.map +1 -0
  43. package/dist/src/schema/GGHttpPermissionsChecker.d.ts +9 -0
  44. package/dist/src/schema/GGHttpPermissionsChecker.d.ts.map +1 -0
  45. package/dist/src/schema/GGHttpPermissionsChecker.js +29 -0
  46. package/dist/src/schema/GGHttpPermissionsChecker.js.map +1 -0
  47. package/dist/src/schema/GGHttpSchema.d.ts +14 -52
  48. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
  49. package/dist/src/schema/GGHttpSchema.js.map +1 -1
  50. package/dist/src/schema/GGWireContextKey.d.ts +30 -0
  51. package/dist/src/schema/GGWireContextKey.d.ts.map +1 -0
  52. package/dist/src/schema/GGWireContextKey.js +36 -0
  53. package/dist/src/schema/GGWireContextKey.js.map +1 -0
  54. package/dist/src/schema/GGWireContextKey.node.d.ts +36 -0
  55. package/dist/src/schema/GGWireContextKey.node.d.ts.map +1 -0
  56. package/dist/src/schema/GGWireContextKey.node.js +83 -0
  57. package/dist/src/schema/GGWireContextKey.node.js.map +1 -0
  58. package/dist/src/schema/httpSchema.d.ts +2 -4
  59. package/dist/src/schema/httpSchema.d.ts.map +1 -1
  60. package/dist/src/schema/httpSchema.js +0 -48
  61. package/dist/src/schema/httpSchema.js.map +1 -1
  62. package/dist/src/server/GGHttp.d.ts +2 -26
  63. package/dist/src/server/GGHttp.d.ts.map +1 -1
  64. package/dist/src/server/GGHttp.js +0 -15
  65. package/dist/src/server/GGHttp.js.map +1 -1
  66. package/dist/src/server/GGHttpSchema.startServer.d.ts +2 -11
  67. package/dist/src/server/GGHttpSchema.startServer.d.ts.map +1 -1
  68. package/dist/src/server/GGHttpSchema.startServer.js +14 -28
  69. package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
  70. package/dist/src/server/GGHttpServer.d.ts +45 -14
  71. package/dist/src/server/GGHttpServer.d.ts.map +1 -1
  72. package/dist/src/server/GGHttpServer.js +61 -87
  73. package/dist/src/server/GGHttpServer.js.map +1 -1
  74. package/dist/src/server/applyRequestMiddleware.d.ts +11 -0
  75. package/dist/src/server/applyRequestMiddleware.d.ts.map +1 -0
  76. package/dist/src/server/applyRequestMiddleware.js +38 -0
  77. package/dist/src/server/applyRequestMiddleware.js.map +1 -0
  78. package/dist/src/server/applyResponseMiddleware.d.ts +12 -0
  79. package/dist/src/server/applyResponseMiddleware.d.ts.map +1 -0
  80. package/dist/src/server/applyResponseMiddleware.js +25 -0
  81. package/dist/src/server/applyResponseMiddleware.js.map +1 -0
  82. package/dist/src/server/checkers/checkPermissionsAtStart.d.ts +39 -0
  83. package/dist/src/server/checkers/checkPermissionsAtStart.d.ts.map +1 -0
  84. package/dist/src/server/checkers/checkPermissionsAtStart.js +82 -0
  85. package/dist/src/server/checkers/checkPermissionsAtStart.js.map +1 -0
  86. package/dist/src/server/checkers/checkWireConflicts.d.ts +18 -0
  87. package/dist/src/server/checkers/checkWireConflicts.d.ts.map +1 -0
  88. package/dist/src/server/checkers/checkWireConflicts.js +43 -0
  89. package/dist/src/server/checkers/checkWireConflicts.js.map +1 -0
  90. package/dist/src/server/checkers/checkWiresImplemented.d.ts +11 -0
  91. package/dist/src/server/checkers/checkWiresImplemented.d.ts.map +1 -0
  92. package/dist/src/server/checkers/checkWiresImplemented.js +27 -0
  93. package/dist/src/server/checkers/checkWiresImplemented.js.map +1 -0
  94. package/dist/src/tsconfig.json +2 -1
  95. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  96. package/package.json +14 -11
  97. package/src/_dedupCheck.ts +1 -1
  98. package/src/client/GGContextKeySynchronizer.test.ts +106 -0
  99. package/src/client/GGContextKeySynchronizer.ts +42 -0
  100. package/src/client/GGHttpSchema.createClient.ts +10 -1
  101. package/src/index-browser.ts +4 -0
  102. package/src/index-node.ts +18 -5
  103. package/src/rpc/GGHttpRouteRPC.ts +8 -0
  104. package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +11 -4
  105. package/src/rpc/RpcRequest/GGRpcRequestParser.ts +16 -17
  106. package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +5 -0
  107. package/src/schema/GGCookie.ts +151 -0
  108. package/src/schema/GGHeader.ts +48 -0
  109. package/src/schema/GGHttpPermissionsChecker.test.ts +125 -0
  110. package/src/schema/GGHttpPermissionsChecker.ts +35 -0
  111. package/src/schema/GGHttpSchema.ts +15 -63
  112. package/src/schema/GGWireContextKey.node.ts +119 -0
  113. package/src/schema/GGWireContextKey.ts +46 -0
  114. package/src/schema/httpSchema.ts +4 -57
  115. package/src/server/GGHttp.ts +3 -34
  116. package/src/server/GGHttpSchema.startServer.ts +16 -38
  117. package/src/server/GGHttpServer.ts +90 -97
  118. package/src/server/applyRequestMiddleware.ts +40 -0
  119. package/src/server/applyResponseMiddleware.ts +27 -0
  120. package/src/server/checkers/checkPermissionsAtStart.ts +112 -0
  121. package/src/server/checkers/checkWireConflicts.ts +65 -0
  122. package/src/server/checkers/checkWiresImplemented.ts +42 -0
  123. package/src/tsconfig.json +2 -1
  124. package/dist/src/server/GG_PERMISSIONS.d.ts +0 -15
  125. package/dist/src/server/GG_PERMISSIONS.d.ts.map +0 -1
  126. package/dist/src/server/GG_PERMISSIONS.js +0 -15
  127. package/dist/src/server/GG_PERMISSIONS.js.map +0 -1
  128. package/src/server/GG_PERMISSIONS.ts +0 -15
package/README.md CHANGED
@@ -168,169 +168,237 @@ export const MyApiContract = new GGContractClass("MyApi", {
168
168
 
169
169
  ## Permissions
170
170
 
171
- Contract methods may declare a `permission`. The framework gates every request against the declared permission **before the handler runs**, so missing or wrong scopes can never reach service code. Declarations are opt-in but *infectious*: once any route on the server declares one (or `.usePermissions(...)` is wired), every route on the server must declare — the runtime refuses to start otherwise (see "Hard guarantee" below).
171
+ Contract methods may declare a `permission`. The framework gates every request against the declared permission **before the handler runs**, so missing or wrong scopes can never reach service code. The caller's scopes come from the **wires** the schema `.use()`s (see "Authentication & Context" below) — a smart wire verifies the credential and exposes the caller's grants via its `permissions()` resolver. There is no separate `.usePermissions(...)` step and no auth-middleware chain; the schema's wires ARE the source of scopes.
172
172
 
173
- The wiring chain is read top-to-bottom:
173
+ Declarations are opt-in but *infectious*: once any route on the server declares a `permission`, every route on the server must declare one — the runtime refuses to start otherwise (see "Hard guarantee" below).
174
174
 
175
175
  ```typescript
176
- new GGHttp(httpServer)
177
- .use(new JwtAuthMiddleware(secret)) // parses the token → context
178
- .usePermissions(getScopes) // reads context → scope set
179
- .http(ItemApi, new ItemApiImpl())
176
+ // contract: declare the permission a route requires
177
+ export const ItemApiContract = new GGContractClass("ItemApi", {
178
+ list: { success: IsArray(IsItem), errors: [SERVER_ERROR], permission: GG_NO_PERMISSIONS },
179
+ delete: { input: IsItemIdParam, errors: [NOT_AUTHORIZED, FORBIDDEN, SERVER_ERROR], permission: ItemPermission.DELETE },
180
+ })
181
+
182
+ // schema: .use() the wire that authenticates + resolves scopes for these routes
183
+ export const ItemApi = httpSchema(ItemApiContract)
184
+ .pathPrefix("api/items")
185
+ .use(USER_TOKEN_WIRE)
186
+ .routes({ list: GGRpc.GET("list"), delete: GGRpc.DELETE("delete/:id") })
180
187
  ```
181
188
 
182
- Each step has one job: the auth middleware turns a token into identity in context; the scope resolver (a zero-arg `() => ReadonlySet<string> | null`, sync or async) extracts the caller's scopes from that context; the gate calls the resolver, checks `satisfies(method.permission, scopes)`, and throws `NOT_AUTHORIZED` (no identity) or `FORBIDDEN` (wrong scopes). Order matters — `.usePermissions(...)` must come before the `.http(...)` calls it should apply to.
189
+ The gate reads the method's `permission`, collects the caller's grants from every wire on the schema (each wire's `permissions()`), pools them into one set, checks `satisfies(method.permission, pooledGrants)`, and throws `NOT_AUTHORIZED` (a wire couldn't authenticate) or `FORBIDDEN` (authenticated, wrong scopes).
190
+
191
+ **Multi-wire composition (AND across sources).** A schema may `.use()` more than one auth wire (e.g. a user wire AND an org wire). Each wire's server handler runs at the request boundary, so **every** wire must authenticate — any one throwing rejects the request. That is the AND across sources. For scope checks the grants from all wires are **pooled** into one set, and the method's `permission` must be satisfied by that union; grants are matched by string against the pool, with no per-wire ownership of permission strings. A route that declares a non-public `permission` on a schema that `.use()`s **no** permission-resolving wire can never be satisfied (its pooled grants are always empty), so it fails at startup — see the hard guarantee below.
183
192
 
184
- **Hard guarantee.** At server start the framework walks every HTTP / WS route registered on the `GGHttpServer`. If any of them declared a permission, or any chain wired `.usePermissions(...)`, **strict mode** is on for the whole server. In strict mode:
193
+ **Hard guarantee.** At server start the framework walks every HTTP / WS route registered on the `GGHttpServer`, before it accepts traffic. Two guarantees hold:
185
194
 
186
- - Every route must declare `permission` — use `GG_NO_PERMISSIONS` for intentionally public ones. Routes that omit it fail the start and are listed by name.
187
- - A route declaring a non-public permission without a resolver on its registering chain also fails the start.
195
+ - **Wires are implemented (always).** A `.use()`d wire that was never implemented (no `.define(...).create(deps)` in `compose()`) fails the start — a listed wire must work or the request fails loud. This runs regardless of whether any permission is declared.
196
+ - **Permission coverage (strict mode).** If any route declared a permission, **strict mode** is on for the whole server: every route must declare `permission` — use `GG_NO_PERMISSIONS` for intentionally public ones. Routes that omit it fail the start and are listed by name.
188
197
 
189
- The check is per-server (HTTP routes + WS schemas on the same `GGHttpServer`), so a permission declared anywhere is infectious across sibling chains. The only way to opt out is to declare nothing — projects with no auth pay zero ceremony, but the moment one route opts in, the framework forces consistency.
198
+ The coverage check is per-server (HTTP routes + WS schemas on the same `GGHttpServer`), so a permission declared anywhere is infectious across sibling chains. The only way to opt out is to declare nothing — projects with no auth pay zero ceremony, but the moment one route opts in, the framework forces consistency.
190
199
 
191
- **What this does not cover.** The permission gates *endpoint access* — "is this caller allowed to invoke this method at all?" It does not handle *resource access* — "can this caller edit *this specific* post?" That check still belongs in the handler. The same `GGPermissionChecker` the gate used is exposed via `GG_PERMISSIONS.get()`, so handler-side sub-decisions use identical logic with no drift between framework and app code.
200
+ **What this does not cover.** The permission gates *endpoint access* — "is this caller allowed to invoke this method at all?" It does not handle *resource access* — "can this caller edit *this specific* post?" That check still belongs in the handler, which reads the app's own durable principal (minted by the wire's `process()`) — not a framework scope bag.
192
201
 
193
202
  ## Authentication & Context
194
203
 
195
- ### Using Codec (Recommended for Header-Based Auth)
204
+ Authentication and per-request context ride on **wires**. A wire (`GGHeader` / `GGCookie`)
205
+ *is* a context key and a transport middleware at once: it reads a value off the inbound
206
+ request and attaches to a schema with a single `.use(WIRE)`. The schema declares the wire
207
+ once and every method on it parses (and, for credentials, verifies) that wire — you can't
208
+ forget it on a method, and "is this endpoint protected?" is readable off the schema.
209
+
210
+ There are two tiers, decided by whether the wire is `.define()`d server-side:
211
+
212
+ - **Smart (credential) wire** — verified. Holds the raw inbound credential, which is
213
+ **ephemeral**: verified inside the server handler's `process()`, then cleared *before*
214
+ the route handler runs. Handlers read a durable principal the handler minted, never the
215
+ token. Requires `.define()` (server) and `.create(deps)` (per runtime).
216
+ - **Ambient wire** — never `.define()`d. The parsed value lands in the wire and persists
217
+ through the handler (read via `WIRE.get()`). For non-credential headers where absent →
218
+ `undefined` is fine.
219
+
220
+ ### Smart wire — token auth (the common case)
221
+
222
+ The wire and its identity types live in the shared `api/`; the verification handler and the
223
+ durable principal live **server-side only** (handlers/services read it; the client never sees it).
196
224
 
197
225
  ```typescript
198
- // auth/UserAuth.ts
199
- import { GGContextKey } from "@grest-ts/context"
200
- import { IsObject, IsString } from "@grest-ts/schema"
226
+ // api/auth/UserAuth.ts (shared)
227
+ import { GGHeader } from "@grest-ts/http"
228
+ import { IsArray, IsEnum, IsObject, IsString } from "@grest-ts/schema"
201
229
 
202
- export const IsUserAuthToken = IsString.brand("UserAuthToken")
203
- export type tUserAuthToken = typeof IsUserAuthToken.infer
230
+ export enum UserPermission { CAN_EDIT = "CAN_EDIT" }
231
+ export const IsUserPermission = IsEnum(UserPermission)
204
232
 
205
233
  export const IsUserId = IsString.brand("UserId")
206
- export type tUserId = typeof IsUserId.infer
207
-
208
234
  export const IsUser = IsObject({
209
235
  id: IsUserId,
210
236
  username: IsString,
211
- email: IsString
237
+ email: IsString,
238
+ permissions: IsArray(IsUserPermission),
212
239
  })
213
240
  export type User = typeof IsUser.infer
214
241
 
215
- // Define the context value schema
216
- const IsUserAuthContext = IsObject({
217
- token: IsUserAuthToken
218
- })
219
- export type UserAuthContext = typeof IsUserAuthContext.infer
220
-
221
- // Define the header schema
222
- const HEADER_AUTHORIZATION = "authorization"
223
- const HeaderType = IsObject({
224
- [HEADER_AUTHORIZATION]: IsString.orUndefined
225
- })
242
+ // SMART wire: parses `Authorization: Bearer <jwt>`. The raw token is ephemeral — readable
243
+ // only inside the server handler's process(), then cleared before the route handler runs.
244
+ export const USER_TOKEN_WIRE = new GGHeader("authorization", { scheme: "bearer" })
245
+ ```
226
246
 
227
- // Create context key with codec
228
- export const GG_USER_AUTH = new GGContextKey<UserAuthContext>("user_auth", IsUserAuthContext)
229
- GG_USER_AUTH.addCodec("http", HeaderType.codecTo(IsUserAuthContext, {
230
- encode: (headers) => {
231
- const authHeader = headers[HEADER_AUTHORIZATION]
232
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
233
- return { token: undefined as any } // Will fail validation if required
234
- }
235
- return { token: authHeader.substring(7) as tUserAuthToken }
247
+ ```typescript
248
+ // server/auth/UserAuthHandler.ts (server-only)
249
+ import { GGContextKey } from "@grest-ts/context"
250
+ import { NOT_AUTHORIZED } from "@grest-ts/schema"
251
+ import { deepFreeze } from "@grest-ts/common"
252
+ import { IsUser, USER_TOKEN_WIRE } from "../../api/auth/UserAuth"
253
+ import type { UserService } from "../services/UserService"
254
+
255
+ // The durable principal — server-only. USER_DATA IS the User (it owns its permissions).
256
+ // Deep-frozen on set() so a handler can't mutate it to escalate.
257
+ export const USER_DATA = new GGContextKey("userData", IsUser)
258
+
259
+ // .define() attaches the verification handler. Process-global, frozen once (a second
260
+ // .define() throws). The returned registration is .create()d once per runtime in compose().
261
+ export const USER_TOKEN_WIRE_HANDLER = USER_TOKEN_WIRE.define((users: UserService) => ({
262
+ process: async () => {
263
+ // USER_TOKEN_WIRE.get() returns the raw bearer token — readable ONLY here.
264
+ const user = await users.verifyAccessToken(USER_TOKEN_WIRE.get())
265
+ if (!user) throw new NOT_AUTHORIZED({ debugMessage: "invalid token" })
266
+ USER_DATA.set(deepFreeze(user))
236
267
  },
237
- decode: (value) => {
238
- return { [HEADER_AUTHORIZATION]: value.token ? `Bearer ${value.token}` : undefined }
239
- }
268
+ // The caller's grants, read off the durable principal — feeds the per-method gate.
269
+ permissions: async () => USER_DATA.get()!.permissions,
240
270
  }))
241
271
  ```
242
272
 
243
- ### Using Middleware (For Complex Logic)
273
+ `.use()` the wire on every schema whose routes need it, and `.create()` its handler once in
274
+ the runtime's `compose()`:
275
+
276
+ ```typescript
277
+ // api/UserApi.ts
278
+ export const UserApi = httpSchema(UserApiContract)
279
+ .pathPrefix("api/users")
280
+ .use(USER_TOKEN_WIRE) // every route here verifies the token
281
+ .routes({ me: GGRpc.GET("me"), updateProfile: GGRpc.PUT("profile") })
282
+ ```
283
+
284
+ ```typescript
285
+ // server/AppRuntime.ts
286
+ protected compose(): void {
287
+ const userService = new UserService(tokenEngine)
288
+
289
+ // Bind the wire's deps into THIS runtime's scope — once per runtime (a fresh test
290
+ // worker / restart gets its own scope). Deps must be async-context-bound + stateless.
291
+ USER_TOKEN_WIRE_HANDLER.create(userService)
292
+
293
+ new GGHttp(new GGHttpServer())
294
+ .http(UserApi, userService)
295
+ }
296
+ ```
297
+
298
+ In a handler or service, read the durable principal — never the token:
299
+
300
+ ```typescript
301
+ export class UserService {
302
+ me = async (): Promise<User> => USER_DATA.get() // identity for this request
303
+ }
304
+ ```
305
+
306
+ ### Ambient wire — non-credential headers
307
+
308
+ For a header where absent → `undefined` is acceptable and there's nothing to verify, use a
309
+ bare wire (no `.define()`). The value lands in the wire and persists through the handler:
310
+
311
+ ```typescript
312
+ // shared api/
313
+ export const CLIENT_VERSION_WIRE = new GGHeader("x-client-version") // ambient
314
+
315
+ httpSchema(MyContract)
316
+ .use(CLIENT_VERSION_WIRE)
317
+ .routes({ ... })
318
+
319
+ // in a handler — read it directly:
320
+ const version = CLIENT_VERSION_WIRE.get() // string | undefined
321
+ ```
322
+
323
+ When you need several headers folded into one structured context value, write a custom
324
+ `GGTransportMiddleware` (one shape for HTTP and WebSocket). The runtime calls only the
325
+ hooks each side needs: a client runs `update(outbound)` to write outbound headers; a server
326
+ runs `parse(inbound)` to read inbound headers. Declare the headers it touches via `headers`
327
+ so CORS + doc-gen pick them up. Use this for ambient context — **not** for credentials,
328
+ which belong on a smart wire so the token can't leak into handler code.
244
329
 
245
330
  ```typescript
246
331
  // middleware/ClientInfoMiddleware.ts
247
- import { GGHttpRequest, GGHttpTransportMiddleware } from "@grest-ts/http"
248
- import { GGContextKey } from "@grest-ts/context"
332
+ import { GGTransportMiddleware, GGInbound, GGOutbound, GGContextKey } from "@grest-ts/context"
249
333
  import { IsObject, IsString, IsLiteral } from "@grest-ts/schema"
250
334
 
251
- export interface ClientInfo {
252
- version: string
253
- platform: 'web' | 'ios' | 'android'
254
- }
335
+ export interface ClientInfo { version: string; platform: 'web' | 'ios' | 'android' }
255
336
 
256
337
  export const GG_CLIENT_INFO = new GGContextKey<ClientInfo>('clientInfo', IsObject({
257
338
  version: IsString,
258
- platform: IsLiteral("web", "ios", "android")
339
+ platform: IsLiteral("web", "ios", "android"),
259
340
  }))
260
341
 
261
- export const ClientInfoMiddleware: GGHttpTransportMiddleware = {
262
- headers: ['x-client-version', 'x-client-platform'],
263
- responseHeaders: [],
264
- updateRequest(req: GGHttpRequest): void {
342
+ export const ClientInfoMiddleware: GGTransportMiddleware = {
343
+ headers: { 'x-client-version': IsString.orUndefined, 'x-client-platform': IsString.orUndefined },
344
+ update(outbound: GGOutbound): void {
265
345
  const info = GG_CLIENT_INFO.get()
266
346
  if (info) {
267
- req.headers['x-client-version'] = info.version
268
- req.headers['x-client-platform'] = info.platform
347
+ outbound.headers['x-client-version'] = info.version
348
+ outbound.headers['x-client-platform'] = info.platform
269
349
  }
270
350
  },
271
- parseRequest(req: GGHttpRequest): void {
351
+ parse(inbound: GGInbound): void {
272
352
  GG_CLIENT_INFO.set({
273
- version: req.headers['x-client-version'] ?? 'unknown',
274
- platform: (req.headers['x-client-platform'] ?? 'web') as ClientInfo['platform']
353
+ version: inbound.headers['x-client-version'] ?? 'unknown',
354
+ platform: (inbound.headers['x-client-platform'] ?? 'web') as ClientInfo['platform'],
275
355
  })
276
- }
356
+ },
277
357
  }
358
+
359
+ httpSchema(MyContract).use(ClientInfoMiddleware).routes({ ... })
278
360
  ```
279
361
 
280
- ### Adding Auth/Context to API
362
+ ### Client — sending the credential
281
363
 
282
- ```typescript
283
- import { GG_USER_AUTH } from "./auth/UserAuth"
284
- import { ClientInfoMiddleware } from "./middleware/ClientInfoMiddleware"
285
- import { GG_INTL_LOCALE } from "@grest-ts/intl"
364
+ The browser app does **not** hand-write the outbound side. `GGAuthSession` (from
365
+ `@grest-ts/auth`) owns the token lifecycle (localStorage persistence, cross-tab refresh
366
+ dedup, proactive refresh) and configures the wires it holds itself:
286
367
 
287
- export const MyApiContract = new GGContractClass("MyApi", {
288
- list: {
289
- success: IsArray(IsItem),
290
- errors: [NOT_AUTHORIZED, SERVER_ERROR]
291
- },
292
- create: {
293
- input: IsCreateRequest,
294
- success: IsItem,
295
- errors: [NOT_AUTHORIZED, VALIDATION_ERROR, SERVER_ERROR]
296
- }
297
- })
368
+ ```typescript
369
+ import { GGAuthSession } from "@grest-ts/auth"
370
+ import { USER_TOKEN_WIRE } from "../../api/auth/UserAuth"
298
371
 
299
- // Chain multiple context providers
300
- export const MyApi = httpSchema(MyApiContract)
301
- .pathPrefix("api/items")
302
- .useHeader(GG_INTL_LOCALE) // Use codec from context key
303
- .useHeader(GG_USER_AUTH) // Use codec from context key
304
- .use(ClientInfoMiddleware) // Use middleware object
305
- .routes({
306
- list: GGRpc.GET("list"),
307
- create: GGRpc.POST("create")
308
- })
372
+ export const session = GGAuthSession
373
+ .withToken(USER_TOKEN_WIRE, { refresh: api.authApi.refresh, localStorageKey: "auth" })
309
374
  ```
310
375
 
376
+ For the no-session case (a static service-to-service API key), the underlying primitive is
377
+ `WIRE.defineClient({ value: () => API_KEY })`.
378
+
311
379
  ### Public API (No Auth)
312
380
 
381
+ A public route simply lives on a schema that does **not** `.use()` an auth wire — "what's
382
+ public?" is answered by looking at the schema, not by reading handler code. (There is no
383
+ "optional auth": anonymous and authenticated variants live on separate schemas.)
384
+
313
385
  ```typescript
314
386
  export const PublicApiContract = new GGContractClass("PublicApi", {
315
- status: {
316
- success: IsStatusResponse,
317
- errors: [SERVER_ERROR]
318
- },
319
- login: {
320
- input: IsLoginRequest,
321
- success: IsLoginResponse,
322
- errors: [VALIDATION_ERROR, SERVER_ERROR]
323
- }
387
+ status: { success: IsStatusResponse, errors: [SERVER_ERROR], permission: GG_NO_PERMISSIONS },
388
+ login: { input: IsLoginRequest, success: IsLoginResponse, errors: [VALIDATION_ERROR, SERVER_ERROR], permission: GG_NO_PERMISSIONS },
324
389
  })
325
390
 
326
391
  export const PublicApi = httpSchema(PublicApiContract)
327
- .pathPrefix("pub")
392
+ .pathPrefix("pub") // no .use(WIRE) → public
328
393
  .routes({
329
394
  status: GGRpc.GET("status"),
330
- login: GGRpc.POST("login")
395
+ login: GGRpc.POST("login"),
331
396
  })
332
397
  ```
333
398
 
399
+ (`permission: GG_NO_PERMISSIONS` is only needed once any route on the server declares a
400
+ permission — see "Permissions" → strict mode.)
401
+
334
402
  ## Error Types
335
403
 
336
404
  ### Declaring Errors in Contract
@@ -376,7 +444,7 @@ export class MyService {
376
444
  const item = await this.findItem(request.id)
377
445
  if (!item) throw new NOT_FOUND()
378
446
 
379
- const user = GG_USER_AUTH.get()
447
+ const user = USER_DATA.get()
380
448
  if (item.ownerId !== user.id) throw new FORBIDDEN()
381
449
 
382
450
  return this.updateItem(item, request)
@@ -388,21 +456,24 @@ export class MyService {
388
456
 
389
457
  ### Using GGHttp (Fluent API)
390
458
 
459
+ Auth wiring is **not** a chain on `GGHttp` — the schema carries the wire (`.use(WIRE)`) and
460
+ the runtime binds the wire's deps once via `WIRE_HANDLER.create(deps)`. `GGHttp.use(...)` is
461
+ reserved for *ambient* middleware (e.g. client-info, trace) that should apply to every API
462
+ registered on that builder.
463
+
391
464
  ```typescript
392
465
  import { GGHttp, GGHttpServer } from "@grest-ts/http"
393
466
 
394
467
  protected compose(): void {
395
468
  const httpServer = new GGHttpServer()
396
469
 
397
- new GGHttp(httpServer)
398
- .http(PublicApi, publicService)
399
- .http(StatusApi, {
400
- status: async () => ({ status: true })
401
- })
470
+ // Bind each used wire's handler into this runtime's scope (once per runtime).
471
+ USER_TOKEN_WIRE_HANDLER.create(userService)
402
472
 
403
473
  new GGHttp(httpServer)
404
- .use(new UserContextMiddleware(userService))
405
- .http(MyApi, myService)
474
+ .use(ClientInfoMiddleware) // ambient middleware for every API below (optional)
475
+ .http(PublicApi, publicService) // no wire on its schema → public
476
+ .http(MyApi, myService) // MyApi schema .use(USER_TOKEN_WIRE)
406
477
  .http(UserAuthApi, userService)
407
478
  }
408
479
  ```
@@ -437,38 +508,36 @@ protected compose(): void {
437
508
 
438
509
  ### CORS Headers
439
510
 
440
- CORS `Access-Control-Allow-Headers` are auto-discovered from middleware and codecs.
441
- Only `Content-Type` is included by default (it's set by the framework's RPC layer).
442
- All other headers — including `Authorization` — are registered automatically when
443
- middleware declares them via `headers` or when `useHeader()` extracts them from codecs.
511
+ CORS `Access-Control-Allow-Headers` are auto-discovered from the wires and middleware a
512
+ schema `.use()`s. Only `Content-Type` is included by default (it's set by the framework's
513
+ RPC layer). All other headers — including `Authorization` — are registered automatically
514
+ from each wire/middleware's declared header names.
444
515
 
445
- When using `useHeader()`, header names are extracted from the codec's input schema automatically:
516
+ A `GGHeader` wire carries its own header name, so `.use()`ing it is enough — no extra CORS
517
+ wiring:
446
518
 
447
519
  ```typescript
448
- const HeaderType = IsObject({
449
- "x-org-token": IsString.orUndefined // Auto-discovered as CORS header
450
- })
451
- GG_ORG_TOKEN.addCodec("http", HeaderType.codecTo(...))
520
+ export const ORG_TOKEN_WIRE = new GGHeader("x-org-token", {}) // smart wire, custom header
452
521
 
453
522
  httpSchema(Contract)
454
- .useHeader(GG_ORG_TOKEN) // "x-org-token" added to CORS Allow-Headers
523
+ .use(ORG_TOKEN_WIRE) // "x-org-token" added to CORS Allow-Headers automatically
455
524
  .routes({ ... })
456
525
  ```
457
526
 
458
- Both `headers` (request) and `responseHeaders` (response) are required on middleware and codecs —
459
- TypeScript enforces this at compile time. Use `[]` when not applicable:
527
+ A custom middleware declares the headers it reads via `headers` and the response headers it
528
+ sets via `responseHeaders` — both are `Record<string, GGSchema<string | undefined>>`,
529
+ keyed by header name. The names feed CORS auto-discovery; omit a field when not applicable:
460
530
 
461
531
  ```typescript
462
- export const MyMiddleware: GGHttpTransportMiddleware = {
463
- headers: ['x-custom-header'],
464
- responseHeaders: [],
465
- updateRequest(req) { ... },
466
- parseRequest(req) { ... }
532
+ export const MyMiddleware: GGTransportMiddleware = {
533
+ headers: { 'x-custom-header': IsString.orUndefined },
534
+ update(outbound) { ... },
535
+ parse(inbound) { ... }
467
536
  }
468
537
  ```
469
538
 
470
539
  Codecs also declare `responseHeaders`. For example, `GGFileDownload` declares
471
- `['Content-Disposition']` which is automatically added to `Access-Control-Expose-Headers`.
540
+ `Content-Disposition` which is automatically added to `Access-Control-Expose-Headers`.
472
541
 
473
542
  You can also register headers manually on the server:
474
543
 
@@ -478,6 +547,124 @@ httpServer.registerCorsHeaders(['x-custom-header'])
478
547
  httpServer.registerCorsExposeHeaders(['x-custom-response-header'])
479
548
  ```
480
549
 
550
+ ## Cookies (httpOnly sessions)
551
+
552
+ An httpOnly session cookie is a value the browser stores and re-sends automatically
553
+ that JavaScript cannot read (so XSS can't steal it). It is **server-minted**: the
554
+ server emits `Set-Cookie`, the browser stores and resends it, and JS never touches it.
555
+
556
+ A cookie is a **`GGCookie` wire** — it IS its own context key. Its name is the cookie
557
+ name. Attach it with one `.use(SESSION)` on the schema; read it with `SESSION.get()` in any
558
+ handler; write it with the static `GGCookie.setCookie(SESSION, …)` / `clearCookie(SESSION)`
559
+ from a route that declared `.updatesCookie(SESSION)`:
560
+
561
+ ```typescript
562
+ // shared api/
563
+ import {GGRpc, httpSchema, GGCookie} from "@grest-ts/http"
564
+
565
+ // The wire's name IS the cookie name. No cookie policy in the shared API.
566
+ export const SESSION = new GGCookie("session")
567
+
568
+ export const AuthApi = httpSchema(AuthContract)
569
+ .use(SESSION)
570
+ .pathPrefix("api/auth")
571
+ .routes({
572
+ login: GGRpc.POST("login").updatesCookie(SESSION), // may write the cookie
573
+ logout: GGRpc.POST("logout").updatesCookie(SESSION), // may write (clear)
574
+ me: GGRpc.GET("me"), // read-only -> no declaration
575
+ })
576
+ ```
577
+
578
+ ```typescript
579
+ import {GGCookie} from "@grest-ts/http"
580
+ import {SESSION} from "../../api/AuthApi"
581
+
582
+ export class AuthService {
583
+ login = async (input: LoginRequest): Promise<User> => {
584
+ const {user, token} = await this.verify(input)
585
+ // Write rules live HERE, at the set site. Safe defaults (HttpOnly, Secure,
586
+ // SameSite=Lax, Path=/) are applied; pass only what differs:
587
+ GGCookie.setCookie(SESSION, token, {maxAgeSec: input.remember ? 60*60*24*30 : 60*60})
588
+ return user
589
+ }
590
+ logout = async (): Promise<void> => { GGCookie.clearCookie(SESSION) } // -> Max-Age=0 clear
591
+ me = async (): Promise<User> => this.fromSession(SESSION.get()) // read the cookie
592
+ }
593
+ ```
594
+
595
+ **Read vs write.** Reading is implicit — `SESSION.get()` works on any route whose schema
596
+ `.use()`d the cookie. **Writing is explicit**: only a route that declared
597
+ `.updatesCookie(SESSION)` may write it. A `GGCookie.setCookie(SESSION, …)` call from a route
598
+ that *didn't* declare it throws `SERVER_ERROR`, so a deep service function can't silently
599
+ mint or change someone's session — the capability is visible at the API boundary. The
600
+ inbound value stays set-once: the written value never touches `SESSION.get()`.
601
+
602
+ **Emit-on-write.** `setCookie`/`clearCookie` schedule a pending Set-Cookie that the wire
603
+ flushes onto the response: `setCookie(SESSION, token)` → `Set-Cookie`;
604
+ `clearCookie(SESSION)` (or `setCookie(SESSION, undefined)`) → `Max-Age=0` clear; a route
605
+ that writes nothing emits nothing.
606
+
607
+ **Write rules live at the `setCookie(value, options)` call site**, never in the shared API —
608
+ only the cookie name (needed to *read* it on every route) is on the schema. Options:
609
+ `httpOnly`, `secure`, `sameSite`, `path`, `domain`, `maxAgeSec`. Safe defaults (`HttpOnly`,
610
+ `Secure`, `SameSite=Lax`, `Path=/`) are applied by the serializer, so `setCookie(SESSION,
611
+ token)` is safe; pass only deviations. `SameSite=None` forces `Secure`; name/path/domain are
612
+ validated (no CR/LF/`;`). A scoped cookie repeats `path`/`domain` on clear so deletion matches:
613
+
614
+ ```typescript
615
+ GGCookie.setCookie(SESSION, token, {path: "/api", domain: ".example.com", sameSite: "none", maxAgeSec: WEEK})
616
+ GGCookie.clearCookie(SESSION, {path: "/api", domain: ".example.com"}) // clear must match scope
617
+ ```
618
+
619
+ **Domain scope is a security boundary.** The default is **host-only** (no `Domain`):
620
+ the cookie is sent only to the exact host that set it. `domain: ".example.com"` sends
621
+ it to **every** subdomain, so *any* subdomain — including user-controlled or untrusted
622
+ content — can read it. Never share a parent domain with untrusted subdomains; host
623
+ untrusted/user content on a **separate registrable domain** instead.
624
+
625
+ **Cross-site cookies.** A `SameSite=Lax` cookie is not sent on cross-site requests.
626
+ For a cross-site setup (browser and API on different sites) use `sameSite: "none"` +
627
+ `secure: true` **and** credentialed CORS on the server (below) with an exact-origin
628
+ allowlist — never a wildcard.
629
+
630
+ ### Credentialed CORS (cross-origin cookies)
631
+
632
+ Cross-origin cookie requests require the response to echo the **specific** request
633
+ Origin plus `Access-Control-Allow-Credentials: true` — browsers reject the wildcard
634
+ `*` when credentials are involved. Opt in per server (default off keeps the
635
+ permissive `*`):
636
+
637
+ ```typescript
638
+ new GGHttpServer({
639
+ cors: {
640
+ origins: ["https://app.example.com"], // exact list, or (origin) => boolean
641
+ credentials: true,
642
+ },
643
+ })
644
+ ```
645
+
646
+ Only an allowlisted Origin is echoed (never `*`), with `Vary: Origin`. Never reflect
647
+ an arbitrary origin with credentials — the allowlist is the gate.
648
+
649
+ ### Client: send cookies
650
+
651
+ The browser attaches cross-origin cookies only when the caller opts in:
652
+
653
+ ```typescript
654
+ const client = AuthApi.createClient({url: "https://api.example.com", credentials: "include"})
655
+ ```
656
+
657
+ Same-origin calls and the default need nothing.
658
+
659
+ ### WebSocket cookies
660
+
661
+ The same `GGCookie` wire works on a WebSocket schema, read-only: a browser auto-attaches
662
+ the httpOnly cookie to the WS **upgrade** request, and the runtime fills `inbound.cookie`
663
+ from that real upgrade header (never from the spoofable in-band handshake message). `.use()`
664
+ the same `SESSION` wire on the WS schema and read it at handshake with `SESSION.get()` — no
665
+ client code, no ticket dance. There is no `Set-Cookie` over a socket, so the wire only reads
666
+ there (`.updatesCookie` is an HTTP route concept). See `@grest-ts/websocket` → "Cookies".
667
+
481
668
  ## HTTP Client
482
669
 
483
670
  ### Creating Clients
@@ -589,10 +776,14 @@ export const NotificationApiContract = defineSocketContract("NotificationApi", {
589
776
 
590
777
  export const NotificationApi = webSocketSchema(NotificationApiContract)
591
778
  .path("ws/notifications")
592
- .use(AuthMiddleware)
779
+ .use(USER_TOKEN_WIRE) // same wire as the HTTP schemas — verified at handshake
593
780
  .done()
594
781
  ```
595
782
 
783
+ The wire works identically on WebSocket: it reads + verifies the credential off the upgrade
784
+ handshake (the `.define()` handler runs once at connect). See `@grest-ts/websocket` for the
785
+ full WS auth + permissions model.
786
+
596
787
  ### WebSocket Server Handler
597
788
 
598
789
  ```typescript
@@ -602,7 +793,7 @@ export class NotificationService {
602
793
  private connections = new Map<string, Set<WebSocketOutgoing<any>>>()
603
794
 
604
795
  handleConnection = (incoming: WebSocketIncoming<any>, outgoing: WebSocketOutgoing<any>): void => {
605
- const user = GG_USER_AUTH.get()
796
+ const user = USER_DATA.get() // durable principal minted by the wire at handshake
606
797
 
607
798
  // Track connection
608
799
  if (!this.connections.has(user.id)) {
@@ -643,8 +834,10 @@ import { GGHttpServer } from "@grest-ts/http"
643
834
  protected compose(): void {
644
835
  const httpServer = new GGHttpServer()
645
836
 
837
+ // One wire handler covers every schema that .use()s it — HTTP and WS alike.
838
+ USER_TOKEN_WIRE_HANDLER.create(userService)
839
+
646
840
  new GGHttp(httpServer)
647
- .use(new UserContextMiddleware(userService))
648
841
  .http(MyApi, myService)
649
842
 
650
843
  // Register WebSocket on the same HTTP server
@@ -11,7 +11,7 @@
11
11
  const __KEY = Symbol.for("grest-ts:dedup:@grest-ts/http");
12
12
  const __slot = globalThis;
13
13
  const __existing = __slot[__KEY];
14
- const __here = { path: import.meta.url, version: "0.0.47" };
14
+ const __here = { path: import.meta.url, version: "0.0.49" };
15
15
  // Same path + same version means the same physical package is being
16
16
  // re-evaluated by a different ESM runner (e.g. vitest projects share one
17
17
  // process but each project has its own module cache). That isn't a true
@@ -0,0 +1,11 @@
1
+ import { GGContextKey } from "@grest-ts/context";
2
+ export interface GGKeyController {
3
+ isStale: () => boolean;
4
+ recover: () => Promise<void>;
5
+ }
6
+ export declare class GGContextKeySynchronizer {
7
+ static provide(key: GGContextKey<any>, controller: GGKeyController): void;
8
+ static waitFor(key: GGContextKey<any>): Promise<void>;
9
+ static clear(key: GGContextKey<any>): void;
10
+ }
11
+ //# sourceMappingURL=GGContextKeySynchronizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GGContextKeySynchronizer.d.ts","sourceRoot":"","sources":["../../../src/client/GGContextKeySynchronizer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAA;AAG9C,MAAM,WAAW,eAAe;IAC5B,OAAO,EAAE,MAAM,OAAO,CAAA;IACtB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC/B;AAYD,qBAAa,wBAAwB;IAEjC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,YAAY,CAAC,GAAG,CAAC,EAAE,UAAU,EAAE,eAAe,GAAG,IAAI;WAS5D,OAAO,CAAC,GAAG,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAS3D,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,IAAI;CAG7C"}
@@ -0,0 +1,26 @@
1
+ import { GGContextKey } from "@grest-ts/context";
2
+ import { IsAny } from "@grest-ts/schema";
3
+ const GG_SYNC_CONTROLLERS = new GGContextKey("sync:controllers", IsAny);
4
+ export class GGContextKeySynchronizer {
5
+ static provide(key, controller) {
6
+ let registry = GG_SYNC_CONTROLLERS.get();
7
+ if (registry === undefined) {
8
+ registry = new Map();
9
+ GG_SYNC_CONTROLLERS.set(registry);
10
+ }
11
+ registry.set(key.name, { controller, inflight: undefined });
12
+ }
13
+ static async waitFor(key) {
14
+ const entry = GG_SYNC_CONTROLLERS.get()?.get(key.name);
15
+ if (entry === undefined || !entry.controller.isStale())
16
+ return;
17
+ entry.inflight ??= entry.controller.recover().finally(() => {
18
+ entry.inflight = undefined;
19
+ });
20
+ await entry.inflight;
21
+ }
22
+ static clear(key) {
23
+ GG_SYNC_CONTROLLERS.get()?.delete(key.name);
24
+ }
25
+ }
26
+ //# sourceMappingURL=GGContextKeySynchronizer.js.map