@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.
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-api/SKILL.md +51 -1
- package/build/hold.d.ts +52 -0
- package/build/hold.d.ts.map +1 -0
- package/build/hold.js +56 -0
- package/build/hold.js.map +1 -0
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/utils/error.d.ts +13 -0
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +21 -4
- package/build/utils/error.js.map +1 -1
- package/package.json +10 -10
- package/src/hold.ts +84 -0
- package/src/index.ts +1 -0
- package/src/utils/error.ts +20 -3
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-api",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-08-
|
|
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.
|
|
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)
|
package/build/hold.d.ts
ADDED
|
@@ -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
package/build/index.d.ts.map
CHANGED
|
@@ -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
package/build/index.js.map
CHANGED
|
@@ -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"}
|
package/build/utils/error.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/build/utils/error.js
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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
|
}
|
package/build/utils/error.js.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
36
|
-
"@owlmeans/auth": "^0.1.18-rc.
|
|
37
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
38
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
39
|
-
"@owlmeans/error": "^0.1.18-rc.
|
|
40
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
41
|
-
"@owlmeans/route": "^0.1.18-rc.
|
|
42
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
43
|
-
"@owlmeans/server-entrypoint": "^0.1.18-rc.
|
|
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
package/src/utils/error.ts
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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(
|