@ultimabase/maintenance 0.4.0-canary.0 → 0.4.0-canary.20261102201751
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/CHANGELOG.md +32 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/key.d.ts +5 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.js +26 -0
- package/package.json +6 -6
- package/src/index.ts +25 -9
- package/src/key.ts +33 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @ultimabase/maintenance
|
|
2
|
+
|
|
3
|
+
## 0.4.0-canary.20261102201751
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7accc26: Each plugin declares the codes its errors carry in its types' `errors`, and `ultimabase` exports `ErrorCodeOf<typeof app>`: the kernel's codes and those of each plugin the application lists, for a client to type its messages against (D-206). A GraphQL document the schema refuses, as an unknown field, now carries `extensions.code: 'BAD_USER_INPUT'`, as REST's refused query.
|
|
8
|
+
- 9cebf63: New package, `@ultimabase/maintenance`: `maintenancePlugin()` answers 503
|
|
9
|
+
`MAINTENANCE`, with `Retry-After`, to every request and WebSocket upgrade
|
|
10
|
+
but Master's, and closes the open sockets with 1012, while the maintenance
|
|
11
|
+
is on. `PUT /_ultimabase/maintenance { enabled }`, with the master key or
|
|
12
|
+
the `key` option sent in `x-ultimabase-maintenance-key`, turns it on and
|
|
13
|
+
off, and so do `plugins.maintenance.lock()` and `unlock()`; every server
|
|
14
|
+
follows the stored state, or `initial` while none is stored, and a start
|
|
15
|
+
that cannot read it fails (D-207). `/health` and `/ready` are left
|
|
16
|
+
alone. Plugins add their own gates with `plugins.http.gate(gate)`, run on
|
|
17
|
+
each request past CORS and on each upgrade (D-205).
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Updated dependencies [67d5152]
|
|
22
|
+
- Updated dependencies [7accc26]
|
|
23
|
+
- Updated dependencies [8dca2cf]
|
|
24
|
+
- Updated dependencies [4e1a966]
|
|
25
|
+
- Updated dependencies [9b491fb]
|
|
26
|
+
- Updated dependencies [9b491fb]
|
|
27
|
+
- Updated dependencies [9cebf63]
|
|
28
|
+
- Updated dependencies [1d7fda8]
|
|
29
|
+
- Updated dependencies [a778695]
|
|
30
|
+
- Updated dependencies [e61e1c2]
|
|
31
|
+
- ultimabase@0.4.0-canary.20261102201751
|
|
32
|
+
- @ultimabase/http@0.4.0-canary.20261102201751
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type { PluginOf, PluginTypes } from 'ultimabase';
|
|
|
4
4
|
export interface MaintenanceOptions {
|
|
5
5
|
/** In milliseconds, how long a client waits before it tries again: 60 000 by default. */
|
|
6
6
|
readonly retryAfter?: number;
|
|
7
|
+
/** Sent in `x-ultimabase-maintenance-key`, it switches the maintenance and nothing else. */
|
|
8
|
+
readonly key?: string | undefined;
|
|
9
|
+
/** The state while none is stored: `'unlocked'` by default. */
|
|
10
|
+
readonly initial?: 'locked' | 'unlocked';
|
|
7
11
|
}
|
|
8
12
|
/** What the maintenance plugin offers other plugins. */
|
|
9
13
|
export interface MaintenanceApi {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EAAU,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAM/D,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IAClC,yFAAyF;IACzF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,4FAA4F;IAC5F,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAA;CACxC;AAED,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC9B,2DAA2D;IAC3D,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,4CAA4C;IAC5C,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACvB,6CAA6C;IAC7C,MAAM,IAAI,OAAO,CAAA;CACjB;AAED,6CAA6C;AAC7C,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAA;IAC5B,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAA;IACvC,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAC9B;AA+ID,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,WAAW,CAAsB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { httpPlugin, routes } from '@ultimabase/http';
|
|
2
|
-
import { definePlugin, master, PublicError } from 'ultimabase';
|
|
2
|
+
import { definePlugin, master, PublicError, Refused } from 'ultimabase';
|
|
3
3
|
import manifest from '../package.json' with { type: 'json' };
|
|
4
|
+
import { keyed } from './key.js';
|
|
4
5
|
import { MaintenanceState } from './state.js';
|
|
5
6
|
/** The route Master turns it on and off with, which it never refuses. */
|
|
6
7
|
const path = '/_ultimabase/maintenance';
|
|
@@ -11,9 +12,9 @@ function follow(state, locked) {
|
|
|
11
12
|
state.close();
|
|
12
13
|
state.locked = locked;
|
|
13
14
|
}
|
|
14
|
-
async function stored() {
|
|
15
|
+
async function stored(state) {
|
|
15
16
|
const row = await MaintenanceState.findUnique({ where: { key } }, { as: master });
|
|
16
|
-
return row?.enabled ??
|
|
17
|
+
return row?.enabled ?? state.initial;
|
|
17
18
|
}
|
|
18
19
|
async function store(state, locked) {
|
|
19
20
|
await MaintenanceState.upsert({
|
|
@@ -26,7 +27,7 @@ async function store(state, locked) {
|
|
|
26
27
|
/** Reads the state again; keeps the last one when it cannot. */
|
|
27
28
|
async function reload(state) {
|
|
28
29
|
try {
|
|
29
|
-
follow(state, await stored());
|
|
30
|
+
follow(state, await stored(state));
|
|
30
31
|
}
|
|
31
32
|
catch (error) {
|
|
32
33
|
state.logger.error({ err: error }, 'The maintenance state could not be read: it stays as it was.');
|
|
@@ -56,8 +57,14 @@ const maintenance = definePlugin({
|
|
|
56
57
|
models: [MaintenanceState],
|
|
57
58
|
api: (context) => {
|
|
58
59
|
const { plugins, options, identify, logger } = context;
|
|
60
|
+
const { initial = 'unlocked' } = options;
|
|
61
|
+
if (initial !== 'locked' && initial !== 'unlocked') {
|
|
62
|
+
throw new Refused("The maintenance's initial is 'locked' or 'unlocked'.");
|
|
63
|
+
}
|
|
64
|
+
const switches = keyed(options.key);
|
|
59
65
|
const state = {
|
|
60
66
|
locked: false,
|
|
67
|
+
initial: initial === 'locked',
|
|
61
68
|
close: () => plugins.http.closeUpgrades({ code: 1012, reason: 'Maintenance' }),
|
|
62
69
|
logger,
|
|
63
70
|
};
|
|
@@ -77,8 +84,8 @@ const maintenance = definePlugin({
|
|
|
77
84
|
}, { status: 503, headers: { 'retry-after': String(seconds) } });
|
|
78
85
|
});
|
|
79
86
|
plugins.http.mount(routes().put(path, async (c) => {
|
|
80
|
-
if (c.var.caller.kind !== 'master') {
|
|
81
|
-
throw new PublicError('ACCESS_DENIED', 'Only the master key turns the maintenance on or off.');
|
|
87
|
+
if (c.var.caller.kind !== 'master' && !switches(c.req.raw)) {
|
|
88
|
+
throw new PublicError('ACCESS_DENIED', 'Only the master key or the maintenance key turns the maintenance on or off.');
|
|
82
89
|
}
|
|
83
90
|
const enabled = enabledIn(await c.req.json().catch(() => undefined));
|
|
84
91
|
await store(state, enabled);
|
|
@@ -91,7 +98,10 @@ const maintenance = definePlugin({
|
|
|
91
98
|
};
|
|
92
99
|
},
|
|
93
100
|
start: async (context) => {
|
|
94
|
-
|
|
101
|
+
// A read that fails fails the start: the server must not serve
|
|
102
|
+
// while it does not know whether it is in maintenance (D-207).
|
|
103
|
+
const state = stateOf(context);
|
|
104
|
+
state.locked = await stored(state);
|
|
95
105
|
},
|
|
96
106
|
changed: (changes, context) => {
|
|
97
107
|
if (changes.some((change) => change.model === MaintenanceState.name)) {
|
package/dist/key.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** The header a request sends the maintenance key in. */
|
|
2
|
+
export declare const keyHeader = "x-ultimabase-maintenance-key";
|
|
3
|
+
/** Whether a request sends `key`; never, when it is unset. */
|
|
4
|
+
export declare function keyed(key: string | undefined): (request: Request) => boolean;
|
|
5
|
+
//# sourceMappingURL=key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"key.d.ts","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"AAOA,yDAAyD;AACzD,eAAO,MAAM,SAAS,iCAAiC,CAAA;AAMvD,8DAA8D;AAC9D,wBAAgB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAiB5E"}
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The maintenance key (D-207): it switches the maintenance, nothing else, so a
|
|
2
|
+
// deployment pipeline does not hold the master key. It follows the master
|
|
3
|
+
// key's rules: unset when empty, a header's characters only, and 32
|
|
4
|
+
// characters at least in production.
|
|
5
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { Refused } from 'ultimabase';
|
|
7
|
+
/** The header a request sends the maintenance key in. */
|
|
8
|
+
export const keyHeader = 'x-ultimabase-maintenance-key';
|
|
9
|
+
// Digests have one length, so the comparison takes the same time for any key.
|
|
10
|
+
const digest = (text) => createHash('sha256').update(text).digest();
|
|
11
|
+
/** Whether a request sends `key`; never, when it is unset. */
|
|
12
|
+
export function keyed(key) {
|
|
13
|
+
if (key === undefined || key === '')
|
|
14
|
+
return () => false;
|
|
15
|
+
if (!/^[\x21-\x7e]+$/.test(key)) {
|
|
16
|
+
throw new Refused('The maintenance key holds a space or a character outside ASCII, which a header cannot carry: use letters, digits and symbols only.');
|
|
17
|
+
}
|
|
18
|
+
if (process.env['NODE_ENV'] === 'production' && key.length < 32) {
|
|
19
|
+
throw new Refused(`The maintenance key is ${key.length} characters long; production needs 32 at least: \`openssl rand -base64 32\` gives one.`);
|
|
20
|
+
}
|
|
21
|
+
const expected = digest(key);
|
|
22
|
+
return (request) => {
|
|
23
|
+
const sent = request.headers.get(keyHeader);
|
|
24
|
+
return sent !== null && timingSafeEqual(digest(sent), expected);
|
|
25
|
+
};
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/maintenance",
|
|
3
|
-
"version": "0.4.0-canary.
|
|
3
|
+
"version": "0.4.0-canary.20261102201751",
|
|
4
4
|
"description": "Maintenance for UltimaBase: every server answers 503 and closes its WebSockets until it ends, Master's requests aside.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@ultimabase/http": "
|
|
35
|
-
"ultimabase": "
|
|
34
|
+
"@ultimabase/http": "0.4.0-canary.20261102201751",
|
|
35
|
+
"ultimabase": "0.4.0-canary.20261102201751"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@ultimabase/http": "0.
|
|
39
|
-
"@ultimabase/testing": "0.
|
|
40
|
-
"ultimabase": "0.
|
|
38
|
+
"@ultimabase/http": "0.4.0-canary.20261102201751",
|
|
39
|
+
"@ultimabase/testing": "0.4.0-canary.20261102201751",
|
|
40
|
+
"ultimabase": "0.4.0-canary.20261102201751",
|
|
41
41
|
"vite": "8.3.4",
|
|
42
42
|
"vitest": "5.0.5"
|
|
43
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
// Maintenance (D-205): while it is on, every request and WebSocket upgrade but
|
|
2
2
|
// Master's answers 503 `MAINTENANCE`, and the sockets open close with 1012.
|
|
3
|
-
// Its state is a row: every server follows it through the change feed
|
|
3
|
+
// Its state is a row: every server follows it through the change feed, and
|
|
4
|
+
// starts from `initial` while there is none (D-207).
|
|
4
5
|
import type { Http } from '@ultimabase/http'
|
|
5
6
|
import { httpPlugin, routes } from '@ultimabase/http'
|
|
6
7
|
import type { Logger, PluginOf, PluginTypes } from 'ultimabase'
|
|
7
|
-
import { definePlugin, master, PublicError } from 'ultimabase'
|
|
8
|
+
import { definePlugin, master, PublicError, Refused } from 'ultimabase'
|
|
8
9
|
import manifest from '../package.json' with { type: 'json' }
|
|
10
|
+
import { keyed } from './key.ts'
|
|
9
11
|
import { MaintenanceState } from './state.ts'
|
|
10
12
|
|
|
11
13
|
/** What `maintenancePlugin` takes. */
|
|
12
14
|
export interface MaintenanceOptions {
|
|
13
15
|
/** In milliseconds, how long a client waits before it tries again: 60 000 by default. */
|
|
14
16
|
readonly retryAfter?: number
|
|
17
|
+
/** Sent in `x-ultimabase-maintenance-key`, it switches the maintenance and nothing else. */
|
|
18
|
+
readonly key?: string | undefined
|
|
19
|
+
/** The state while none is stored: `'unlocked'` by default. */
|
|
20
|
+
readonly initial?: 'locked' | 'unlocked'
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
/** What the maintenance plugin offers other plugins. */
|
|
@@ -37,9 +43,10 @@ export interface Maintenance extends PluginTypes {
|
|
|
37
43
|
const path = '/_ultimabase/maintenance'
|
|
38
44
|
const key = 'state'
|
|
39
45
|
|
|
40
|
-
/** This server's state, and how it closes the sockets open. */
|
|
46
|
+
/** This server's state, the one while none is stored, and how it closes the sockets open. */
|
|
41
47
|
interface State {
|
|
42
48
|
locked: boolean
|
|
49
|
+
readonly initial: boolean
|
|
43
50
|
readonly close: () => void
|
|
44
51
|
readonly logger: Logger
|
|
45
52
|
}
|
|
@@ -50,12 +57,12 @@ function follow(state: State, locked: boolean): void {
|
|
|
50
57
|
state.locked = locked
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
async function stored(): Promise<boolean> {
|
|
60
|
+
async function stored(state: State): Promise<boolean> {
|
|
54
61
|
const row = await MaintenanceState.findUnique(
|
|
55
62
|
{ where: { key } },
|
|
56
63
|
{ as: master },
|
|
57
64
|
)
|
|
58
|
-
return row?.enabled ??
|
|
65
|
+
return row?.enabled ?? state.initial
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
async function store(state: State, locked: boolean): Promise<void> {
|
|
@@ -73,7 +80,7 @@ async function store(state: State, locked: boolean): Promise<void> {
|
|
|
73
80
|
/** Reads the state again; keeps the last one when it cannot. */
|
|
74
81
|
async function reload(state: State): Promise<void> {
|
|
75
82
|
try {
|
|
76
|
-
follow(state, await stored())
|
|
83
|
+
follow(state, await stored(state))
|
|
77
84
|
} catch (error) {
|
|
78
85
|
state.logger.error(
|
|
79
86
|
{ err: error },
|
|
@@ -111,8 +118,14 @@ const maintenance = definePlugin<Maintenance>({
|
|
|
111
118
|
models: [MaintenanceState],
|
|
112
119
|
api: (context) => {
|
|
113
120
|
const { plugins, options, identify, logger } = context
|
|
121
|
+
const { initial = 'unlocked' } = options
|
|
122
|
+
if (initial !== 'locked' && initial !== 'unlocked') {
|
|
123
|
+
throw new Refused("The maintenance's initial is 'locked' or 'unlocked'.")
|
|
124
|
+
}
|
|
125
|
+
const switches = keyed(options.key)
|
|
114
126
|
const state: State = {
|
|
115
127
|
locked: false,
|
|
128
|
+
initial: initial === 'locked',
|
|
116
129
|
close: () =>
|
|
117
130
|
plugins.http.closeUpgrades({ code: 1012, reason: 'Maintenance' }),
|
|
118
131
|
logger,
|
|
@@ -136,10 +149,10 @@ const maintenance = definePlugin<Maintenance>({
|
|
|
136
149
|
})
|
|
137
150
|
plugins.http.mount(
|
|
138
151
|
routes().put(path, async (c) => {
|
|
139
|
-
if (c.var.caller.kind !== 'master') {
|
|
152
|
+
if (c.var.caller.kind !== 'master' && !switches(c.req.raw)) {
|
|
140
153
|
throw new PublicError(
|
|
141
154
|
'ACCESS_DENIED',
|
|
142
|
-
'Only the master key turns the maintenance on or off.',
|
|
155
|
+
'Only the master key or the maintenance key turns the maintenance on or off.',
|
|
143
156
|
)
|
|
144
157
|
}
|
|
145
158
|
const enabled = enabledIn(await c.req.json().catch(() => undefined))
|
|
@@ -154,7 +167,10 @@ const maintenance = definePlugin<Maintenance>({
|
|
|
154
167
|
}
|
|
155
168
|
},
|
|
156
169
|
start: async (context) => {
|
|
157
|
-
|
|
170
|
+
// A read that fails fails the start: the server must not serve
|
|
171
|
+
// while it does not know whether it is in maintenance (D-207).
|
|
172
|
+
const state = stateOf(context)
|
|
173
|
+
state.locked = await stored(state)
|
|
158
174
|
},
|
|
159
175
|
changed: (changes, context) => {
|
|
160
176
|
if (changes.some((change) => change.model === MaintenanceState.name)) {
|
package/src/key.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The maintenance key (D-207): it switches the maintenance, nothing else, so a
|
|
2
|
+
// deployment pipeline does not hold the master key. It follows the master
|
|
3
|
+
// key's rules: unset when empty, a header's characters only, and 32
|
|
4
|
+
// characters at least in production.
|
|
5
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
6
|
+
import { Refused } from 'ultimabase'
|
|
7
|
+
|
|
8
|
+
/** The header a request sends the maintenance key in. */
|
|
9
|
+
export const keyHeader = 'x-ultimabase-maintenance-key'
|
|
10
|
+
|
|
11
|
+
// Digests have one length, so the comparison takes the same time for any key.
|
|
12
|
+
const digest = (text: string): Buffer =>
|
|
13
|
+
createHash('sha256').update(text).digest()
|
|
14
|
+
|
|
15
|
+
/** Whether a request sends `key`; never, when it is unset. */
|
|
16
|
+
export function keyed(key: string | undefined): (request: Request) => boolean {
|
|
17
|
+
if (key === undefined || key === '') return () => false
|
|
18
|
+
if (!/^[\x21-\x7e]+$/.test(key)) {
|
|
19
|
+
throw new Refused(
|
|
20
|
+
'The maintenance key holds a space or a character outside ASCII, which a header cannot carry: use letters, digits and symbols only.',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
if (process.env['NODE_ENV'] === 'production' && key.length < 32) {
|
|
24
|
+
throw new Refused(
|
|
25
|
+
`The maintenance key is ${key.length} characters long; production needs 32 at least: \`openssl rand -base64 32\` gives one.`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
const expected = digest(key)
|
|
29
|
+
return (request) => {
|
|
30
|
+
const sent = request.headers.get(keyHeader)
|
|
31
|
+
return sent !== null && timingSafeEqual(digest(sent), expected)
|
|
32
|
+
}
|
|
33
|
+
}
|