@foss.global/forgefixtures 0.2.0

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.
Files changed (65) hide show
  1. package/.smartconfig.json +49 -0
  2. package/changelog.md +17 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/classes.certificateauthority.d.ts +22 -0
  6. package/dist_ts/classes.certificateauthority.js +93 -0
  7. package/dist_ts/classes.containerlifecycle.d.ts +88 -0
  8. package/dist_ts/classes.containerlifecycle.js +383 -0
  9. package/dist_ts/classes.giteafixture.d.ts +41 -0
  10. package/dist_ts/classes.giteafixture.js +182 -0
  11. package/dist_ts/classes.giteaseed.d.ts +13 -0
  12. package/dist_ts/classes.giteaseed.js +432 -0
  13. package/dist_ts/classes.gitlabfixture.d.ts +49 -0
  14. package/dist_ts/classes.gitlabfixture.js +237 -0
  15. package/dist_ts/classes.gitlabseed.d.ts +13 -0
  16. package/dist_ts/classes.gitlabseed.js +466 -0
  17. package/dist_ts/classes.httpclient.d.ts +53 -0
  18. package/dist_ts/classes.httpclient.js +116 -0
  19. package/dist_ts/classes.reaper.d.ts +25 -0
  20. package/dist_ts/classes.reaper.js +102 -0
  21. package/dist_ts/classes.tlsterminator.d.ts +21 -0
  22. package/dist_ts/classes.tlsterminator.js +131 -0
  23. package/dist_ts/constants.d.ts +22 -0
  24. package/dist_ts/constants.js +30 -0
  25. package/dist_ts/giteaseed.default.d.ts +10 -0
  26. package/dist_ts/giteaseed.default.js +87 -0
  27. package/dist_ts/gitlabseed.default.d.ts +12 -0
  28. package/dist_ts/gitlabseed.default.js +84 -0
  29. package/dist_ts/index.d.ts +17 -0
  30. package/dist_ts/index.js +17 -0
  31. package/dist_ts/interfaces.d.ts +92 -0
  32. package/dist_ts/interfaces.giteaseed.d.ts +182 -0
  33. package/dist_ts/interfaces.giteaseed.js +2 -0
  34. package/dist_ts/interfaces.gitlabseed.d.ts +183 -0
  35. package/dist_ts/interfaces.gitlabseed.js +2 -0
  36. package/dist_ts/interfaces.js +2 -0
  37. package/dist_ts/ownership.d.ts +29 -0
  38. package/dist_ts/ownership.js +140 -0
  39. package/dist_ts/plugins.d.ts +13 -0
  40. package/dist_ts/plugins.js +18 -0
  41. package/dist_ts/responses.d.ts +7 -0
  42. package/dist_ts/responses.js +30 -0
  43. package/license.md +21 -0
  44. package/package.json +65 -0
  45. package/readme.md +206 -0
  46. package/ts/00_commitinfo_data.ts +8 -0
  47. package/ts/classes.certificateauthority.ts +117 -0
  48. package/ts/classes.containerlifecycle.ts +432 -0
  49. package/ts/classes.giteafixture.ts +205 -0
  50. package/ts/classes.giteaseed.ts +486 -0
  51. package/ts/classes.gitlabfixture.ts +258 -0
  52. package/ts/classes.gitlabseed.ts +502 -0
  53. package/ts/classes.httpclient.ts +156 -0
  54. package/ts/classes.reaper.ts +126 -0
  55. package/ts/classes.tlsterminator.ts +136 -0
  56. package/ts/constants.ts +35 -0
  57. package/ts/giteaseed.default.ts +88 -0
  58. package/ts/gitlabseed.default.ts +86 -0
  59. package/ts/index.ts +17 -0
  60. package/ts/interfaces.giteaseed.ts +130 -0
  61. package/ts/interfaces.gitlabseed.ts +135 -0
  62. package/ts/interfaces.ts +94 -0
  63. package/ts/ownership.ts +160 -0
  64. package/ts/plugins.ts +23 -0
  65. package/ts/responses.ts +33 -0
@@ -0,0 +1,135 @@
1
+ export type TGitlabVisibility = 'public' | 'internal' | 'private';
2
+
3
+ export interface IGitlabSeedFile {
4
+ path: string;
5
+ content: string;
6
+ }
7
+
8
+ export interface IGitlabSeedUser {
9
+ username: string;
10
+ name?: string;
11
+ }
12
+
13
+ export interface IGitlabSeedGroup {
14
+ /** Group path segment. The full path is `<parent>/<path>`. */
15
+ path: string;
16
+ /** Full path of an earlier group in the spec, making this a subgroup. */
17
+ parent?: string;
18
+ visibility: TGitlabVisibility;
19
+ members?: Array<{ username: string; accessLevel: TGitlabAccessLevel }>;
20
+ }
21
+
22
+ /** GitLab access levels by name: guest 10, reporter 20, developer 30, maintainer 40, owner 50. */
23
+ export type TGitlabAccessLevel = 'guest' | 'reporter' | 'developer' | 'maintainer' | 'owner';
24
+
25
+ export interface IGitlabSeedIssue {
26
+ kind: 'issue';
27
+ /** Spec-local identifier used to find this issue in the manifest. */
28
+ key: string;
29
+ title: string;
30
+ description?: string;
31
+ /** Existing user who opens the issue (through the administrator's sudo). */
32
+ author: string;
33
+ issueType?: 'issue' | 'incident';
34
+ confidential?: boolean;
35
+ state: 'opened' | 'closed';
36
+ /** Applied by the administrator, so they are not dropped for authors without planning rights. */
37
+ labels?: string[];
38
+ milestone?: string;
39
+ assignees?: string[];
40
+ notes?: Array<{ author: string; body: string }>;
41
+ }
42
+
43
+ export interface IGitlabSeedMergeRequest {
44
+ kind: 'mergeRequest';
45
+ key: string;
46
+ title: string;
47
+ description?: string;
48
+ author: string;
49
+ /** Branch in the same project. */
50
+ sourceBranch: string;
51
+ targetBranch: string;
52
+ state: 'opened' | 'closed';
53
+ labels?: string[];
54
+ }
55
+
56
+ export interface IGitlabSeedProject {
57
+ /** Full path of a seeded group, or a seeded username for a personal project. */
58
+ namespace: string;
59
+ path: string;
60
+ visibility: TGitlabVisibility;
61
+ description?: string;
62
+ members?: Array<{ username: string; accessLevel: TGitlabAccessLevel }>;
63
+ /** Committed to `main` after initialisation with a README. */
64
+ files?: IGitlabSeedFile[];
65
+ /** Each branch is created by one commit of its files on top of `from` (default `main`). */
66
+ branches?: Array<{ name: string; from?: string; files: [IGitlabSeedFile, ...IGitlabSeedFile[]] }>;
67
+ tags?: Array<{ name: string; ref: string; message?: string }>;
68
+ labels?: Array<{ name: string; color: string; description?: string }>;
69
+ /** Closed milestones are closed after the items: GitLab ignores a closed milestone assigned through the API. */
70
+ milestones?: Array<{ title: string; description?: string; state?: 'active' | 'closed' }>;
71
+ /** Issues and merge requests have separate IID sequences; within each kind they are created in order. */
72
+ issuesAndMergeRequests?: Array<IGitlabSeedIssue | IGitlabSeedMergeRequest>;
73
+ releases?: Array<{ tagName: string; ref: string; name: string; description?: string }>;
74
+ }
75
+
76
+ export interface IGitlabSeedSpec {
77
+ users: IGitlabSeedUser[];
78
+ groups: IGitlabSeedGroup[];
79
+ projects: IGitlabSeedProject[];
80
+ /** Deleted after everything else (without hard delete); GitLab moves their contributions to its ghost user. */
81
+ deleteUsers?: string[];
82
+ }
83
+
84
+ export interface IGitlabSeedManifestAuthor {
85
+ id: number;
86
+ username: string;
87
+ }
88
+
89
+ /** What GitLab itself reported after seeding, read back once all mutations finished. */
90
+ export interface IGitlabSeedManifest {
91
+ baseUrl: string;
92
+ version: string;
93
+ users: Array<{ username: string; id: number; deleted: boolean }>;
94
+ groups: Array<{ fullPath: string; id: number; parentId: number | null; visibility: TGitlabVisibility }>;
95
+ projects: IGitlabSeedManifestProject[];
96
+ }
97
+
98
+ export interface IGitlabSeedManifestProject {
99
+ pathWithNamespace: string;
100
+ id: number;
101
+ namespaceId: number;
102
+ namespaceKind: 'group' | 'user';
103
+ visibility: TGitlabVisibility;
104
+ defaultBranch: string;
105
+ webUrl: string;
106
+ httpUrlToRepo: string;
107
+ branches: Array<{ name: string; commitSha: string }>;
108
+ tags: Array<{ name: string; commitSha: string }>;
109
+ labels: Array<{ name: string; id: number }>;
110
+ milestones: Array<{ title: string; id: number; iid: number; state: 'active' | 'closed' }>;
111
+ issues: Array<{
112
+ key: string;
113
+ id: number;
114
+ iid: number;
115
+ state: 'opened' | 'closed';
116
+ issueType: string;
117
+ confidential: boolean;
118
+ author: IGitlabSeedManifestAuthor;
119
+ labels: string[];
120
+ milestone: string | null;
121
+ assignees: string[];
122
+ notes: Array<{ id: number; author: IGitlabSeedManifestAuthor }>;
123
+ }>;
124
+ mergeRequests: Array<{
125
+ key: string;
126
+ id: number;
127
+ iid: number;
128
+ state: 'opened' | 'closed';
129
+ author: IGitlabSeedManifestAuthor;
130
+ sourceBranch: string;
131
+ targetBranch: string;
132
+ sha: string;
133
+ }>;
134
+ releases: Array<{ tagName: string; name: string }>;
135
+ }
@@ -0,0 +1,94 @@
1
+ export type TForgeFixtureKind = 'gitea' | 'gitlab';
2
+
3
+ /** A published forge image, pinned by its multi-architecture index digest. */
4
+ export interface IForgeFixtureImage {
5
+ /** Upstream release the image is expected to report through its version API. */
6
+ version: string;
7
+ /** Human-readable tag, retained as evidence only. The digest is what gets pulled. */
8
+ tag: string;
9
+ /** Complete `repository@sha256:<index digest>` reference, pulled and verified exactly. */
10
+ repoDigest: string;
11
+ }
12
+
13
+ /** Options shared by every fixture lifecycle. */
14
+ export interface IForgeFixtureLifecycleOptions {
15
+ /**
16
+ * Docker Engine socket. Omit to use `@apiclient.xyz/docker` resolution
17
+ * (`DOCKER_HOST`, then `/var/run/docker.sock`).
18
+ */
19
+ dockerSocketPath?: string;
20
+ /**
21
+ * Upper bound for this lifecycle. Once exceeded, any later reaper run may
22
+ * remove its resources even if the owning process is still alive.
23
+ */
24
+ maxLifetimeMs?: number;
25
+ /** Deadline for pulling the pinned image, including its digest verification. */
26
+ pullTimeoutMs?: number;
27
+ /** Deadline from container start until the forge answers as ready. */
28
+ startupTimeoutMs?: number;
29
+ }
30
+
31
+ /** Ownership evidence written into the labels of every Docker resource of one lifecycle. */
32
+ export interface IForgeFixtureOwner {
33
+ lifecycleId: string;
34
+ kind: TForgeFixtureKind;
35
+ /** systemd machine id (`/etc/machine-id`); distinguishes hosts that share a host name. */
36
+ machineId: string;
37
+ hostname: string;
38
+ bootId: string;
39
+ /** PID namespace of the owner (`pid:[<inode>]`); PIDs are only comparable inside it. */
40
+ pidNamespace: string;
41
+ pid: number;
42
+ /** Process start time in clock ticks since boot, guarding against PID reuse. */
43
+ processStartTicks: string;
44
+ createdAt: string;
45
+ expiresAt: string;
46
+ }
47
+
48
+ export type TForgeFixtureReapReason = 'owner-exited' | 'owner-rebooted' | 'expired';
49
+
50
+ export interface IForgeFixtureReapReport {
51
+ removedContainers: string[];
52
+ removedNetworks: string[];
53
+ removedVolumes: string[];
54
+ /** Lifecycles removed, with the reason each was considered stale. */
55
+ reapedLifecycles: Array<{ lifecycleId: string; reason: TForgeFixtureReapReason }>;
56
+ /** Managed resources whose ownership labels could not be interpreted. They are never removed. */
57
+ unrecognizedResources: Array<{ type: 'container' | 'network' | 'volume'; id: string }>;
58
+ }
59
+
60
+ /** Material a test needs to talk to a started fixture over verified TLS. */
61
+ export interface IForgeFixtureEndpoint {
62
+ /** HTTPS instance root without trailing slash, for example `https://127.0.0.1:41234`. */
63
+ baseUrl: string;
64
+ /** PEM of the lifecycle-local certificate authority that issued the endpoint certificate. */
65
+ caCertificatePem: string;
66
+ }
67
+
68
+ export interface IGiteaFixtureRuntime extends IForgeFixtureEndpoint {
69
+ kind: 'gitea';
70
+ lifecycleId: string;
71
+ /** Version reported by `/api/v1/version`, verified against the image pin. */
72
+ version: string;
73
+ image: IForgeFixtureImage;
74
+ /** Site administrator created at bootstrap. Its password is random and never retained. */
75
+ admin: {
76
+ username: string;
77
+ /** Access token with scope `all`. */
78
+ token: string;
79
+ };
80
+ }
81
+
82
+ export interface IGitlabFixtureRuntime extends IForgeFixtureEndpoint {
83
+ kind: 'gitlab';
84
+ lifecycleId: string;
85
+ /** Version reported by `/api/v4/version`, verified against the image pin. */
86
+ version: string;
87
+ image: IForgeFixtureImage;
88
+ /** The built-in `root` administrator. Its password is generated by GitLab and never read. */
89
+ admin: {
90
+ username: string;
91
+ /** Personal access token with scopes `api` and `sudo`. */
92
+ token: string;
93
+ };
94
+ }
@@ -0,0 +1,160 @@
1
+ import * as plugins from './plugins.js';
2
+ import { forgeFixtureLabels } from './constants.js';
3
+ import type { IForgeFixtureOwner, TForgeFixtureKind, TForgeFixtureReapReason } from './interfaces.js';
4
+
5
+ export interface IForgeFixtureProcessIdentity {
6
+ machineId: string;
7
+ hostname: string;
8
+ bootId: string;
9
+ pidNamespace: string;
10
+ pid: number;
11
+ processStartTicks: string;
12
+ }
13
+
14
+ const machineIdPattern = /^[0-9a-f]{32}$/;
15
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
16
+ const decimalPattern = /^[0-9]{1,20}$/;
17
+ const pidNamespacePattern = /^pid:\[[0-9]{1,20}\]$/;
18
+
19
+ const isErrnoException = (errorArg: unknown): errorArg is NodeJS.ErrnoException =>
20
+ errorArg instanceof Error && 'code' in errorArg;
21
+
22
+ /**
23
+ * Reads field 22 (`starttime`) of `/proc/<pid>/stat`. Returns `undefined` when
24
+ * the process does not exist. The command name may contain spaces and
25
+ * parentheses, so fields are counted after its closing parenthesis.
26
+ */
27
+ export const readProcessStartTicks = async (pidArg: number): Promise<string | undefined> => {
28
+ let stat: string;
29
+ try {
30
+ stat = await plugins.fs.promises.readFile(`/proc/${pidArg}/stat`, 'utf8');
31
+ } catch (error) {
32
+ if (isErrnoException(error) && error.code === 'ENOENT') return undefined;
33
+ throw error;
34
+ }
35
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
36
+ // fields[0] is field 3 (state), so field 22 is fields[19].
37
+ const startTicks = fields[19];
38
+ if (startTicks === undefined || !decimalPattern.test(startTicks)) {
39
+ throw new Error(`Process ${pidArg} has a malformed /proc stat record.`);
40
+ }
41
+ return startTicks;
42
+ };
43
+
44
+ /**
45
+ * Stands in for the machine ID of a process whose system has no valid
46
+ * `/etc/machine-id`, as in many container images. It is fixed for the life of
47
+ * the process and never matches another process, so every other owner is
48
+ * foreign to this one, and this owner is foreign to every other process: both
49
+ * sides judge each other by expiry only.
50
+ */
51
+ const processLocalMachineId = plugins.crypto.randomBytes(16).toString('hex');
52
+
53
+ const readMachineId = async (): Promise<string> => {
54
+ let machineId: string;
55
+ try {
56
+ machineId = (await plugins.fs.promises.readFile('/etc/machine-id', 'utf8')).trim();
57
+ } catch (error) {
58
+ if (isErrnoException(error) && error.code === 'ENOENT') return processLocalMachineId;
59
+ throw error;
60
+ }
61
+ return machineIdPattern.test(machineId) ? machineId : processLocalMachineId;
62
+ };
63
+
64
+ export const readCurrentProcessIdentity = async (): Promise<IForgeFixtureProcessIdentity> => {
65
+ const machineId = await readMachineId();
66
+ const bootId = (await plugins.fs.promises.readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim();
67
+ if (!uuidPattern.test(bootId)) throw new Error('The kernel boot id is malformed.');
68
+ const processStartTicks = await readProcessStartTicks(process.pid);
69
+ if (processStartTicks === undefined) throw new Error('The current process has no /proc stat record.');
70
+ const pidNamespace = await plugins.fs.promises.readlink('/proc/self/ns/pid');
71
+ if (!pidNamespacePattern.test(pidNamespace)) throw new Error('The current PID namespace identity is malformed.');
72
+ return { machineId, hostname: plugins.os.hostname(), bootId, pidNamespace, pid: process.pid, processStartTicks };
73
+ };
74
+
75
+ export const createForgeFixtureOwner = async (
76
+ kindArg: TForgeFixtureKind,
77
+ maxLifetimeMsArg: number,
78
+ ): Promise<IForgeFixtureOwner> => {
79
+ if (!Number.isSafeInteger(maxLifetimeMsArg) || maxLifetimeMsArg < 60_000) {
80
+ throw new TypeError('maxLifetimeMs must be an integer of at least 60000.');
81
+ }
82
+ const identity = await readCurrentProcessIdentity();
83
+ const createdAt = new Date();
84
+ return {
85
+ lifecycleId: plugins.crypto.randomUUID(),
86
+ kind: kindArg,
87
+ ...identity,
88
+ createdAt: createdAt.toISOString(),
89
+ expiresAt: new Date(createdAt.getTime() + maxLifetimeMsArg).toISOString(),
90
+ };
91
+ };
92
+
93
+ /** Labels carried by every Docker resource of one lifecycle. */
94
+ export const forgeFixtureOwnerLabels = (ownerArg: IForgeFixtureOwner): Record<string, string> => ({
95
+ [forgeFixtureLabels.managed]: 'true',
96
+ [forgeFixtureLabels.lifecycleId]: ownerArg.lifecycleId,
97
+ [forgeFixtureLabels.kind]: ownerArg.kind,
98
+ [forgeFixtureLabels.machineId]: ownerArg.machineId,
99
+ [forgeFixtureLabels.hostname]: ownerArg.hostname,
100
+ [forgeFixtureLabels.bootId]: ownerArg.bootId,
101
+ [forgeFixtureLabels.pidNamespace]: ownerArg.pidNamespace,
102
+ [forgeFixtureLabels.pid]: String(ownerArg.pid),
103
+ [forgeFixtureLabels.processStartTicks]: ownerArg.processStartTicks,
104
+ [forgeFixtureLabels.createdAt]: ownerArg.createdAt,
105
+ [forgeFixtureLabels.expiresAt]: ownerArg.expiresAt,
106
+ });
107
+
108
+ const isIsoTimestamp = (valueArg: string | undefined): valueArg is string =>
109
+ valueArg !== undefined && !Number.isNaN(Date.parse(valueArg)) && new Date(valueArg).toISOString() === valueArg;
110
+
111
+ /** Parses ownership labels; returns `undefined` for anything this package did not write. */
112
+ export const parseForgeFixtureOwnerLabels = (
113
+ labelsArg: Record<string, string> | undefined,
114
+ ): IForgeFixtureOwner | undefined => {
115
+ if (!labelsArg || labelsArg[forgeFixtureLabels.managed] !== 'true') return undefined;
116
+ const lifecycleId = labelsArg[forgeFixtureLabels.lifecycleId];
117
+ const kind = labelsArg[forgeFixtureLabels.kind];
118
+ const machineId = labelsArg[forgeFixtureLabels.machineId];
119
+ const hostname = labelsArg[forgeFixtureLabels.hostname];
120
+ const bootId = labelsArg[forgeFixtureLabels.bootId];
121
+ const pidNamespace = labelsArg[forgeFixtureLabels.pidNamespace];
122
+ const pid = labelsArg[forgeFixtureLabels.pid];
123
+ const processStartTicks = labelsArg[forgeFixtureLabels.processStartTicks];
124
+ const createdAt = labelsArg[forgeFixtureLabels.createdAt];
125
+ const expiresAt = labelsArg[forgeFixtureLabels.expiresAt];
126
+ if (
127
+ lifecycleId === undefined || !uuidPattern.test(lifecycleId)
128
+ || (kind !== 'gitea' && kind !== 'gitlab')
129
+ || machineId === undefined || !machineIdPattern.test(machineId)
130
+ || !hostname
131
+ || bootId === undefined || !uuidPattern.test(bootId)
132
+ || pidNamespace === undefined || !pidNamespacePattern.test(pidNamespace)
133
+ || pid === undefined || !decimalPattern.test(pid) || !Number.isSafeInteger(Number(pid))
134
+ || processStartTicks === undefined || !decimalPattern.test(processStartTicks)
135
+ || !isIsoTimestamp(createdAt) || !isIsoTimestamp(expiresAt)
136
+ ) return undefined;
137
+ return {
138
+ lifecycleId, kind, machineId, hostname, bootId, pidNamespace, pid: Number(pid), processStartTicks, createdAt, expiresAt,
139
+ };
140
+ };
141
+
142
+ /**
143
+ * Decides whether a lifecycle is stale. A PID identifies a process only inside
144
+ * its PID namespace: host-network containers sharing the Docker socket share
145
+ * hostname and boot id but not PIDs, and machines sharing a remote daemon can
146
+ * share a host name. Owners on another machine (machine id or host name) or in
147
+ * another PID namespace are therefore judged by expiry only.
148
+ */
149
+ export const classifyForgeFixtureOwner = async (
150
+ ownerArg: IForgeFixtureOwner,
151
+ currentArg: IForgeFixtureProcessIdentity,
152
+ nowArg: Date,
153
+ ): Promise<TForgeFixtureReapReason | 'alive'> => {
154
+ if (Date.parse(ownerArg.expiresAt) <= nowArg.getTime()) return 'expired';
155
+ if (ownerArg.machineId !== currentArg.machineId || ownerArg.hostname !== currentArg.hostname) return 'alive';
156
+ if (ownerArg.bootId !== currentArg.bootId) return 'owner-rebooted';
157
+ if (ownerArg.pidNamespace !== currentArg.pidNamespace) return 'alive';
158
+ const startTicks = await readProcessStartTicks(ownerArg.pid);
159
+ return startTicks === ownerArg.processStartTicks ? 'alive' : 'owner-exited';
160
+ };
package/ts/plugins.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Polyfill required by @peculiar/x509 (through tsyringe). This import must precede its evaluation.
2
+ import 'reflect-metadata';
3
+
4
+ // Node native scope
5
+ import * as crypto from 'node:crypto';
6
+ import * as fs from 'node:fs';
7
+ import * as http from 'node:http';
8
+ import * as https from 'node:https';
9
+ import * as net from 'node:net';
10
+ import * as os from 'node:os';
11
+ import * as tls from 'node:tls';
12
+
13
+ export { crypto, fs, http, https, net, os, tls };
14
+
15
+ // @apiclient.xyz scope
16
+ import * as docker from '@apiclient.xyz/docker';
17
+
18
+ export { docker };
19
+
20
+ // third-party scope
21
+ import * as x509 from '@peculiar/x509';
22
+
23
+ export { x509 };
@@ -0,0 +1,33 @@
1
+ /** Narrow readers for forge JSON responses. Each throws with the field it expected. */
2
+
3
+ export const record = (valueArg: unknown, whatArg: string): Record<string, unknown> => {
4
+ if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) {
5
+ throw new Error(`Expected ${whatArg} to be an object.`);
6
+ }
7
+ return valueArg as Record<string, unknown>;
8
+ };
9
+
10
+ export const array = (valueArg: unknown, whatArg: string): unknown[] => {
11
+ if (!Array.isArray(valueArg)) throw new Error(`Expected ${whatArg} to be an array.`);
12
+ return valueArg;
13
+ };
14
+
15
+ export const integer = (valueArg: unknown, whatArg: string): number => {
16
+ if (typeof valueArg !== 'number' || !Number.isSafeInteger(valueArg)) {
17
+ throw new Error(`Expected ${whatArg} to be a safe integer.`);
18
+ }
19
+ return valueArg;
20
+ };
21
+
22
+ export const text = (valueArg: unknown, whatArg: string): string => {
23
+ if (typeof valueArg !== 'string') throw new Error(`Expected ${whatArg} to be a string.`);
24
+ return valueArg;
25
+ };
26
+
27
+ export const boolean = (valueArg: unknown, whatArg: string): boolean => {
28
+ if (typeof valueArg !== 'boolean') throw new Error(`Expected ${whatArg} to be a boolean.`);
29
+ return valueArg;
30
+ };
31
+
32
+ export const field = (valueArg: unknown, keyArg: string, whatArg: string): unknown =>
33
+ record(valueArg, whatArg)[keyArg];