@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 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 {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EAAU,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAK/D,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IAClC,yFAAyF;IACzF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC5B;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;AAqID,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,WAAW,CAAsB,CAAA"}
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 ?? false;
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
- stateOf(context).locked = await stored();
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.0",
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": "^0.3.0",
35
- "ultimabase": "^0.3.0"
34
+ "@ultimabase/http": "0.4.0-canary.20261102201751",
35
+ "ultimabase": "0.4.0-canary.20261102201751"
36
36
  },
37
37
  "devDependencies": {
38
- "@ultimabase/http": "0.3.0",
39
- "@ultimabase/testing": "0.3.0",
40
- "ultimabase": "0.3.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 ?? false
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
- stateOf(context).locked = await stored()
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
+ }