@vida-global/core 2.3.3 → 2.3.4
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/AGENTS.md +45 -4
- package/lib/activeRecord/db/migrator.js +4 -1
- package/lib/server/controllerMixins/renderer.js +29 -2
- package/lib/server/controllerMixins/requestDetails.js +32 -0
- package/lib/server/doc/requestDetails.md +5 -0
- package/lib/server/server.js +1 -1
- package/package.json +1 -1
- package/test/activeRecord/db/migrator.test.js +42 -0
- package/test/server/controllerMixins/renderer.test.js +62 -0
- package/test/server/controllerMixins/requestDetails.test.js +66 -0
package/AGENTS.md
CHANGED
|
@@ -2,12 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
Use this guide to understand and write code for the vida-core repo.
|
|
4
4
|
|
|
5
|
+
`@vida-global/core` is the shared library behind all Vida apps. It also ships to
|
|
6
|
+
customers inside `vida-apps-tools`, so the exported surface is a public API.
|
|
7
|
+
Treat a changed or removed export as a breaking change, not a refactor.
|
|
8
|
+
|
|
9
|
+
CommonJS throughout (`require` / `module.exports`). There is no linter in this
|
|
10
|
+
repo — the style guide below is the only enforcement, so follow it closely.
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
# Style and conventions
|
|
14
|
+
|
|
15
|
+
Always in force:
|
|
16
|
+
|
|
6
17
|
@agents/style.md
|
|
7
|
-
@agents/server.md
|
|
8
|
-
@agents/db.md
|
|
9
|
-
@agents/apis.md
|
|
10
18
|
@agents/testing.md
|
|
11
19
|
|
|
20
|
+
Read only when the work touches that area:
|
|
21
|
+
|
|
22
|
+
- `agents/server.md` — Express endpoints, controllers, validation, status codes
|
|
23
|
+
- `agents/db.md` — ActiveRecord, Sequelize, migrations, caching
|
|
24
|
+
- `agents/apis.md` — outbound HTTP clients to third parties
|
|
25
|
+
- `agents/git.md` — branches, commits, GitHub
|
|
26
|
+
- `agents/frontend.md` — Next.js/React only. Not used by this package.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Commands
|
|
30
|
+
|
|
31
|
+
npm test # jest, coverage on by default
|
|
32
|
+
npm test -- test/server # narrow to a directory
|
|
33
|
+
npm test -- -t 'name' # narrow by test name
|
|
34
|
+
npm run develop # prerelease via @vida-global/release
|
|
35
|
+
npm run release # release via @vida-global/release
|
|
36
|
+
|
|
37
|
+
`npm test` carries required flags (`LOG_LEVEL=test`, `NODE_NO_WARNINGS=1`,
|
|
38
|
+
`node --experimental-vm-modules`). Always go through the script. Calling `jest`
|
|
39
|
+
directly drops them and the run misbehaves.
|
|
40
|
+
|
|
41
|
+
Coverage is collected on every run. `/helpers/` is excluded by design.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Repository map
|
|
45
|
+
|
|
46
|
+
`index.js` is the whole public surface. `lib/` holds the modules; `test/` mirrors
|
|
47
|
+
`lib/` one directory per module. Per-module guides live next to the code and are
|
|
48
|
+
listed in `README.md`: `activeRecord`, `http`, `logger`, `server`, `jobQueue`,
|
|
49
|
+
plus `cache`, `redis`, `apm`, `observability`, `utils`.
|
|
12
50
|
|
|
13
|
-
|
|
51
|
+
One non-obvious mechanic in `index.js`: server error classes are exported by
|
|
52
|
+
filtering `lib/server` exports on `prototype instanceof AbstractServerError`. A
|
|
53
|
+
new error class that extends it is exported automatically — do not add it to
|
|
54
|
+
`module.exports` by hand. One that does not extend it is silently absent.
|
|
@@ -119,8 +119,11 @@ class Migrator {
|
|
|
119
119
|
|
|
120
120
|
|
|
121
121
|
idColumn(idType=DEFAULT_ID_TYPE) {
|
|
122
|
+
if (idType === false) return {};
|
|
123
|
+
|
|
122
124
|
if (!ID_TYPES.includes(idType)) {
|
|
123
|
-
throw new Error(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}`
|
|
125
|
+
throw new Error(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}, or `
|
|
126
|
+
+ `false for a table that declares its own primary key.`);
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
return this[`${idType}IdColumn`];
|
|
@@ -9,6 +9,13 @@ class StreamInProgressError extends Error {}
|
|
|
9
9
|
class NoActiveStreamError extends Error {}
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
// xmlbuilder2's default object-notation control prefixes: @attribute, #text, $cdata,
|
|
13
|
+
// !comment, ?processing-instruction. A key using one of these must survive sanitization
|
|
14
|
+
// unmangled, or an intended attribute (e.g. {'@track': 'both'}) silently turns into a
|
|
15
|
+
// child element (`<_track>both</_track>`) instead of `track="both"`.
|
|
16
|
+
const XML_CONVERT_PREFIXES = ['@', '#', '$', '!', '?'];
|
|
17
|
+
|
|
18
|
+
|
|
12
19
|
const CONTENT_TYPE_BY_EXTENSION = {
|
|
13
20
|
css: 'text/css; charset=utf-8',
|
|
14
21
|
gif: 'image/gif',
|
|
@@ -88,12 +95,17 @@ const InstanceMethods = {
|
|
|
88
95
|
if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.xml;
|
|
89
96
|
const doc = create({ [this.xmlRootElement]: body });
|
|
90
97
|
const xml = doc.end();
|
|
91
|
-
this.
|
|
98
|
+
this._send(xml);
|
|
92
99
|
},
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
renderTextResponse(body) {
|
|
96
103
|
if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.txt;
|
|
104
|
+
this._send(body);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_send(body) {
|
|
97
109
|
this._response.send(body);
|
|
98
110
|
},
|
|
99
111
|
|
|
@@ -182,8 +194,23 @@ const InstanceMethods = {
|
|
|
182
194
|
|
|
183
195
|
// XML element names can't start with a digit or contain spaces/other invalid characters,
|
|
184
196
|
// so sanitize keys that would otherwise make xmlbuilder2 throw on dynamic response data.
|
|
197
|
+
// A leading xmlbuilder2 control prefix (see XML_CONVERT_PREFIXES) is preserved as-is;
|
|
198
|
+
// only the remainder (the attribute/PI name, if any) is sanitized.
|
|
185
199
|
_toXMLElementName(key) {
|
|
186
|
-
|
|
200
|
+
key = `${key}`;
|
|
201
|
+
|
|
202
|
+
const prefix = XML_CONVERT_PREFIXES.find(p => key.startsWith(p));
|
|
203
|
+
if (prefix) {
|
|
204
|
+
const rest = key.slice(prefix.length);
|
|
205
|
+
return rest ? prefix + this._sanitizeXMLNamePart(rest) : prefix;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return this._sanitizeXMLNamePart(key);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_sanitizeXMLNamePart(name) {
|
|
213
|
+
name = name.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
187
214
|
if (!/^[a-zA-Z_]/.test(name)) name = `_${name}`;
|
|
188
215
|
return name;
|
|
189
216
|
},
|
|
@@ -47,8 +47,10 @@ const Accessors = {
|
|
|
47
47
|
|
|
48
48
|
contentType: { get() { return this.requestHeaders['content-type'] }},
|
|
49
49
|
cookies: { get() { return this._request.cookies || {} }},
|
|
50
|
+
isProxiedRequest:{ get() { return Boolean(this.requestHeaders['x-forwarded-for']) }},
|
|
50
51
|
requestBody: { get() { return this._request.body }},
|
|
51
52
|
requestHeaders: { get() { return structuredClone(this._request.headers || {}) }},
|
|
53
|
+
requestHost: { get() { return this.requestHeaders.host }},
|
|
52
54
|
requestId: { get() { return this._request.id }},
|
|
53
55
|
requestIp: { get() { return this._request.ip }},
|
|
54
56
|
requestMethod: { get() { return this._request.method }},
|
|
@@ -73,6 +75,36 @@ const Accessors = {
|
|
|
73
75
|
return match[1].trim();
|
|
74
76
|
}
|
|
75
77
|
},
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
// The absolute URL the caller addressed, rebuilt from the request. Webhook signature schemes
|
|
81
|
+
// sign this string, so it has to match what the sender used character for character.
|
|
82
|
+
requestUrl: {
|
|
83
|
+
get() { return `${this.requestProtocol}://${this.requestHost}${this.url}` }
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
// TLS usually terminates at a load balancer, and not every balancer forwards
|
|
88
|
+
// X-Forwarded-Proto, so `_request.protocol` can report http on a request the caller made over
|
|
89
|
+
// https. Prefer the forwarded protocol, assume https for anything else that arrived through a
|
|
90
|
+
// proxy, and fall back to the connection's own protocol for direct requests.
|
|
91
|
+
requestProtocol: {
|
|
92
|
+
get() {
|
|
93
|
+
if (this.forwardedProtocol) return this.forwardedProtocol;
|
|
94
|
+
if (this.isProxiedRequest) return 'https';
|
|
95
|
+
return this._request.protocol;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// A comma-joined chain lists the original client's protocol first.
|
|
101
|
+
forwardedProtocol: {
|
|
102
|
+
get() {
|
|
103
|
+
const header = this.requestHeaders['x-forwarded-proto'];
|
|
104
|
+
if (!header) return null;
|
|
105
|
+
return header.split(',')[0].trim();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
76
108
|
}
|
|
77
109
|
|
|
78
110
|
|
|
@@ -19,6 +19,11 @@ Within an action, the controller exposes:
|
|
|
19
19
|
| `this.requestMethod` | HTTP method. |
|
|
20
20
|
| `this.requestIp` | Client IP address. |
|
|
21
21
|
| `this.url` | Original URL. |
|
|
22
|
+
| `this.requestHost` | Shortcut for `requestHeaders['host']`. |
|
|
23
|
+
| `this.requestUrl` | Absolute URL the caller addressed: `requestProtocol://requestHost + url`. Use this when verifying a webhook signature that covers the URL. |
|
|
24
|
+
| `this.requestProtocol` | `forwardedProtocol`, else `https` when `isProxiedRequest`, else the connection's own protocol. |
|
|
25
|
+
| `this.forwardedProtocol` | First entry of `X-Forwarded-Proto`, or `null`. |
|
|
26
|
+
| `this.isProxiedRequest` | `true` when `X-Forwarded-For` is present. |
|
|
22
27
|
| `this.bearerToken` | Parsed `Authorization: Bearer <token>` value, or `null`. |
|
|
23
28
|
| `this.statusCode` | Current outgoing status (settable, but prefer the render helpers below). |
|
|
24
29
|
| `this.logger` | Per-request child logger. |
|
package/lib/server/server.js
CHANGED
|
@@ -192,7 +192,7 @@ class VidaServer {
|
|
|
192
192
|
const method = action.method.toLowerCase();
|
|
193
193
|
const requestHandler = this.requestHandler(action.action, controllerCls)
|
|
194
194
|
if (process.env.NODE_ENV != 'test') {
|
|
195
|
-
this.logger.
|
|
195
|
+
this.logger.debug(`ROUTE: ${method.toUpperCase().padEnd(6)} ${action.path}`);
|
|
196
196
|
}
|
|
197
197
|
this['_'+method](action.path, requestHandler);
|
|
198
198
|
}
|
package/package.json
CHANGED
|
@@ -177,6 +177,48 @@ describe('Migrator', () => {
|
|
|
177
177
|
.rejects.toThrow(`Unknown id type "${idType}". Use one of: bigint, int, uuid`);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
it ('tells the caller false is an option when refusing an id type', async () => {
|
|
181
|
+
await expect(Helpers.migrator.createTable(Helpers.randomString(), {}, { id: Helpers.randomString() }))
|
|
182
|
+
.rejects.toThrow('or false for a table that declares its own primary key');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// For a table whose primary key is a natural column, e.g. a baseline migration recreating
|
|
186
|
+
// a table that predates the migrations directory.
|
|
187
|
+
it ('adds no id column when id is false', async () => {
|
|
188
|
+
const tableName = Helpers.randomString();
|
|
189
|
+
|
|
190
|
+
await Helpers.migrator.createTable(tableName, {}, { id: false });
|
|
191
|
+
|
|
192
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
193
|
+
expect(columns).not.toHaveProperty('id');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it ('keeps the table details when id is false', async () => {
|
|
197
|
+
const tableName = Helpers.randomString();
|
|
198
|
+
const uuid = { allowNull: false, primaryKey: true, type: 'UUID' };
|
|
199
|
+
|
|
200
|
+
await Helpers.migrator.createTable(tableName, { uuid }, { id: false, timestamps: false });
|
|
201
|
+
|
|
202
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(
|
|
203
|
+
tableName, { uuid }, Helpers.noTransaction,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const notFalseCases = [['null', null], ['zero', 0], ['an empty string', '']];
|
|
208
|
+
it.each(notFalseCases)('does not treat %s as false', async (_label, idType) => {
|
|
209
|
+
await expect(Helpers.migrator.createTable(Helpers.randomString(), {}, { id: idType }))
|
|
210
|
+
.rejects.toThrow('Unknown id type');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it ('still defaults to a bigint id when no id option is given', async () => {
|
|
214
|
+
const tableName = Helpers.randomString();
|
|
215
|
+
|
|
216
|
+
await Helpers.migrator.createTable(tableName, {}, {});
|
|
217
|
+
|
|
218
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
219
|
+
expect(columns.id).toEqual({ allowNull: false, autoIncrement: true, primaryKey: true, type: 'BIGINT' });
|
|
220
|
+
});
|
|
221
|
+
|
|
180
222
|
it ('lets the details override the id column', async () => {
|
|
181
223
|
const tableName = Helpers.randomString();
|
|
182
224
|
const id = { allowNull: false, primaryKey: true, type: 'UUID' };
|
|
@@ -494,6 +494,68 @@ describe('VidaServerController', () => {
|
|
|
494
494
|
expect(controller._toXMLElementName('first name')).toBe('first_name');
|
|
495
495
|
expect(controller._toXMLElementName('a:b')).toBe('a_b');
|
|
496
496
|
});
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
// xmlbuilder2 reads these prefixes as instructions rather than as part of the name.
|
|
500
|
+
// Sanitizing one away silently turns an intended attribute into a child element.
|
|
501
|
+
const prefixCases = [
|
|
502
|
+
['attribute', '@', '@track', 'both'],
|
|
503
|
+
['text', '#', '#text', 'value'],
|
|
504
|
+
['cdata', '$', '$cdata', 'value'],
|
|
505
|
+
['comment', '!', '!comment', 'value'],
|
|
506
|
+
['processing instruction', '?', '?target', 'value'],
|
|
507
|
+
];
|
|
508
|
+
it.each(prefixCases)('preserves the %s prefix', (_label, _prefix, key) => {
|
|
509
|
+
expect(controller._toXMLElementName(key)).toBe(key);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it ('sanitizes the name after the prefix but keeps the prefix', () => {
|
|
513
|
+
expect(controller._toXMLElementName('@my attr')).toBe('@my_attr');
|
|
514
|
+
expect(controller._toXMLElementName('@a:b')).toBe('@a_b');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it ('underscores a prefixed name that starts with a digit', () => {
|
|
518
|
+
expect(controller._toXMLElementName('@123')).toBe('@_123');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it ('returns a bare prefix untouched', () => {
|
|
522
|
+
expect(controller._toXMLElementName('@')).toBe('@');
|
|
523
|
+
expect(controller._toXMLElementName('#')).toBe('#');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it ('only treats a prefix as a prefix in the leading position', () => {
|
|
527
|
+
expect(controller._toXMLElementName('track@')).toBe('track_');
|
|
528
|
+
expect(controller._toXMLElementName('a#b')).toBe('a_b');
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
// The end-to-end reason the prefixes are preserved: without it these render as child
|
|
534
|
+
// elements (<_track>both</_track>) rather than attributes.
|
|
535
|
+
describe('#renderXMLResponse attribute rendering', () => {
|
|
536
|
+
it ('renders @-prefixed keys as XML attributes', async () => {
|
|
537
|
+
const response = buildResponseMock();
|
|
538
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
539
|
+
controller.formatResponseBody = body => body;
|
|
540
|
+
|
|
541
|
+
await controller.render({Record: {'@track': 'both', '@trim': 'do-not-trim'}});
|
|
542
|
+
|
|
543
|
+
expect(response.send).toHaveBeenCalledWith(
|
|
544
|
+
expect.stringContaining('<Record track="both" trim="do-not-trim"/>')
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it ('still renders an ordinary key as a child element', async () => {
|
|
549
|
+
const response = buildResponseMock();
|
|
550
|
+
const controller = new FooController({query: {_format: 'xml'}}, response);
|
|
551
|
+
controller.formatResponseBody = body => body;
|
|
552
|
+
|
|
553
|
+
await controller.render({Record: {'weird key!': 'x'}});
|
|
554
|
+
|
|
555
|
+
expect(response.send).toHaveBeenCalledWith(
|
|
556
|
+
expect.stringContaining('<weird_key_>x</weird_key_>')
|
|
557
|
+
);
|
|
558
|
+
});
|
|
497
559
|
});
|
|
498
560
|
|
|
499
561
|
|
|
@@ -88,6 +88,72 @@ describe('VidaServerController', () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
describe('#requestHost', () => {
|
|
92
|
+
it ('returns the host request header', () => {
|
|
93
|
+
request.headers.host = `${TestHelpers.Faker.Text.randomString()}.vida.dev`;
|
|
94
|
+
expect(controller.requestHost).toEqual(request.headers.host);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
describe('#forwardedProtocol', () => {
|
|
100
|
+
it ('returns null when the proxy sends no forwarded protocol', () => {
|
|
101
|
+
delete request.headers['x-forwarded-proto'];
|
|
102
|
+
expect(controller.forwardedProtocol).toBe(null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it ('returns the forwarded protocol', () => {
|
|
106
|
+
request.headers['x-forwarded-proto'] = 'https';
|
|
107
|
+
expect(controller.forwardedProtocol).toEqual('https');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it ('returns only the first entry of a forwarded protocol chain', () => {
|
|
111
|
+
request.headers['x-forwarded-proto'] = 'https, http';
|
|
112
|
+
expect(controller.forwardedProtocol).toEqual('https');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
describe('#isProxiedRequest', () => {
|
|
118
|
+
it.each([
|
|
119
|
+
['x-forwarded-for is present', '203.0.113.7', true],
|
|
120
|
+
['x-forwarded-for is absent', undefined, false],
|
|
121
|
+
])('returns %s -> %s', (_label, forwardedFor, expected) => {
|
|
122
|
+
delete request.headers['x-forwarded-for'];
|
|
123
|
+
if (forwardedFor) request.headers['x-forwarded-for'] = forwardedFor;
|
|
124
|
+
expect(controller.isProxiedRequest).toBe(expected);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
describe('#requestUrl', () => {
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
request.headers.host = 'api.vida.dev';
|
|
132
|
+
request.originalUrl = '/things?page=2';
|
|
133
|
+
request.protocol = 'http';
|
|
134
|
+
delete request.headers['x-forwarded-proto'];
|
|
135
|
+
delete request.headers['x-forwarded-for'];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it ('uses the forwarded protocol when the proxy sends one', () => {
|
|
139
|
+
request.headers['x-forwarded-proto'] = 'https';
|
|
140
|
+
request.headers['x-forwarded-for'] = '203.0.113.7';
|
|
141
|
+
expect(controller.requestUrl).toEqual('https://api.vida.dev/things?page=2');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Not every balancer forwards X-Forwarded-Proto, so a proxied request can look like
|
|
145
|
+
// plain http even though the caller addressed an https URL.
|
|
146
|
+
it ('assumes https for a proxied request that carries no forwarded protocol', () => {
|
|
147
|
+
request.headers['x-forwarded-for'] = '203.0.113.7';
|
|
148
|
+
expect(controller.requestUrl).toEqual('https://api.vida.dev/things?page=2');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it ('keeps the connection protocol for a direct request with no proxy headers', () => {
|
|
152
|
+
expect(controller.requestUrl).toEqual('http://api.vida.dev/things?page=2');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
|
|
91
157
|
describe('#statusCode', () => {
|
|
92
158
|
it ('returns the response status code', () => {
|
|
93
159
|
expect(controller.statusCode).toEqual(response.statusCode);
|