@grest-ts/http 0.0.34 → 0.0.35

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.
@@ -3,6 +3,21 @@ import {GGContractApiDefinition, GGContractImplementation} from "@grest-ts/schem
3
3
  import {GGHttpServerMiddleware} from "./GGHttpSchema.startServer";
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
+
6
21
  export class GGHttp<TContext = undefined> {
7
22
 
8
23
  /**
@@ -12,6 +27,7 @@ export class GGHttp<TContext = undefined> {
12
27
  */
13
28
  protected readonly httpServer: GGHttpServer
14
29
  private readonly middlewares: GGHttpServerMiddleware[] = [];
30
+ private permissionResolver?: GGScopeResolver;
15
31
 
16
32
  constructor(httpServer: GGHttpServer) {
17
33
  this.httpServer = httpServer;
@@ -22,11 +38,29 @@ export class GGHttp<TContext = undefined> {
22
38
  return this as any;
23
39
  }
24
40
 
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
+
25
55
  public http<TContract extends GGContractApiDefinition, TSchemaContext>(
26
56
  schema: GGHttpSchema<TContract, TSchemaContext>,
27
57
  implementation: GGContractImplementation<TContract>
28
58
  ): this {
29
- schema.register(implementation, {http: this.httpServer, middlewares: this.middlewares});
59
+ schema.register(implementation, {
60
+ http: this.httpServer,
61
+ middlewares: this.middlewares,
62
+ permissionResolver: this.permissionResolver,
63
+ });
30
64
  return this as any;
31
65
  }
32
66
  }
@@ -6,7 +6,7 @@
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 {ERROR, GGContractApiDefinition, GGContractImplementation, GGContractMethod, OK, SERVER_ERROR} from "@grest-ts/schema";
9
+ import {describePermission, ERROR, FORBIDDEN, GG_NO_PERMISSIONS, GGContractApiDefinition, GGContractImplementation, GGContractMethod, GGPermissionChecker, NOT_AUTHORIZED, OK, satisfies, SERVER_ERROR} from "@grest-ts/schema";
10
10
  import {HttpMethod} from "@grest-ts/common";
11
11
  import {GG_DISCOVERY} from "@grest-ts/discovery";
12
12
  import {GGContext, GGContextStore} from "@grest-ts/context";
@@ -17,6 +17,8 @@ import {GGHttpMetrics} from "./GGHttpMetrics";
17
17
  import {GG_HTTP_SERVER} from "./GG_HTTP_SERVER";
18
18
  import {GGHttpServer} from "./GGHttpServer";
19
19
  import {GGLog} from "@grest-ts/logger";
20
+ import {GG_PERMISSIONS} from "./GG_PERMISSIONS";
21
+ import type {GGScopeResolver} from "./GGHttp";
20
22
 
21
23
  export interface GGHttpSchemaConfig {
22
24
  /**
@@ -27,6 +29,12 @@ export interface GGHttpSchemaConfig {
27
29
  * Additional middlewares to apply to all routes.
28
30
  */
29
31
  middlewares?: GGHttpServerMiddleware[];
32
+ /**
33
+ * Optional scope resolver. When set, the gate calls it once per request,
34
+ * populates GG_PERMISSIONS, and rejects requests whose contract permission
35
+ * is not satisfied by the resolved scopes.
36
+ */
37
+ permissionResolver?: GGScopeResolver;
30
38
  }
31
39
 
32
40
  export interface GGHttpServerMiddleware {
@@ -98,6 +106,8 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
98
106
  // @TODO This is not really used and only for testkit so it would register implementation of the contract... Not cool
99
107
  httpSchema.contract.implement(implementation);
100
108
 
109
+ if (config.permissionResolver) server._markResolverWired(httpSchema);
110
+
101
111
  for (const methodName in httpSchema.codec) {
102
112
  // Wire format.
103
113
  const codec: GGHttpCodec = httpSchema.codec[methodName]
@@ -127,6 +137,19 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
127
137
  try {
128
138
  const rpcInput = await requestParser.parseRequest(req)
129
139
  try {
140
+ if (config.permissionResolver) {
141
+ const scopes = await config.permissionResolver()
142
+ if (scopes != null) GG_PERMISSIONS.set(new GGPermissionChecker(scopes))
143
+ const required = contractFunctionSchema.permission
144
+ if (required !== undefined && required !== GG_NO_PERMISSIONS) {
145
+ if (scopes == null) throw new NOT_AUTHORIZED({
146
+ debugMessage: `${httpSchema.name}.${methodName} requires ${describePermission(required)} but no caller identity was resolved`
147
+ })
148
+ if (!satisfies(required, scopes)) throw new FORBIDDEN({
149
+ debugMessage: `${httpSchema.name}.${methodName} requires ${describePermission(required)} — caller scopes did not satisfy`
150
+ })
151
+ }
152
+ }
130
153
  rpcResult = {success: true, type: "OK", data: await implFn(rpcInput)}
131
154
  // GGLog.debug(httpSchema, "Response", rpcResult) // This is very slow to log this (like 3x performance loss)
132
155
  } catch (error: unknown) {
@@ -6,9 +6,16 @@ import {GG_HTTP_SERVER} from "./GG_HTTP_SERVER";
6
6
  import {GGLog} from "@grest-ts/logger";
7
7
  import findMyWay, {HTTPMethod} from "find-my-way";
8
8
  import type {GGHttpSchema} from "../schema/GGHttpSchema";
9
+ import {describePermission, GG_NO_PERMISSIONS, GGContractMethod, GGPermission} from "@grest-ts/schema";
9
10
  // Forward declaration — actual type lives in @grest-ts/websocket to avoid circular dep.
10
11
  // GGHttpServer only stores the array; callers cast as needed.
11
- type AnyWebSocketSchema = {name: string; path: string; contract: unknown; middlewares: readonly unknown[]};
12
+ type AnyWebSocketSchema = {
13
+ name: string;
14
+ path: string;
15
+ contract: {clientToServer: {methods: Record<string, GGContractMethod>}};
16
+ middlewares: readonly unknown[];
17
+ connectPermission?: GGPermission;
18
+ };
12
19
 
13
20
  export interface GGHttpServerAdapterConfig {
14
21
  key?: GGLocatorKey<GGHttpServer>;
@@ -183,12 +190,88 @@ export class GGHttpServer {
183
190
  this._registeredWebSocketSchemas.push(schema);
184
191
  }
185
192
 
193
+ private readonly _schemasWithResolver = new Set<object>();
194
+
195
+ /** @internal Called by HTTP / WS register paths when a scope resolver is wired. */
196
+ public _markResolverWired(schema: object): void {
197
+ this._schemasWithResolver.add(schema);
198
+ }
199
+
200
+ private _checkPermissionsAtStart(): void {
201
+ type Surface = {label: string; permission: GGPermission | undefined; resolverWired: boolean};
202
+ const surfaces: Surface[] = [];
203
+
204
+ for (const schema of this._registeredSchemas) {
205
+ const resolverWired = this._schemasWithResolver.has(schema);
206
+ const methods = schema.contract?.methods ?? {};
207
+ for (const name of Object.keys(methods)) {
208
+ surfaces.push({
209
+ label: `${schema.name}.${name}`,
210
+ permission: (methods[name] as GGContractMethod).permission,
211
+ resolverWired,
212
+ });
213
+ }
214
+ }
215
+ for (const ws of this._registeredWebSocketSchemas) {
216
+ const resolverWired = this._schemasWithResolver.has(ws);
217
+ const methods = ws.contract.clientToServer.methods;
218
+ for (const name of Object.keys(methods)) {
219
+ surfaces.push({
220
+ label: `${ws.name}.${name}`,
221
+ permission: methods[name].permission,
222
+ resolverWired,
223
+ });
224
+ }
225
+ if (ws.connectPermission !== undefined) {
226
+ surfaces.push({
227
+ label: `${ws.name} (connectPermission)`,
228
+ permission: ws.connectPermission,
229
+ resolverWired,
230
+ });
231
+ }
232
+ }
233
+
234
+ let strict = false;
235
+ const undeclared: Surface[] = [];
236
+ const orphaned: Surface[] = [];
237
+ for (const s of surfaces) {
238
+ if (s.permission !== undefined || s.resolverWired) strict = true;
239
+ if (s.permission === undefined) undeclared.push(s);
240
+ else if (s.permission !== GG_NO_PERMISSIONS && !s.resolverWired) orphaned.push(s);
241
+ }
242
+ if (!strict) return;
243
+
244
+ if (undeclared.length > 0) {
245
+ const lines = undeclared.map(s => ` ${s.label}`).join("\n");
246
+ throw new Error(
247
+ `GGHttpServer: permission strict mode is active on this server ` +
248
+ `(at least one route declares a permission or has .usePermissions(...) wired), ` +
249
+ `but the following routes have no \`permission\` declared:\n\n` +
250
+ lines +
251
+ `\n\nFix: declare \`permission\` on every route — use \`GG_NO_PERMISSIONS\` for intentionally public ones.`
252
+ );
253
+ }
254
+ if (orphaned.length > 0) {
255
+ const lines = orphaned.map(s =>
256
+ ` ${s.label} requires ${describePermission(s.permission)}`
257
+ ).join("\n");
258
+ throw new Error(
259
+ `GGHttpServer: these routes declare non-public permissions but their schema was ` +
260
+ `registered without a scope resolver:\n\n` +
261
+ lines +
262
+ `\n\nFix: call \`.usePermissions(yourResolver)\` on the GGHttp chain (or pass ` +
263
+ `\`permissionResolver\` to the WS schema config) before registering these routes.`
264
+ );
265
+ }
266
+ }
267
+
186
268
  public registerRoute(method: HttpMethod, path: string, handler: GGHttpRequestCallback): void {
187
269
  this.router.on(method as HTTPMethod, path, handler as unknown as findMyWay.Handler<findMyWay.HTTPVersion.V1>);
188
270
  }
189
271
 
190
272
  public async start(): Promise<void> {
191
273
  Object.freeze(this._registeredSchemas);
274
+ this._checkPermissionsAtStart();
192
275
  this._port = await new Promise((resolve) => {
193
276
  this.httpServer.listen(this.configuredPort, '0.0.0.0', () => {
194
277
  const port = (this.httpServer.address() as any).port;
@@ -0,0 +1,15 @@
1
+ import {GGContextKey} from "@grest-ts/context";
2
+ import {GGPermissionChecker, IsAny} from "@grest-ts/schema";
3
+
4
+ /**
5
+ * Per-request permission context populated by the gate when a scope resolver
6
+ * is wired via GGHttp.usePermissions(...). Handler code can read it to make
7
+ * sub-decisions inside an authorized method:
8
+ *
9
+ * const perm = GG_PERMISSIONS.get();
10
+ * if (perm.has(AppPermission.Admin)) { ... }
11
+ *
12
+ * The checker uses the same satisfies() the framework gate uses — no parallel
13
+ * implementations.
14
+ */
15
+ export const GG_PERMISSIONS = new GGContextKey<GGPermissionChecker>("permissions", IsAny as any);