@grest-ts/http 0.0.48 → 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 (127) hide show
  1. package/README.md +243 -175
  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/index-browser.d.ts +4 -2
  8. package/dist/src/index-browser.d.ts.map +1 -1
  9. package/dist/src/index-browser.js +4 -2
  10. package/dist/src/index-browser.js.map +1 -1
  11. package/dist/src/index-node.d.ts +8 -3
  12. package/dist/src/index-node.d.ts.map +1 -1
  13. package/dist/src/index-node.js +16 -7
  14. package/dist/src/index-node.js.map +1 -1
  15. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts +4 -3
  16. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -1
  17. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +9 -2
  18. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -1
  19. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts +3 -3
  20. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts.map +1 -1
  21. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js +13 -16
  22. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js.map +1 -1
  23. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts +1 -1
  24. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts.map +1 -1
  25. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js +3 -3
  26. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js.map +1 -1
  27. package/dist/src/schema/GGCookie.d.ts +50 -0
  28. package/dist/src/schema/GGCookie.d.ts.map +1 -0
  29. package/dist/src/schema/GGCookie.js +130 -0
  30. package/dist/src/schema/GGCookie.js.map +1 -0
  31. package/dist/src/schema/GGHeader.d.ts +23 -0
  32. package/dist/src/schema/GGHeader.d.ts.map +1 -0
  33. package/dist/src/schema/GGHeader.js +41 -0
  34. package/dist/src/schema/GGHeader.js.map +1 -0
  35. package/dist/src/schema/GGHttpPermissionsChecker.d.ts +9 -0
  36. package/dist/src/schema/GGHttpPermissionsChecker.d.ts.map +1 -0
  37. package/dist/src/schema/GGHttpPermissionsChecker.js +29 -0
  38. package/dist/src/schema/GGHttpPermissionsChecker.js.map +1 -0
  39. package/dist/src/schema/GGHttpSchema.d.ts +8 -59
  40. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
  41. package/dist/src/schema/GGHttpSchema.js.map +1 -1
  42. package/dist/src/schema/GGWireContextKey.d.ts +30 -0
  43. package/dist/src/schema/GGWireContextKey.d.ts.map +1 -0
  44. package/dist/src/schema/GGWireContextKey.js +36 -0
  45. package/dist/src/schema/GGWireContextKey.js.map +1 -0
  46. package/dist/src/schema/GGWireContextKey.node.d.ts +36 -0
  47. package/dist/src/schema/GGWireContextKey.node.d.ts.map +1 -0
  48. package/dist/src/schema/GGWireContextKey.node.js +83 -0
  49. package/dist/src/schema/GGWireContextKey.node.js.map +1 -0
  50. package/dist/src/schema/httpSchema.d.ts +2 -2
  51. package/dist/src/schema/httpSchema.d.ts.map +1 -1
  52. package/dist/src/schema/httpSchema.js.map +1 -1
  53. package/dist/src/server/GGHttp.d.ts +2 -26
  54. package/dist/src/server/GGHttp.d.ts.map +1 -1
  55. package/dist/src/server/GGHttp.js +0 -15
  56. package/dist/src/server/GGHttp.js.map +1 -1
  57. package/dist/src/server/GGHttpSchema.startServer.d.ts +2 -11
  58. package/dist/src/server/GGHttpSchema.startServer.d.ts.map +1 -1
  59. package/dist/src/server/GGHttpSchema.startServer.js +11 -28
  60. package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
  61. package/dist/src/server/GGHttpServer.d.ts +16 -14
  62. package/dist/src/server/GGHttpServer.d.ts.map +1 -1
  63. package/dist/src/server/GGHttpServer.js +26 -79
  64. package/dist/src/server/GGHttpServer.js.map +1 -1
  65. package/dist/src/server/applyRequestMiddleware.d.ts +11 -0
  66. package/dist/src/server/applyRequestMiddleware.d.ts.map +1 -0
  67. package/dist/src/server/applyRequestMiddleware.js +38 -0
  68. package/dist/src/server/applyRequestMiddleware.js.map +1 -0
  69. package/dist/src/server/applyResponseMiddleware.d.ts +2 -2
  70. package/dist/src/server/applyResponseMiddleware.d.ts.map +1 -1
  71. package/dist/src/server/applyResponseMiddleware.js +2 -2
  72. package/dist/src/server/applyResponseMiddleware.js.map +1 -1
  73. package/dist/src/server/checkers/checkPermissionsAtStart.d.ts +39 -0
  74. package/dist/src/server/checkers/checkPermissionsAtStart.d.ts.map +1 -0
  75. package/dist/src/server/checkers/checkPermissionsAtStart.js +82 -0
  76. package/dist/src/server/checkers/checkPermissionsAtStart.js.map +1 -0
  77. package/dist/src/server/checkers/checkWireConflicts.d.ts +18 -0
  78. package/dist/src/server/checkers/checkWireConflicts.d.ts.map +1 -0
  79. package/dist/src/server/checkers/checkWireConflicts.js +43 -0
  80. package/dist/src/server/checkers/checkWireConflicts.js.map +1 -0
  81. package/dist/src/server/checkers/checkWiresImplemented.d.ts +11 -0
  82. package/dist/src/server/checkers/checkWiresImplemented.d.ts.map +1 -0
  83. package/dist/src/server/checkers/checkWiresImplemented.js +27 -0
  84. package/dist/src/server/checkers/checkWiresImplemented.js.map +1 -0
  85. package/dist/src/tsconfig.json +2 -1
  86. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  87. package/package.json +14 -11
  88. package/src/_dedupCheck.ts +1 -1
  89. package/src/client/GGContextKeySynchronizer.test.ts +106 -0
  90. package/src/client/GGContextKeySynchronizer.ts +42 -0
  91. package/src/index-browser.ts +4 -2
  92. package/src/index-node.ts +17 -7
  93. package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +11 -4
  94. package/src/rpc/RpcRequest/GGRpcRequestParser.ts +16 -17
  95. package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +5 -4
  96. package/src/schema/GGCookie.ts +151 -0
  97. package/src/schema/GGHeader.ts +48 -0
  98. package/src/schema/GGHttpPermissionsChecker.test.ts +125 -0
  99. package/src/schema/GGHttpPermissionsChecker.ts +35 -0
  100. package/src/schema/GGHttpSchema.ts +8 -71
  101. package/src/schema/GGWireContextKey.node.ts +119 -0
  102. package/src/schema/GGWireContextKey.ts +46 -0
  103. package/src/schema/httpSchema.ts +3 -3
  104. package/src/server/GGHttp.ts +3 -34
  105. package/src/server/GGHttpSchema.startServer.ts +13 -38
  106. package/src/server/GGHttpServer.ts +33 -89
  107. package/src/server/applyRequestMiddleware.ts +40 -0
  108. package/src/server/applyResponseMiddleware.ts +5 -5
  109. package/src/server/checkers/checkPermissionsAtStart.ts +112 -0
  110. package/src/server/checkers/checkWireConflicts.ts +65 -0
  111. package/src/server/checkers/checkWiresImplemented.ts +42 -0
  112. package/src/tsconfig.json +2 -1
  113. package/dist/src/schema/cookieMiddleware.d.ts +0 -71
  114. package/dist/src/schema/cookieMiddleware.d.ts.map +0 -1
  115. package/dist/src/schema/cookieMiddleware.js +0 -126
  116. package/dist/src/schema/cookieMiddleware.js.map +0 -1
  117. package/dist/src/schema/headerBinding.d.ts +0 -35
  118. package/dist/src/schema/headerBinding.d.ts.map +0 -1
  119. package/dist/src/schema/headerBinding.js +0 -49
  120. package/dist/src/schema/headerBinding.js.map +0 -1
  121. package/dist/src/server/GG_PERMISSIONS.d.ts +0 -15
  122. package/dist/src/server/GG_PERMISSIONS.d.ts.map +0 -1
  123. package/dist/src/server/GG_PERMISSIONS.js +0 -15
  124. package/dist/src/server/GG_PERMISSIONS.js.map +0 -1
  125. package/src/schema/cookieMiddleware.ts +0 -164
  126. package/src/schema/headerBinding.ts +0 -65
  127. 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).
183
190
 
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:
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.
185
192
 
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.
193
+ **Hard guarantee.** At server start the framework walks every HTTP / WS route registered on the `GGHttpServer`, before it accepts traffic. Two guarantees hold:
188
194
 
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.
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.
190
197
 
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.
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.
199
+
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
 
@@ -484,19 +553,20 @@ An httpOnly session cookie is a value the browser stores and re-sends automatica
484
553
  that JavaScript cannot read (so XSS can't steal it). It is **server-minted**: the
485
554
  server emits `Set-Cookie`, the browser stores and resends it, and JS never touches it.
486
555
 
487
- A cookie is just a **context key bound to the wire** — same feel as an auth context
488
- key. Define a `GGContextKeyForCookie` (a `GGContextKey` whose value rides as a cookie),
489
- bind it with `.useCookie(SESSION)` on the schema, and use `.get()` / `.set()` in handlers:
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)`:
490
560
 
491
561
  ```typescript
492
562
  // shared api/
493
- import {GGRpc, httpSchema, GGContextKeyForCookie} from "@grest-ts/http"
563
+ import {GGRpc, httpSchema, GGCookie} from "@grest-ts/http"
494
564
 
495
- // The key's name IS the cookie's wire name. No cookie policy in the shared API.
496
- export const SESSION = new GGContextKeyForCookie("session")
565
+ // The wire's name IS the cookie name. No cookie policy in the shared API.
566
+ export const SESSION = new GGCookie("session")
497
567
 
498
568
  export const AuthApi = httpSchema(AuthContract)
499
- .useCookie(SESSION)
569
+ .use(SESSION)
500
570
  .pathPrefix("api/auth")
501
571
  .routes({
502
572
  login: GGRpc.POST("login").updatesCookie(SESSION), // may write the cookie
@@ -506,44 +576,44 @@ export const AuthApi = httpSchema(AuthContract)
506
576
  ```
507
577
 
508
578
  ```typescript
579
+ import {GGCookie} from "@grest-ts/http"
580
+ import {SESSION} from "../../api/AuthApi"
581
+
509
582
  export class AuthService {
510
583
  login = async (input: LoginRequest): Promise<User> => {
511
584
  const {user, token} = await this.verify(input)
512
585
  // Write rules live HERE, at the set site. Safe defaults (HttpOnly, Secure,
513
586
  // SameSite=Lax, Path=/) are applied; pass only what differs:
514
- SESSION.set(token, {maxAgeSec: input.remember ? 60*60*24*30 : 60*60})
587
+ GGCookie.setCookie(SESSION, token, {maxAgeSec: input.remember ? 60*60*24*30 : 60*60})
515
588
  return user
516
589
  }
517
- logout = async (): Promise<void> => { SESSION.delete() } // -> Max-Age=0 clear
518
- me = async (): Promise<User> => this.fromSession(SESSION.get()) // read the cookie
590
+ logout = async (): Promise<void> => { GGCookie.clearCookie(SESSION) } // -> Max-Age=0 clear
591
+ me = async (): Promise<User> => this.fromSession(SESSION.get()) // read the cookie
519
592
  }
520
593
  ```
521
594
 
522
- **Read vs write.** Reading is implicit — `SESSION.get()` works on any route the schema
523
- `.useCookie`-bound (just like an auth context key). **Writing is explicit**: only a
524
- route that declared `.updatesCookie(SESSION)` may change the cookie. A handler that
525
- calls `SESSION.set(...)` on a route that *didn't* declare it is a `SERVER_ERROR`
526
- (logged, change rolled back), so a deep service function can't silently mint or change
527
- someone's session — the capability is visible at the API boundary. (Auth context needs
528
- no such gate: the server only *reads* it from the request; a cookie is *produced* by
529
- the server.)
530
-
531
- **Emit-on-change.** Within a route that may write, `useCookie` emits `Set-Cookie` only
532
- when the handler **changes** the key versus what arrived: `set(token)` → `Set-Cookie`;
533
- `delete()` (or `set(undefined)`) → `Max-Age=0` clear; untouched → nothing (read routes
534
- don't re-emit, and there's no spurious clear when no cookie arrived).
535
-
536
- **Write rules live at `.set(value, options)`**, never in the shared API — only the
537
- cookie's wire name (the key's name, needed to *read* it on every route) is on the
538
- schema. Options: `httpOnly`, `secure`, `sameSite`, `path`, `domain`, `maxAgeSec`. Safe
539
- defaults (`HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`) are applied by the serializer,
540
- so `set(token)` is safe; pass only deviations. `SameSite=None` forces `Secure`;
541
- name/path/domain are validated (no CR/LF/`;`). A scoped cookie repeats `path`/`domain`
542
- on clear so deletion matches:
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:
543
613
 
544
614
  ```typescript
545
- SESSION.set(token, {path: "/api", domain: ".example.com", sameSite: "none", maxAgeSec: WEEK})
546
- SESSION.delete({path: "/api", domain: ".example.com"}) // clear must match scope
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
547
617
  ```
548
618
 
549
619
  **Domain scope is a security boundary.** The default is **host-only** (no `Domain`):
@@ -588,20 +658,12 @@ Same-origin calls and the default need nothing.
588
658
 
589
659
  ### WebSocket cookies
590
660
 
591
- First-class WebSocket cookie support (the same `SESSION` key populated from the
592
- browser's `Cookie` on the WS upgrade, read-only) is planned — see
593
- `docs/plans/WS_COOKIE_SUPPORT.md`. Until it lands, gate a socket by the session with
594
- the **ticket pattern**: a cookie-authenticated HTTP endpoint mints a short-lived,
595
- single-use ticket; the browser opens the socket carrying that ticket on the handshake
596
- via the existing token transport:
597
-
598
- ```typescript
599
- // HTTP: cookie-authenticated endpoint mints a short-lived ticket
600
- wsTicket = async (): Promise<{ticket: string}> => {
601
- const session = SESSION.get() // read from the cookie
602
- return {ticket: await this.mintTicket(session, {ttlSec: 10, singleUse: true})}
603
- }
604
- ```
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".
605
667
 
606
668
  ## HTTP Client
607
669
 
@@ -714,10 +776,14 @@ export const NotificationApiContract = defineSocketContract("NotificationApi", {
714
776
 
715
777
  export const NotificationApi = webSocketSchema(NotificationApiContract)
716
778
  .path("ws/notifications")
717
- .use(AuthMiddleware)
779
+ .use(USER_TOKEN_WIRE) // same wire as the HTTP schemas — verified at handshake
718
780
  .done()
719
781
  ```
720
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
+
721
787
  ### WebSocket Server Handler
722
788
 
723
789
  ```typescript
@@ -727,7 +793,7 @@ export class NotificationService {
727
793
  private connections = new Map<string, Set<WebSocketOutgoing<any>>>()
728
794
 
729
795
  handleConnection = (incoming: WebSocketIncoming<any>, outgoing: WebSocketOutgoing<any>): void => {
730
- const user = GG_USER_AUTH.get()
796
+ const user = USER_DATA.get() // durable principal minted by the wire at handshake
731
797
 
732
798
  // Track connection
733
799
  if (!this.connections.has(user.id)) {
@@ -768,8 +834,10 @@ import { GGHttpServer } from "@grest-ts/http"
768
834
  protected compose(): void {
769
835
  const httpServer = new GGHttpServer()
770
836
 
837
+ // One wire handler covers every schema that .use()s it — HTTP and WS alike.
838
+ USER_TOKEN_WIRE_HANDLER.create(userService)
839
+
771
840
  new GGHttp(httpServer)
772
- .use(new UserContextMiddleware(userService))
773
841
  .http(MyApi, myService)
774
842
 
775
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.48" };
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"}