@owlmeans/server-api 0.1.18-rc.1 → 0.1.18-rc.11

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-api",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.496Z",
4
+ "version": "0.1.18-rc.11",
5
+ "generatedAt": "2026-08-24T21:14:00.090Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,13 +8,14 @@ user-invocable: false
8
8
  # @owlmeans/server-api
9
9
 
10
10
  **Layer:** Server
11
- **Install:** `"@owlmeans/server-api": "^0.1.18-rc.0"` in `dependencies`
11
+ **Install:** `"@owlmeans/server-api": "^0.1.18-rc.11"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
15
15
  | Export | Description |
16
16
  |--------|-------------|
17
17
  | `makeServer()` | Factory for the Fastify-based API server |
18
+ | `holdApiPort(cfg, { okPath?, payload? })` | Own the app's port during boot; released by the real `listen()` |
18
19
  | `Server`, `ServerRequest`, `ServerResponse` types | Runtime shapes |
19
20
  | Errors | Typed transport errors |
20
21
  | Constants | Default ports, paths |
@@ -34,7 +35,56 @@ const server = makeServer({ port: 8080 })
34
35
  context.registerService(server)
35
36
  ```
36
37
 
38
+ ## Holding the port through the boot
39
+
40
+ The server builds its route table from the entrypoints the context knows about, so it can only
41
+ bind **after** `init()` resolves — and Fastify refuses new routes once it is listening. That
42
+ leaves every way a boot can fail with nothing on the port: the edge answers a bare upstream
43
+ connect error naming neither the app nor the reason.
44
+
45
+ `holdApiPort` closes that window: a minimal Fastify instance binds the app's socket immediately —
46
+ resolving port and host from the same `cfg.services[cfg.service]` declaration `listen()` uses —
47
+ answers `okPath` with 200 and everything else with **503, not 404** (the real routes do not exist
48
+ yet, and "no such route" would be a lie that outlives the boot), evaluating `payload()` per
49
+ request so a changing boot phase is reported live.
50
+
51
+ ```typescript
52
+ const hold = await holdApiPort(ctx.cfg, { okPath: '/healtz', payload: bootPayload })
53
+ try { await initialize() } catch (e) { recordFailure(e); return } // hold keeps answering
54
+ await hold.release() // awaited — listen() binds on the next line
55
+ await ctx.getApiServer().listen()
56
+ ```
57
+
58
+ Two rules the caller owns: a **bind failure is fatal and loud** — name it (EADDRINUSE above all)
59
+ and `process.exit(1)`, because carrying on ends the boot with nothing listening and nothing
60
+ holding the event loop, a clean exit 0 while a stale predecessor keeps serving; and a **failed
61
+ init returns without exiting** — the hold keeps the loop open and is the only thing that can say
62
+ why.
63
+
64
+ ## Error → HTTP status
65
+
66
+ `handleError` (`./utils`) is the single mapping from a thrown error to a status. Handlers own no
67
+ status table: they throw, and this decides. Two families reach it and both are recognised —
68
+ `AccessError` / `AuthFailedError`, raised by this package's own request pipeline, and
69
+ `AuthForbidden` / `AuthorizationError` from `@owlmeans/auth`, which is what guards and gates throw.
70
+
71
+ | Thrown | Status | Meaning |
72
+ |--------|--------|---------|
73
+ | `AuthForbidden`, `AccessError` | `403` | An established identity that lacks the permission — re-authenticating changes nothing |
74
+ | `AuthorizationError`, `AuthFailedError` | `401` | No usable credential: absent, expired, revoked, or naming a session this server does not hold |
75
+ | anything else | `500` | |
76
+
77
+ **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch is tested first —
78
+ otherwise every refusal of a permission is reported as a failure to authenticate. And an
79
+ unrecognised refusal answering 500 reports a refused request as a crashed server: the client cannot
80
+ tell "sign in again" from "the service is broken", and a consumer watching statuses concludes the
81
+ application fell over.
82
+
83
+ The body is always `ResilientError.marshal(ResilientError.ensure(error)).message`, and nothing is
84
+ written when the reply was already sent.
85
+
37
86
  ## Depends On
38
87
 
39
88
  - `@owlmeans/context`, `@owlmeans/entrypoint`, `@owlmeans/route`
89
+ - `@owlmeans/auth` (the error family the guards throw), `@owlmeans/error`, `@owlmeans/api` (status constants)
40
90
  - `fastify` (runtime)
@@ -0,0 +1,52 @@
1
+ import type { ServerConfig } from '@owlmeans/server-context';
2
+ export interface ApiPortHoldOptions {
3
+ /**
4
+ * The one path answered 200 while the hold is in place — the app's health endpoint, so
5
+ * whatever supervises the process keeps a conventional liveness answer through the boot.
6
+ * Everything else answers 503 with the same body. Absent, everything is 503.
7
+ */
8
+ okPath?: string;
9
+ /** Body of every answer, evaluated per request so a changing boot phase is reported live. */
10
+ payload?: () => unknown;
11
+ }
12
+ export interface ApiPortHold {
13
+ /**
14
+ * Free the port for the real server. Awaits the actual close — `listen()` follows immediately,
15
+ * and a predecessor still bound makes it throw — and force-closes keep-alive sockets, so a
16
+ * supervisor's own poller cannot hold the release open.
17
+ */
18
+ release: () => Promise<void>;
19
+ }
20
+ /**
21
+ * Own the app's port while the context initializes, and answer for the app until `listen()`
22
+ * takes over.
23
+ *
24
+ * The API server itself cannot do this: it builds its route table from the entrypoints the
25
+ * context knows about, so it can only bind AFTER `init()` resolves — and Fastify refuses new
26
+ * routes once it is listening. Without a hold, the whole boot window, and every way a boot can
27
+ * fail, leaves nothing on the port; the edge then answers a bare upstream connect error that
28
+ * names neither the app nor the reason, which is indistinguishable from a crashed pod or an app
29
+ * that was never installed.
30
+ *
31
+ * So the boot sequence an app wants is:
32
+ *
33
+ * const hold = await holdApiPort(cfg, { okPath, payload }) // bind FIRST
34
+ * try { ...initialize... } catch { record the failure; return } // hold keeps answering
35
+ * await hold.release()
36
+ * await context.getApiServer().listen()
37
+ *
38
+ * Everything that is not the health path answers **503, not 404**: the routes the app really
39
+ * serves do not exist yet, and "no such route" would be a lie that outlives the boot.
40
+ *
41
+ * The port and host are resolved exactly as `listen()` resolves them — from the service's own
42
+ * declaration in `cfg.services[cfg.service]` — so the hold and the server can never disagree
43
+ * about which socket they are trading.
44
+ *
45
+ * A bind failure here (EADDRINUSE above all) throws to the caller and must be treated as fatal:
46
+ * a process that continues past it ends the boot with nothing listening and nothing holding the
47
+ * event loop, i.e. a clean exit 0 while a predecessor keeps serving stale code. Name the error
48
+ * and exit non-zero — that is what makes a supervisor reclaim the port instead of respawning
49
+ * into it.
50
+ */
51
+ export declare const holdApiPort: (cfg: ServerConfig, opts?: ApiPortHoldOptions) => Promise<ApiPortHold>;
52
+ //# sourceMappingURL=hold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hold.d.ts","sourceRoot":"","sources":["../src/hold.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAG5D,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,6FAA6F;IAC7F,OAAO,CAAC,EAAE,MAAM,OAAO,CAAA;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,WAAW,QACjB,YAAY,SAAQ,kBAAkB,KAC1C,OAAO,CAAC,WAAW,CA0BrB,CAAA"}
package/build/hold.js ADDED
@@ -0,0 +1,56 @@
1
+ import Fastify from 'fastify';
2
+ import { CLOSED_HOST, OPENED_HOST, PORT } from './consts.js';
3
+ /**
4
+ * Own the app's port while the context initializes, and answer for the app until `listen()`
5
+ * takes over.
6
+ *
7
+ * The API server itself cannot do this: it builds its route table from the entrypoints the
8
+ * context knows about, so it can only bind AFTER `init()` resolves — and Fastify refuses new
9
+ * routes once it is listening. Without a hold, the whole boot window, and every way a boot can
10
+ * fail, leaves nothing on the port; the edge then answers a bare upstream connect error that
11
+ * names neither the app nor the reason, which is indistinguishable from a crashed pod or an app
12
+ * that was never installed.
13
+ *
14
+ * So the boot sequence an app wants is:
15
+ *
16
+ * const hold = await holdApiPort(cfg, { okPath, payload }) // bind FIRST
17
+ * try { ...initialize... } catch { record the failure; return } // hold keeps answering
18
+ * await hold.release()
19
+ * await context.getApiServer().listen()
20
+ *
21
+ * Everything that is not the health path answers **503, not 404**: the routes the app really
22
+ * serves do not exist yet, and "no such route" would be a lie that outlives the boot.
23
+ *
24
+ * The port and host are resolved exactly as `listen()` resolves them — from the service's own
25
+ * declaration in `cfg.services[cfg.service]` — so the hold and the server can never disagree
26
+ * about which socket they are trading.
27
+ *
28
+ * A bind failure here (EADDRINUSE above all) throws to the caller and must be treated as fatal:
29
+ * a process that continues past it ends the boot with nothing listening and nothing holding the
30
+ * event loop, i.e. a clean exit 0 while a predecessor keeps serving stale code. Name the error
31
+ * and exit non-zero — that is what makes a supervisor reclaim the port instead of respawning
32
+ * into it.
33
+ */
34
+ export const holdApiPort = async (cfg, opts = {}) => {
35
+ const config = cfg.services[cfg.service];
36
+ const port = config?.internalPort ?? config?.port ?? PORT;
37
+ const host = config?.opened === true ? OPENED_HOST : CLOSED_HOST;
38
+ const payload = opts.payload ?? (() => ({ status: 'OK' }));
39
+ // Quiet and disposable: no logger, no plugins, and connections are force-closed on release so
40
+ // the handover to the real server is bounded rather than waiting out a keep-alive.
41
+ const server = Fastify({ logger: false, forceCloseConnections: true });
42
+ if (opts.okPath != null) {
43
+ server.all(opts.okPath, async (_request, reply) => {
44
+ return await reply.code(200).send(payload());
45
+ });
46
+ }
47
+ server.setNotFoundHandler(async (_request, reply) => {
48
+ return await reply.code(503).send(payload());
49
+ });
50
+ await server.listen({ port, host });
51
+ console.log(`api-server: holding ${host}:${port} for the boot${opts.okPath != null ? ` (${opts.okPath})` : ''}`);
52
+ return {
53
+ release: async () => { await server.close(); },
54
+ };
55
+ };
56
+ //# sourceMappingURL=hold.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hold.js","sourceRoot":"","sources":["../src/hold.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAA;AAE7B,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAsB5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAC9B,GAAiB,EAAE,IAAI,GAAuB,EAAE,EAC1B,EAAE;IACxB,MAAM,MAAM,GAAG,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,MAAM,EAAE,YAAY,IAAI,MAAM,EAAE,IAAI,IAAI,IAAI,CAAA;IACzD,MAAM,IAAI,GAAG,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,WAAW,CAAA;IAEhE,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;IAE1D,8FAA8F;IAC9F,mFAAmF;IACnF,MAAM,MAAM,GAAG,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,qBAAqB,EAAE,IAAI,EAAE,CAAC,CAAA;IAEtE,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;QACxB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE;YAChD,OAAO,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAA;QAC9C,CAAC,CAAC,CAAA;IACJ,CAAC;IACD,MAAM,CAAC,kBAAkB,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE;QAClD,OAAO,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAA;IAC9C,CAAC,CAAC,CAAA;IAEF,MAAM,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;IACnC,OAAO,CAAC,GAAG,CAAC,uBAAuB,IAAI,IAAI,IAAI,gBAAgB,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAEhH,OAAO;QACL,OAAO,EAAE,KAAK,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK,EAAE,CAAA,CAAC,CAAC;KAC9C,CAAA;AACH,CAAC,CAAA"}
package/build/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export * from './server.js';
3
3
  export * from './consts.js';
4
4
  export * from './errors.js';
5
5
  export * from './helper.js';
6
+ export * from './hold.js';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA"}
package/build/index.js CHANGED
@@ -2,4 +2,5 @@ export * from './server.js';
2
2
  export * from './consts.js';
3
3
  export * from './errors.js';
4
4
  export * from './helper.js';
5
+ export * from './hold.js';
5
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA"}
@@ -1,3 +1,16 @@
1
1
  import type { FastifyReply } from 'fastify';
2
+ /**
3
+ * Map a thrown error onto an HTTP status.
4
+ *
5
+ * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
6
+ * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
7
+ * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
8
+ * one answers 500, which reports a refused request as a crashed server: the client cannot tell
9
+ * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
10
+ * application fell over.
11
+ *
12
+ * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
13
+ * tested first or every refusal of a permission is reported as a failure to authenticate.
14
+ */
2
15
  export declare const handleError: (error: Error, reply: FastifyReply) => void;
3
16
  //# sourceMappingURL=error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAI3C,eAAO,MAAM,WAAW,UAAW,KAAK,SAAS,YAAY,SAY5D,CAAA"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAK3C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,UAAW,KAAK,SAAS,YAAY,SAe5D,CAAA"}
@@ -1,14 +1,31 @@
1
1
  import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api';
2
2
  import { AccessError, AuthFailedError } from '../errors.js';
3
+ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth';
3
4
  import { ResilientError } from '@owlmeans/error';
5
+ /**
6
+ * Map a thrown error onto an HTTP status.
7
+ *
8
+ * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
9
+ * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
10
+ * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
11
+ * one answers 500, which reports a refused request as a crashed server: the client cannot tell
12
+ * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
13
+ * application fell over.
14
+ *
15
+ * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
16
+ * tested first or every refusal of a permission is reported as a failure to authenticate.
17
+ */
4
18
  export const handleError = (error, reply) => {
5
19
  let code = SERVER_ERROR;
6
- if (error instanceof AuthFailedError) {
7
- code = UNAUTHORIZED_ERROR;
8
- }
9
- else if (error instanceof AccessError) {
20
+ if (error instanceof AuthForbidden || error instanceof AccessError) {
21
+ // An established identity that lacks the permission. Re-authenticating changes nothing.
10
22
  code = FORBIDDEN_ERROR;
11
23
  }
24
+ else if (error instanceof AuthorizationError || error instanceof AuthFailedError) {
25
+ // No usable credential — absent, expired, revoked, or naming a session this server no longer
26
+ // holds. The caller is told to sign in, not that signing in would not help.
27
+ code = UNAUTHORIZED_ERROR;
28
+ }
12
29
  if (!reply.sent) {
13
30
  reply.code(code).send(ResilientError.marshal(ResilientError.ensure(error)).message);
14
31
  }
@@ -1 +1 @@
1
- {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAEjF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAY,EAAE,KAAmB,EAAE,EAAE;IAC/D,IAAI,IAAI,GAAW,YAAY,CAAA;IAC/B,IAAI,KAAK,YAAY,eAAe,EAAE,CAAC;QACrC,IAAI,GAAG,kBAAkB,CAAA;IAC3B,CAAC;SAAM,IAAI,KAAK,YAAY,WAAW,EAAE,CAAC;QACxC,IAAI,GAAG,eAAe,CAAA;IACxB,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAChB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CACnB,cAAc,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,KAAc,CAAC,CAAC,CAAC,OAAO,CACtE,CAAA;IACH,CAAC;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAEjF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC3D,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAClE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAY,EAAE,KAAmB,EAAE,EAAE;IAC/D,IAAI,IAAI,GAAW,YAAY,CAAA;IAC/B,IAAI,KAAK,YAAY,aAAa,IAAI,KAAK,YAAY,WAAW,EAAE,CAAC;QACnE,wFAAwF;QACxF,IAAI,GAAG,eAAe,CAAA;IACxB,CAAC;SAAM,IAAI,KAAK,YAAY,kBAAkB,IAAI,KAAK,YAAY,eAAe,EAAE,CAAC;QACnF,6FAA6F;QAC7F,4EAA4E;QAC5E,IAAI,GAAG,kBAAkB,CAAA;IAC3B,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAChB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CACnB,cAAc,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,KAAc,CAAC,CAAC,CAAC,OAAO,CACtE,CAAA;IACH,CAAC;AACH,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-api",
3
- "version": "0.1.18-rc.1",
3
+ "version": "0.1.18-rc.11",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -32,15 +32,15 @@
32
32
  "@fastify/helmet": "^13.1.0",
33
33
  "@fastify/middie": "^9.0.2",
34
34
  "@fastify/multipart": "^9.0.1",
35
- "@owlmeans/api": "^0.1.18-rc.1",
36
- "@owlmeans/auth": "^0.1.18-rc.1",
37
- "@owlmeans/auth-common": "^0.1.18-rc.1",
38
- "@owlmeans/context": "^0.1.18-rc.1",
39
- "@owlmeans/error": "^0.1.18-rc.1",
40
- "@owlmeans/entrypoint": "^0.1.18-rc.1",
41
- "@owlmeans/route": "^0.1.18-rc.1",
42
- "@owlmeans/server-context": "^0.1.18-rc.1",
43
- "@owlmeans/server-entrypoint": "^0.1.18-rc.1",
35
+ "@owlmeans/api": "^0.1.18-rc.7",
36
+ "@owlmeans/auth": "^0.1.18-rc.6",
37
+ "@owlmeans/auth-common": "^0.1.18-rc.7",
38
+ "@owlmeans/context": "^0.1.18-rc.6",
39
+ "@owlmeans/error": "^0.1.18-rc.6",
40
+ "@owlmeans/entrypoint": "^0.1.18-rc.7",
41
+ "@owlmeans/route": "^0.1.18-rc.6",
42
+ "@owlmeans/server-context": "^0.1.18-rc.7",
43
+ "@owlmeans/server-entrypoint": "^0.1.18-rc.7",
44
44
  "ajv-errors": "^3.0.0",
45
45
  "fastify": "^5.0.0",
46
46
  "fastify-raw-body": "^5.0.0",
package/src/hold.ts ADDED
@@ -0,0 +1,84 @@
1
+ import Fastify from 'fastify'
2
+ import type { ServerConfig } from '@owlmeans/server-context'
3
+ import { CLOSED_HOST, OPENED_HOST, PORT } from './consts.js'
4
+
5
+ export interface ApiPortHoldOptions {
6
+ /**
7
+ * The one path answered 200 while the hold is in place — the app's health endpoint, so
8
+ * whatever supervises the process keeps a conventional liveness answer through the boot.
9
+ * Everything else answers 503 with the same body. Absent, everything is 503.
10
+ */
11
+ okPath?: string
12
+ /** Body of every answer, evaluated per request so a changing boot phase is reported live. */
13
+ payload?: () => unknown
14
+ }
15
+
16
+ export interface ApiPortHold {
17
+ /**
18
+ * Free the port for the real server. Awaits the actual close — `listen()` follows immediately,
19
+ * and a predecessor still bound makes it throw — and force-closes keep-alive sockets, so a
20
+ * supervisor's own poller cannot hold the release open.
21
+ */
22
+ release: () => Promise<void>
23
+ }
24
+
25
+ /**
26
+ * Own the app's port while the context initializes, and answer for the app until `listen()`
27
+ * takes over.
28
+ *
29
+ * The API server itself cannot do this: it builds its route table from the entrypoints the
30
+ * context knows about, so it can only bind AFTER `init()` resolves — and Fastify refuses new
31
+ * routes once it is listening. Without a hold, the whole boot window, and every way a boot can
32
+ * fail, leaves nothing on the port; the edge then answers a bare upstream connect error that
33
+ * names neither the app nor the reason, which is indistinguishable from a crashed pod or an app
34
+ * that was never installed.
35
+ *
36
+ * So the boot sequence an app wants is:
37
+ *
38
+ * const hold = await holdApiPort(cfg, { okPath, payload }) // bind FIRST
39
+ * try { ...initialize... } catch { record the failure; return } // hold keeps answering
40
+ * await hold.release()
41
+ * await context.getApiServer().listen()
42
+ *
43
+ * Everything that is not the health path answers **503, not 404**: the routes the app really
44
+ * serves do not exist yet, and "no such route" would be a lie that outlives the boot.
45
+ *
46
+ * The port and host are resolved exactly as `listen()` resolves them — from the service's own
47
+ * declaration in `cfg.services[cfg.service]` — so the hold and the server can never disagree
48
+ * about which socket they are trading.
49
+ *
50
+ * A bind failure here (EADDRINUSE above all) throws to the caller and must be treated as fatal:
51
+ * a process that continues past it ends the boot with nothing listening and nothing holding the
52
+ * event loop, i.e. a clean exit 0 while a predecessor keeps serving stale code. Name the error
53
+ * and exit non-zero — that is what makes a supervisor reclaim the port instead of respawning
54
+ * into it.
55
+ */
56
+ export const holdApiPort = async (
57
+ cfg: ServerConfig, opts: ApiPortHoldOptions = {}
58
+ ): Promise<ApiPortHold> => {
59
+ const config = cfg.services[cfg.service]
60
+ const port = config?.internalPort ?? config?.port ?? PORT
61
+ const host = config?.opened === true ? OPENED_HOST : CLOSED_HOST
62
+
63
+ const payload = opts.payload ?? (() => ({ status: 'OK' }))
64
+
65
+ // Quiet and disposable: no logger, no plugins, and connections are force-closed on release so
66
+ // the handover to the real server is bounded rather than waiting out a keep-alive.
67
+ const server = Fastify({ logger: false, forceCloseConnections: true })
68
+
69
+ if (opts.okPath != null) {
70
+ server.all(opts.okPath, async (_request, reply) => {
71
+ return await reply.code(200).send(payload())
72
+ })
73
+ }
74
+ server.setNotFoundHandler(async (_request, reply) => {
75
+ return await reply.code(503).send(payload())
76
+ })
77
+
78
+ await server.listen({ port, host })
79
+ console.log(`api-server: holding ${host}:${port} for the boot${opts.okPath != null ? ` (${opts.okPath})` : ''}`)
80
+
81
+ return {
82
+ release: async () => { await server.close() },
83
+ }
84
+ }
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from './server.js'
4
4
  export * from './consts.js'
5
5
  export * from './errors.js'
6
6
  export * from './helper.js'
7
+ export * from './hold.js'
@@ -1,14 +1,31 @@
1
1
  import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api'
2
2
  import type { FastifyReply } from 'fastify'
3
3
  import { AccessError, AuthFailedError } from '../errors.js'
4
+ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
4
5
  import { ResilientError } from '@owlmeans/error'
5
6
 
7
+ /**
8
+ * Map a thrown error onto an HTTP status.
9
+ *
10
+ * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
11
+ * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
12
+ * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
13
+ * one answers 500, which reports a refused request as a crashed server: the client cannot tell
14
+ * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
15
+ * application fell over.
16
+ *
17
+ * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
18
+ * tested first or every refusal of a permission is reported as a failure to authenticate.
19
+ */
6
20
  export const handleError = (error: Error, reply: FastifyReply) => {
7
21
  let code: number = SERVER_ERROR
8
- if (error instanceof AuthFailedError) {
9
- code = UNAUTHORIZED_ERROR
10
- } else if (error instanceof AccessError) {
22
+ if (error instanceof AuthForbidden || error instanceof AccessError) {
23
+ // An established identity that lacks the permission. Re-authenticating changes nothing.
11
24
  code = FORBIDDEN_ERROR
25
+ } else if (error instanceof AuthorizationError || error instanceof AuthFailedError) {
26
+ // No usable credential — absent, expired, revoked, or naming a session this server no longer
27
+ // holds. The caller is told to sign in, not that signing in would not help.
28
+ code = UNAUTHORIZED_ERROR
12
29
  }
13
30
  if (!reply.sent) {
14
31
  reply.code(code).send(