@grest-ts/http 0.0.48 → 0.0.50

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
@@ -0,0 +1,125 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import type {GGTransportMiddleware} from "@grest-ts/context";
3
+ import {FORBIDDEN, GG_ANY_PERMISSION, GG_NO_PERMISSIONS, GGPermission, IsString} from "@grest-ts/schema";
4
+ import {GGHttpPermissionsChecker} from "./GGHttpPermissionsChecker";
5
+ import {GGWireContextKey} from "./GGWireContextKey";
6
+
7
+ class TestWire extends GGWireContextKey {
8
+ constructor(name: string, private readonly grants: readonly string[] | null) {
9
+ super(name, IsString.orUndefined);
10
+ }
11
+
12
+ public hasPermissions(): boolean {
13
+ return this.grants !== null;
14
+ }
15
+
16
+ public async getGrantedPermissions(): Promise<readonly string[]> {
17
+ return this.grants ?? [];
18
+ }
19
+ }
20
+
21
+ const plainMiddleware: GGTransportMiddleware = {};
22
+
23
+ describe("GGHttpPermissionsChecker", () => {
24
+
25
+ describe("constructor / wire selection", () => {
26
+ it("ignores non-wire middlewares and wires without permissions", async () => {
27
+ const checker = new GGHttpPermissionsChecker([
28
+ plainMiddleware,
29
+ new TestWire("ambient", null),
30
+ ]);
31
+ expect(await checker.assert("Api", "method", GG_NO_PERMISSIONS)).toEqual([]);
32
+ await expect(checker.assert("Api", "method", "anything")).rejects.toBeInstanceOf(FORBIDDEN);
33
+ });
34
+
35
+ it("collects scopes only from permission-bearing wires", async () => {
36
+ const checker = new GGHttpPermissionsChecker([
37
+ plainMiddleware,
38
+ new TestWire("ambient", null),
39
+ new TestWire("header", ["a", "b"]),
40
+ new TestWire("cookie", ["c"]),
41
+ ]);
42
+ expect(await checker.assert("Api", "method", GG_NO_PERMISSIONS)).toEqual([["a", "b"], ["c"]]);
43
+ });
44
+ });
45
+
46
+ describe("assert — no permission wires (fail closed)", () => {
47
+ const checker = new GGHttpPermissionsChecker([]);
48
+
49
+ it("passes when nothing is required", async () => {
50
+ expect(await checker.assert("Api", "method", GG_NO_PERMISSIONS)).toEqual([]);
51
+ expect(await checker.assert("Api", "method", undefined as unknown as GGPermission)).toEqual([]);
52
+ });
53
+
54
+ it("denies a real or any-permission requirement instead of skipping the check", async () => {
55
+ await expect(checker.assert("Api", "method", "items:read")).rejects.toBeInstanceOf(FORBIDDEN);
56
+ await expect(checker.assert("Api", "method", GG_ANY_PERMISSION)).rejects.toBeInstanceOf(FORBIDDEN);
57
+ });
58
+ });
59
+
60
+ describe("assert — with permission wires", () => {
61
+ const checker = new GGHttpPermissionsChecker([new TestWire("header", ["items:read", "items:write"])]);
62
+
63
+ it("returns the resolved (frozen) array-of-arrays scopes when satisfied", async () => {
64
+ const scopes = await checker.assert("Api", "method", "items:read");
65
+ expect(scopes).toEqual([["items:read", "items:write"]]);
66
+ expect(Object.isFrozen(scopes)).toBe(true);
67
+ });
68
+
69
+ it("passes GG_NO_PERMISSIONS and undefined even when wires are present", async () => {
70
+ expect(await checker.assert("Api", "method", GG_NO_PERMISSIONS)).toEqual([["items:read", "items:write"]]);
71
+ expect(await checker.assert("Api", "method", undefined as unknown as GGPermission)).toEqual([["items:read", "items:write"]]);
72
+ });
73
+
74
+ it("throws FORBIDDEN when the required scope is missing", async () => {
75
+ await expect(checker.assert("Api", "method", "admin:all")).rejects.toBeInstanceOf(FORBIDDEN);
76
+ });
77
+
78
+ it("unions grants across wires when checking a requirement", async () => {
79
+ const twoWires = new GGHttpPermissionsChecker([
80
+ new TestWire("header", ["a"]),
81
+ new TestWire("cookie", ["b"]),
82
+ ]);
83
+ expect(await twoWires.assert("Api", "method", {allOf: ["a", "b"]})).toEqual([["a"], ["b"]]);
84
+ expect(await twoWires.assert("Api", "method", {anyOf: ["a", "z"]})).toEqual([["a"], ["b"]]);
85
+ await expect(twoWires.assert("Api", "method", "c")).rejects.toBeInstanceOf(FORBIDDEN);
86
+ });
87
+ });
88
+
89
+ describe("assertScopes", () => {
90
+ const checker = new GGHttpPermissionsChecker([]);
91
+
92
+ it("passes when scopes satisfy the requirement", () => {
93
+ expect(() => checker.assertGrants("Api", "method", [["x"]], "x")).not.toThrow();
94
+ });
95
+
96
+ it("throws FORBIDDEN when scopes do not satisfy", () => {
97
+ expect(() => checker.assertGrants("Api", "method", [["x"]], "y")).toThrow(FORBIDDEN);
98
+ });
99
+
100
+ it("throws FORBIDDEN when scopes are undefined and a real permission is required", () => {
101
+ expect(() => checker.assertGrants("Api", "method", undefined, "y")).toThrow(FORBIDDEN);
102
+ });
103
+
104
+ it("debug message includes schema.method and the described requirement", () => {
105
+ try {
106
+ checker.assertGrants("Api", "method", [["x"]], "y");
107
+ expect.unreachable();
108
+ } catch (e) {
109
+ expect(e).toBeInstanceOf(FORBIDDEN);
110
+ expect((e as InstanceType<typeof FORBIDDEN>).getDebugContext()?.debugMessage).toContain("Api.method requires");
111
+ expect((e as InstanceType<typeof FORBIDDEN>).getDebugContext()?.debugMessage).toContain('"y"');
112
+ }
113
+ });
114
+
115
+ it("omits the dot when method is empty (WS connect gate)", () => {
116
+ try {
117
+ checker.assertGrants("Api", "", undefined, "y");
118
+ expect.unreachable();
119
+ } catch (e) {
120
+ expect((e as InstanceType<typeof FORBIDDEN>).getDebugContext()?.debugMessage).toContain("Api requires");
121
+ expect((e as InstanceType<typeof FORBIDDEN>).getDebugContext()?.debugMessage).not.toContain("Api. requires");
122
+ }
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,35 @@
1
+ import {GGWireContextKey} from "./GGWireContextKey";
2
+ import type {GGTransportMiddleware} from "@grest-ts/context";
3
+ import {FORBIDDEN, GGPermission, GGPermissionChecker} from "@grest-ts/schema";
4
+
5
+ export class GGHttpPermissionsChecker {
6
+
7
+ private readonly middlewaresWithPermissions: GGWireContextKey[] = []
8
+
9
+ constructor(apiMiddlewares: readonly GGTransportMiddleware[]) {
10
+ for (const mw of apiMiddlewares) {
11
+ if (mw instanceof GGWireContextKey && mw.hasPermissions()) {
12
+ this.middlewaresWithPermissions.push(mw);
13
+ }
14
+ }
15
+ Object.freeze(this.middlewaresWithPermissions)
16
+ }
17
+
18
+ public async assert(schema: string, method: string, required: GGPermission): Promise<ReadonlyArray<ReadonlyArray<string>>> {
19
+ const scopes: Array<readonly string[]> = [];
20
+ for (let i = 0; i < this.middlewaresWithPermissions.length; i++) {
21
+ scopes.push(await this.middlewaresWithPermissions[i].getGrantedPermissions())
22
+ }
23
+ this.assertGrants(schema, method, scopes, required)
24
+ return Object.freeze(scopes) as readonly string[][]
25
+ }
26
+
27
+ public assertGrants(schema: string, method: string, granted: undefined | ReadonlyArray<ReadonlyArray<string>>, required: GGPermission): void {
28
+ if (!GGPermissionChecker.satisfies(required, granted)) {
29
+ throw new FORBIDDEN({
30
+ debugMessage: `${schema + (method ? "." + method : "")} requires ${GGPermissionChecker.describePermission(required)} — caller scopes did not satisfy`
31
+ })
32
+ }
33
+ }
34
+
35
+ }
@@ -1,15 +1,14 @@
1
1
  import {ERROR, ERROR_JSON, GGContractApiDefinition, GGContractClass, GGContractMethod, GGSchema, GGSchemaDescription, OK} from "@grest-ts/schema";
2
2
  import type {HttpMethod} from "@grest-ts/common";
3
3
  import type http from "http";
4
- import type {GGHttpServerMiddleware} from "../server/GGHttpSchema.startServer";
5
- import type {GGContextKey} from "@grest-ts/context";
4
+ import type {GGContextKey, GGTransportMiddleware} from "@grest-ts/context";
6
5
  import type {OpenAPIV3_1} from "openapi-types";
7
6
 
8
7
  export class GGHttpSchema<TContract extends GGContractApiDefinition, TContext> {
9
8
 
10
9
  public readonly name: string
11
10
  public readonly pathPrefix: string
12
- public readonly apiMiddlewares: readonly GGHttpTransportMiddleware[]
11
+ public readonly apiMiddlewares: readonly GGTransportMiddleware[]
13
12
  public readonly codec: Record<keyof TContract, GGHttpCodec>
14
13
  public readonly contract: GGContractClass<TContract> | null = null
15
14
 
@@ -17,7 +16,7 @@ export class GGHttpSchema<TContract extends GGContractApiDefinition, TContext> {
17
16
  pathPrefix: string,
18
17
  contract: GGContractClass<TContract>,
19
18
  wireCodec: Record<keyof TContract, GGHttpCodec>,
20
- middlewares: readonly GGHttpTransportMiddleware[] = []
19
+ middlewares: readonly GGTransportMiddleware[] = []
21
20
  ) {
22
21
  this.name = contract.name
23
22
  this.pathPrefix = pathPrefix
@@ -37,7 +36,7 @@ export class GGHttpSchema<TContract extends GGContractApiDefinition, TContext> {
37
36
  export interface ClientHttpRouteToRpcTransformClientConfig {
38
37
  pathPrefix: string,
39
38
  contract: GGContractMethod,
40
- middlewares: readonly GGHttpTransportMiddleware[]
39
+ middlewares: readonly GGTransportMiddleware[]
41
40
  }
42
41
 
43
42
  export interface ClientHttpRouteToRpcTransformClientCodec {
@@ -58,12 +57,13 @@ export interface GGHttpFetchRequest {
58
57
 
59
58
  export interface ClientHttpRouteToRpcTransformServerConfig {
60
59
  contract: GGContractMethod,
61
- apiMiddlewares: readonly GGHttpTransportMiddleware[],
62
- serverMiddlewares: readonly GGHttpServerMiddleware[]
60
+ /** Schema bindings + server middlewares, already merged into one ordered server pipeline. */
61
+ middlewares: readonly GGTransportMiddleware[]
63
62
  }
64
63
 
65
64
  export interface ClientHttpRouteToRpcTransformServerCodec {
66
- parseRequest: (req: http.IncomingMessage) => Promise<unknown>,
65
+ readRequest: (req: http.IncomingMessage) => Promise<unknown>,
66
+ validateInput: (rawInput: unknown) => unknown,
67
67
  sendResponse: (res: http.ServerResponse, rpcResult: ERROR<string, unknown> | OK<unknown>) => Promise<void>
68
68
  }
69
69
 
@@ -137,66 +137,3 @@ export interface GGHttpCodec {
137
137
  toOpenApiOperation?(config: GGHttpCodecOpenApiConfig): Partial<OpenAPIV3_1.OperationObject>
138
138
  }
139
139
 
140
- // --------------------------------------------------------------------------------------------------------
141
- // Middleware
142
- // --------------------------------------------------------------------------------------------------------
143
-
144
- export interface GGHttpTransportMiddleware {
145
- /**
146
- * Request headers this middleware reads or writes, mapped to their value schemas.
147
- * Keys are header names; values describe the header value format for validation and docs.
148
- * Used for CORS Access-Control-Allow-Headers and OpenAPI parameter docs.
149
- * Use {} if the middleware touches no custom request headers.
150
- *
151
- * @example
152
- * headers: {
153
- * "authorization": IsString.nonEmpty.docs({title: "Bearer token", example: "Bearer ..."}),
154
- * "accept-language": IsLocale.orUndefined
155
- * }
156
- */
157
- readonly headers: Record<string, GGSchema<string | undefined>>;
158
-
159
- /**
160
- * Response headers this middleware sets, mapped to their value schemas.
161
- * Keys are header names; values describe the header value format for validation and docs.
162
- * Used for CORS Access-Control-Expose-Headers and OpenAPI response header docs.
163
- * Use {} if the middleware sets no custom response headers.
164
- */
165
- readonly responseHeaders: Record<string, GGSchema<string | undefined>>;
166
-
167
- /**
168
- * Cookies this middleware reads, mapped to their value schemas. Emitted as `in: cookie`
169
- * parameters in OpenAPI so cookie-bound inputs are documented. Absent for header/plain
170
- * middlewares.
171
- */
172
- readonly cookieParams?: Record<string, GGSchema<string | undefined>>;
173
-
174
- /**
175
- * Client-side: modify outgoing request (add headers, etc.)
176
- */
177
- updateRequest?(req: GGHttpRequest): void;
178
-
179
- /**
180
- * Server-side: parse incoming request (extract context from headers, etc.)
181
- */
182
- parseRequest?(req: GGHttpRequest): void;
183
-
184
- /**
185
- * Server-side: modify outgoing response
186
- */
187
- updateResponse?(res: GGHttpResponse): void;
188
-
189
- /**
190
- * Client-side: parse incoming response
191
- */
192
- parseResponse?(res: GGHttpResponse): void;
193
- }
194
-
195
- export interface GGHttpRequest {
196
- headers?: Record<string, string | string[]>;
197
- queryArgs?: Record<string, string | string[]>;
198
- }
199
-
200
- export interface GGHttpResponse {
201
- headers: Record<string, string | string[]>;
202
- }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Server/inbound half of the wire model: define/create the verification handler, run it during
3
+ * the request pipeline (process), and expose the caller's grants to the permission gate
4
+ * (permissions). Node-only — keeps @grest-ts/locator out of the browser bundle.
5
+ */
6
+ import {GGLocator, GGLocatorKey} from "@grest-ts/locator"
7
+ import {GGWireContextKey} from "./GGWireContextKey"
8
+
9
+ export interface GGWireServerHandler {
10
+ /** Verify the raw credential and mint the durable principal. Throws to reject the request. */
11
+ process: () => Promise<void>
12
+ /** The caller's grants from this wire — read off the durable principal minted in process(). */
13
+ permissions?: () => Promise<readonly string[]>
14
+ }
15
+
16
+ const HANDLER_KEYS = new WeakMap<GGWireContextKey, GGLocatorKey<GGWireServerHandler>>()
17
+ const FACTORIES = new WeakMap<GGWireContextKey, unknown>()
18
+
19
+ // Locator keys resolve by their string name, so the handler key must be unique per wire
20
+ // instance — two wires over the same name (e.g. a public ambient cookie and a gated one
21
+ // over the same cookie name) must not share a handler.
22
+ let nextWireHandlerId = 0
23
+
24
+ /** True once .define() has run on this wire — distinguishes verified wires from ambient ones. */
25
+ export function wireIsDefined(wire: GGWireContextKey): boolean {
26
+ return FACTORIES.has(wire)
27
+ }
28
+
29
+ function handlerKeyFor(wire: GGWireContextKey): GGLocatorKey<GGWireServerHandler> {
30
+ let key = HANDLER_KEYS.get(wire)
31
+ if (!key) {
32
+ key = new GGLocatorKey<GGWireServerHandler>(`wire:${wire.name}:${nextWireHandlerId++}`)
33
+ HANDLER_KEYS.set(wire, key)
34
+ }
35
+ return key
36
+ }
37
+
38
+ function resolveHandler(wire: GGWireContextKey): GGWireServerHandler {
39
+ const handler = handlerKeyFor(wire).tryGet()
40
+ if (!handler) {
41
+ throw new Error(`Wire "${wire.name}" is used but not implemented — call ${wire.name}.define(...).create(deps) in compose().`)
42
+ }
43
+ return handler
44
+ }
45
+
46
+ /**
47
+ * Binds the verification handler's deps into one runtime's locator scope. Returned by
48
+ * `.define()`; `.create(deps)` runs once per scope (a fresh worker / restart gets its own).
49
+ */
50
+ export class GGWireHandlerRegistration<Deps> {
51
+ constructor(
52
+ private readonly wire: GGWireContextKey,
53
+ private readonly factory: (deps: Deps) => GGWireServerHandler,
54
+ ) {
55
+ }
56
+
57
+ public create(deps: Deps): void {
58
+ const key = handlerKeyFor(this.wire)
59
+ if (GGLocator.getScope().has(key)) {
60
+ throw new Error(`Wire "${this.wire.name}" was already created in this runtime scope — .create() runs once per runtime.`)
61
+ }
62
+ GGLocator.getScope().set(key, this.factory(deps))
63
+ }
64
+ }
65
+
66
+ declare module "./GGWireContextKey" {
67
+ interface GGWireContextKey {
68
+ /** Attach the server-side verification handler. Process-global, frozen once — a second call throws. */
69
+ define<Deps>(factory: (deps: Deps) => GGWireServerHandler): GGWireHandlerRegistration<Deps>
70
+
71
+ /** @internal server pipeline hook — verify the credential and mint the durable principal. */
72
+ process(): Promise<void>
73
+
74
+ /** @internal If wire is handling permissions **/
75
+ hasPermissions(): boolean;
76
+
77
+ /** @internal permission-gate hook — the caller's grants from this wire. */
78
+ getGrantedPermissions(): Promise<readonly string[]>
79
+
80
+ /** @internal startup validation — true once .create() has run on the current runtime scope. */
81
+ hasHandler(): boolean
82
+
83
+ /** Drop the ephemeral raw credential after process(); ambient wires keep their value. */
84
+ clear(): void
85
+ }
86
+ }
87
+
88
+ GGWireContextKey.prototype.define = function (factory) {
89
+ if (FACTORIES.has(this)) {
90
+ throw new Error(`Wire "${this.name}" already has .define() — it can only be defined once.`)
91
+ }
92
+ FACTORIES.set(this, factory)
93
+ return new GGWireHandlerRegistration(this, factory as any)
94
+ }
95
+
96
+ GGWireContextKey.prototype.process = async function () {
97
+ if (!this.hasHandler()) return
98
+ await resolveHandler(this).process()
99
+ }
100
+
101
+ GGWireContextKey.prototype.hasPermissions = function (): boolean {
102
+ if (!this.hasHandler()) return false
103
+ const handler = resolveHandler(this)
104
+ return !!handler.permissions
105
+ }
106
+
107
+ GGWireContextKey.prototype.getGrantedPermissions = async function () {
108
+ if (!this.hasHandler()) return []
109
+ const handler = resolveHandler(this)
110
+ return handler.permissions ? await handler.permissions() : []
111
+ }
112
+
113
+ GGWireContextKey.prototype.hasHandler = function () {
114
+ return handlerKeyFor(this).has()
115
+ }
116
+
117
+ GGWireContextKey.prototype.clear = function () {
118
+ if (this.hasHandler() && this.has()) this.delete()
119
+ }
@@ -0,0 +1,46 @@
1
+ import {GGContextKey, type GGTransportMiddleware} from "@grest-ts/context"
2
+ import {GGContextKeySynchronizer} from "../client/GGContextKeySynchronizer"
3
+
4
+ /** Outbound freshness gate for a smart wire. The outbound value is always the wire's own context value. */
5
+ export interface GGWireClientHandler {
6
+ /** Async — true when the wire value is stale and recover() should run before the next outbound read. */
7
+ isStale?: () => boolean
8
+ /** Async — refresh the credential into the wire (via key.set); run by the synchronizer's waitFor before the outbound read. */
9
+ recover?: () => Promise<void>
10
+ }
11
+
12
+ /**
13
+ * Base for credential wires (GGHeader, GGCookie). A wire IS a GGContextKey and a transport
14
+ * middleware at once. Verified vs ambient is decided by whether `.define()` was called:
15
+ * - verified (`.define()`d): the wire holds the raw inbound credential, which is verified
16
+ * during process(), then clear()d before the handler runs — handlers read the durable key
17
+ * minted by the handler, never the credential.
18
+ * - ambient (never `.define()`d): the parsed value lands in the wire and persists through the
19
+ * handler (read via WIRE.get()); no process, no clear, no implementation needed.
20
+ *
21
+ * The server/inbound half (define/create/process/permissions) is attached node-side via
22
+ * ./GGWireContextKey.node — the browser bundle never pulls @grest-ts/locator.
23
+ */
24
+ export abstract class GGWireContextKey extends GGContextKey<string | undefined> implements GGTransportMiddleware {
25
+
26
+ private _clientHandler?: GGWireClientHandler
27
+
28
+ /**
29
+ * Attach the outbound freshness gate. Freeze-once. The outbound value is always the wire's
30
+ * context value; this only registers an isStale/recover gate that refreshes it before the read.
31
+ * GGAuthSession calls it internally on the wires it holds.
32
+ */
33
+ public defineClient(handler: GGWireClientHandler): void {
34
+ if (this._clientHandler) {
35
+ throw new Error(`Wire "${this.name}" already has .defineClient() — it can only be defined once.`)
36
+ }
37
+ this._clientHandler = handler
38
+ if (handler.isStale || handler.recover) {
39
+ GGContextKeySynchronizer.provide(this, {
40
+ isStale: handler.isStale ?? (() => false),
41
+ recover: handler.recover ?? (async () => {
42
+ }),
43
+ })
44
+ }
45
+ }
46
+ }
@@ -1,6 +1,6 @@
1
1
  import {GGHttpCodec, GGHttpSchema} from "./GGHttpSchema";
2
- import {GGHttpTransportMiddleware} from "./GGHttpSchema";
3
2
  import {GGContractApiDefinition, GGContractClass} from "@grest-ts/schema";
3
+ import type {GGTransportMiddleware} from "@grest-ts/context";
4
4
 
5
5
  /**
6
6
  * Create an HTTP API schema builder from a contract.
@@ -36,7 +36,7 @@ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext =
36
36
 
37
37
  private readonly _contract: GGContractClass<TContract>
38
38
  private _pathPrefix: string = ""
39
- private _middlewares: GGHttpTransportMiddleware[] = []
39
+ private _middlewares: GGTransportMiddleware[] = []
40
40
 
41
41
  constructor(contract: GGContractClass<TContract>) {
42
42
  this._contract = contract;
@@ -47,7 +47,7 @@ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext =
47
47
  return this
48
48
  }
49
49
 
50
- use<M extends GGHttpTransportMiddleware>(middleware: M): GGHttpSchemaBuilder<TContract, TContext | M> {
50
+ use<M extends GGTransportMiddleware>(middleware: M): GGHttpSchemaBuilder<TContract, TContext | M> {
51
51
  this._middlewares.push(middleware)
52
52
  return this as unknown as GGHttpSchemaBuilder<TContract, TContext | M>
53
53
  }
@@ -1,23 +1,8 @@
1
1
  import {GGHttpSchema} from "../schema/GGHttpSchema";
2
2
  import {GGContractApiDefinition, GGContractImplementation} from "@grest-ts/schema";
3
- import {GGHttpServerMiddleware} from "./GGHttpSchema.startServer";
3
+ import type {GGTransportMiddleware} from "@grest-ts/context";
4
4
  import {GGHttpServer} from "./GGHttpServer";
5
5
 
6
- /**
7
- * Resolves the caller's permission scopes for the current request. The function
8
- * is called once between transport middleware and the handler. Sync or async —
9
- * both supported (the gate awaits).
10
- *
11
- * Return `null` when no caller identity is available (the gate will throw
12
- * NOT_AUTHORIZED for non-public methods). Return a (possibly empty) set
13
- * otherwise; the gate will call satisfies() against the method's permission.
14
- *
15
- * The resolver should read whatever app-defined context the upstream auth
16
- * middleware populated — it should NOT parse the token itself.
17
- */
18
- export type GGScopeResolver =
19
- () => ReadonlySet<string> | null | Promise<ReadonlySet<string> | null>
20
-
21
6
  export class GGHttp<TContext = undefined> {
22
7
 
23
8
  /**
@@ -26,32 +11,17 @@ export class GGHttp<TContext = undefined> {
26
11
  * Do not tighten back to private.
27
12
  */
28
13
  protected readonly httpServer: GGHttpServer
29
- private readonly middlewares: GGHttpServerMiddleware[] = [];
30
- private permissionResolver?: GGScopeResolver;
14
+ private readonly middlewares: GGTransportMiddleware[] = [];
31
15
 
32
16
  constructor(httpServer: GGHttpServer) {
33
17
  this.httpServer = httpServer;
34
18
  }
35
19
 
36
- public use<M extends GGHttpServerMiddleware>(middleware: M): GGHttp<TContext | M> {
20
+ public use<M extends GGTransportMiddleware>(middleware: M): GGHttp<TContext | M> {
37
21
  this.middlewares.push(middleware);
38
22
  return this as any;
39
23
  }
40
24
 
41
- /**
42
- * Register a scope resolver. Subsequent .http(...) calls capture this
43
- * resolver and use it to gate every request against the contract's
44
- * declared `permission`.
45
- *
46
- * Calling order matters: .use(auth) → .usePermissions(getScopes) → .http(api).
47
- * If .http() runs before .usePermissions(), the call sees no resolver — and
48
- * the startup check will hard-fail if its schema has any non-public method.
49
- */
50
- public usePermissions(resolver: GGScopeResolver): this {
51
- this.permissionResolver = resolver;
52
- return this;
53
- }
54
-
55
25
  public http<TContract extends GGContractApiDefinition, TSchemaContext>(
56
26
  schema: GGHttpSchema<TContract, TSchemaContext>,
57
27
  implementation: GGContractImplementation<TContract>
@@ -59,7 +29,6 @@ export class GGHttp<TContext = undefined> {
59
29
  schema.register(implementation, {
60
30
  http: this.httpServer,
61
31
  middlewares: this.middlewares,
62
- permissionResolver: this.permissionResolver,
63
32
  });
64
33
  return this as any;
65
34
  }
@@ -6,20 +6,19 @@
6
6
  import http from "http";
7
7
  import {GGLocator} from "@grest-ts/locator";
8
8
  import {ClientHttpRouteToRpcTransformServerCodec, GGHttpCodec, GGHttpSchema} from "../schema/GGHttpSchema";
9
- import {describePermission, ERROR, FORBIDDEN, GG_NO_PERMISSIONS, GGContractApiDefinition, GGContractImplementation, GGContractMethod, GGPermissionChecker, NOT_AUTHORIZED, OK, satisfies, SERVER_ERROR} from "@grest-ts/schema";
9
+ import {ERROR, GGContractApiDefinition, GGContractImplementation, GGContractMethod, OK, SERVER_ERROR} from "@grest-ts/schema";
10
10
  import {HttpMethod} from "@grest-ts/common";
11
11
  import {GG_DISCOVERY} from "@grest-ts/discovery";
12
- import {GGContext, GGContextStore} from "@grest-ts/context";
12
+ import {GGContext, GGContextStore, type GGTransportMiddleware} from "@grest-ts/context";
13
13
  import {GG_TRACE} from "@grest-ts/trace";
14
14
  import {GG_HTTP_REQUEST} from "./GG_HTTP_REQUEST";
15
- import {GG_COOKIE_WRITES} from "../schema/cookieMiddleware";
15
+ import {GG_COOKIE_WRITES} from "../schema/GGCookie";
16
16
  import {GG_METRICS} from "@grest-ts/metrics";
17
17
  import {GGHttpMetrics} from "./GGHttpMetrics";
18
18
  import {GG_HTTP_SERVER} from "./GG_HTTP_SERVER";
19
19
  import {GGHttpServer} from "./GGHttpServer";
20
20
  import {GGLog} from "@grest-ts/logger";
21
- import {GG_PERMISSIONS} from "./GG_PERMISSIONS";
22
- import type {GGScopeResolver} from "./GGHttp";
21
+ import {GGHttpPermissionsChecker} from "../schema/GGHttpPermissionsChecker";
23
22
 
24
23
  export interface GGHttpSchemaConfig {
25
24
  /**
@@ -29,17 +28,7 @@ export interface GGHttpSchemaConfig {
29
28
  /**
30
29
  * Additional middlewares to apply to all routes.
31
30
  */
32
- middlewares?: GGHttpServerMiddleware[];
33
- /**
34
- * Optional scope resolver. When set, the gate calls it once per request,
35
- * populates GG_PERMISSIONS, and rejects requests whose contract permission
36
- * is not satisfied by the resolved scopes.
37
- */
38
- permissionResolver?: GGScopeResolver;
39
- }
40
-
41
- export interface GGHttpServerMiddleware {
42
- process?(): Promise<void>
31
+ middlewares?: GGTransportMiddleware[];
43
32
  }
44
33
 
45
34
  declare module "../schema/GGHttpSchema" {
@@ -81,9 +70,10 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
81
70
  const scope = GGLocator.getScope();
82
71
  const parentContext = GGContextStore.tryGetContext();
83
72
 
73
+ const permissionsChecker = new GGHttpPermissionsChecker(apiMiddlewares);
84
74
  for (const mw of apiMiddlewares) {
85
- const hKeys = Object.keys(mw.headers);
86
- const rhKeys = Object.keys(mw.responseHeaders);
75
+ const hKeys = Object.keys(mw.headers ?? {});
76
+ const rhKeys = Object.keys(mw.responseHeaders ?? {});
87
77
  if (hKeys.length) server.registerCorsHeaders(hKeys);
88
78
  if (rhKeys.length) server.registerCorsExposeHeaders(rhKeys);
89
79
  }
@@ -107,8 +97,6 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
107
97
  // @TODO This is not really used and only for testkit so it would register implementation of the contract... Not cool
108
98
  httpSchema.contract.implement(implementation);
109
99
 
110
- if (config.permissionResolver) server._markResolverWired(httpSchema);
111
-
112
100
  for (const methodName in httpSchema.codec) {
113
101
  // Wire format.
114
102
  const codec: GGHttpCodec = httpSchema.codec[methodName]
@@ -125,8 +113,7 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
125
113
  // build request mapping
126
114
  const requestParser: ClientHttpRouteToRpcTransformServerCodec = codec.createForServer({
127
115
  contract: contractFunctionSchema,
128
- apiMiddlewares: apiMiddlewares,
129
- serverMiddlewares: config.middlewares
116
+ middlewares: [...apiMiddlewares, ...(config.middlewares ?? [])]
130
117
  })
131
118
  const cookieWriteNames = new Set((codec.updatesCookies ?? []).map(k => k.name))
132
119
  server.registerRoute(codec.method, pathPrefix + codec.path, async (req: http.IncomingMessage, res: http.ServerResponse): Promise<void> => {
@@ -138,22 +125,10 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
138
125
  const startTime = performance.now()
139
126
  let rpcResult: ERROR<string, unknown> | OK<unknown>
140
127
  try {
141
- const rpcInput = await requestParser.parseRequest(req)
128
+ const rawInput = await requestParser.readRequest(req)
129
+ await permissionsChecker.assert(httpSchema.name, methodName, contractFunctionSchema.permission)
142
130
  try {
143
- if (config.permissionResolver) {
144
- const scopes = await config.permissionResolver()
145
- if (scopes != null) GG_PERMISSIONS.set(new GGPermissionChecker(scopes))
146
- const required = contractFunctionSchema.permission
147
- if (required !== undefined && required !== GG_NO_PERMISSIONS) {
148
- if (scopes == null) throw new NOT_AUTHORIZED({
149
- debugMessage: `${httpSchema.name}.${methodName} requires ${describePermission(required)} but no caller identity was resolved`
150
- })
151
- if (!satisfies(required, scopes)) throw new FORBIDDEN({
152
- debugMessage: `${httpSchema.name}.${methodName} requires ${describePermission(required)} — caller scopes did not satisfy`
153
- })
154
- }
155
- }
156
- rpcResult = {success: true, type: "OK", data: await implFn(rpcInput)}
131
+ rpcResult = {success: true, type: "OK", data: await implFn(requestParser.validateInput(rawInput))}
157
132
  // GGLog.debug(httpSchema, "Response", rpcResult) // This is very slow to log this (like 3x performance loss)
158
133
  } catch (error: unknown) {
159
134
  rpcResult = ERROR.fromUnknown(error);
@@ -170,7 +145,7 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
170
145
  metrics(rpcResult?.type, httpSchema.name, codec.method, pathPrefix, codec.path, startTime)
171
146
  }
172
147
  });
173
- })
148
+ }, `${httpSchema.name}.${methodName}`)
174
149
  }
175
150
  }
176
151