@crowdsource.you/core 1.2.0 → 1.3.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.
package/README.md CHANGED
@@ -14,12 +14,13 @@ transactional outbox and the test sandbox are entry points of the same package.
14
14
  | `@crowdsource.you/core/outbox/postgres` | The PostgreSQL store behind `/outbox`: table definitions and the store itself. | as above |
15
15
  | `@crowdsource.you/core/testing` | Fixtures, a webhook simulator and an in-process sandbox. | nothing beyond contracts |
16
16
 
17
- **Importing the root pulls in none of `express`, `drizzle-orm`, `postgres` or
18
- `@oxy.so/db`.** They are OPTIONAL peer dependencies, reached only through
19
- `/express` and `/outbox`, so an application that files reports and nothing else
20
- installs a client and a contracts package and stops there. That is the whole
21
- reason the receiver and the outbox are subpaths rather than a second package:
22
- there is no version of this package that can disagree with itself.
17
+ **Importing the root pulls in none of `express`, `drizzle-orm`, `postgres`,
18
+ `@oxy.so/db` or `@oxy.so/core`.** They are OPTIONAL peer dependencies, reached
19
+ only through `/express`, `/outbox` and for `@oxy.so/core` a lazy require
20
+ inside `crowdSourceForOxyService()`, so an application that files reports and
21
+ nothing else installs a client and a contracts package and stops there. That is
22
+ the whole reason the receiver and the outbox are subpaths rather than a second
23
+ package: there is no version of this package that can disagree with itself.
23
24
 
24
25
  `@crowdsource.you/contracts` stays a package of its own, and is a **peer
25
26
  dependency** here, so you declare it and own its version. That is deliberate:
@@ -107,6 +108,7 @@ Anything in that table can be overridden per report. Nothing in it has to be.
107
108
  | --- | --- |
108
109
  | `CROWDSOURCE_SERVICE_KEY` | Required for a third party. The one value an integration configures. An Oxy service sets `oxyToken` instead and configures nothing — see "Oxy's own services" below. |
109
110
  | `CROWDSOURCE_BASE_URL` | Optional. Overrides the service host — set it only to point at a local backend. `http://` is accepted for `localhost` and refused for anything else, because a service credential sent in clear is a credential you have to rotate. |
111
+ | `OXY_SERVICE_API_KEY` / `OXY_SERVICE_API_SECRET` | Read only by `crowdSourceForOxyService()`, and only where the workload cannot attest. An Oxy service already sets them for the rest of its Oxy calls. Both or neither — one alone, or either left blank, reads as no credential. |
110
112
 
111
113
  There is **no** `CROWDSOURCE_APP_ID` and there never will be. The application a
112
114
  report belongs to is read off the credential; see "The service key" below.
@@ -135,14 +137,38 @@ report belongs to is read off the credential; see "The service key" below.
135
137
 
136
138
  Mention, Alia, Homiio and the rest hold no service key. They present the Oxy
137
139
  service token their own infrastructure already issues, and CrowdSource resolves
138
- the tenant from the Oxy application it names:
140
+ the tenant from the Oxy application it names — so there is one call and nothing
141
+ to configure:
139
142
 
140
143
  ```ts
141
- const crowdsource = new CrowdSource({
142
- oxyToken: () => oxyServices.getServiceToken(),
143
- });
144
+ import { crowdSourceForOxyService } from '@crowdsource.you/core';
145
+
146
+ const crowdsource = crowdSourceForOxyService(); // undefined where it cannot authenticate
144
147
  ```
145
148
 
149
+ Three applications each carried their own copy of this: build the client once,
150
+ hand back `undefined` where it cannot be used. None of that was theirs to decide,
151
+ so it lives here now.
152
+
153
+ - **Built once per process.** Later calls return the same client whatever they
154
+ ask for; a second one would re-resolve the same tenant and be told the same
155
+ thing.
156
+ - **`undefined`, never a throw**, where the process can neither attest a workload
157
+ identity (ADR 0026) nor present an `OXY_SERVICE_API_KEY`/`OXY_SERVICE_API_SECRET`
158
+ pair. That is a local checkout, and a report filed there must still be stored —
159
+ the durable row is never gated, and what to do about a missing client is the
160
+ caller's decision.
161
+ - **`oxyToken` defaults to `@oxy.so/core`'s `getServiceToken()`**, which is an
162
+ **optional** peer dependency required lazily on this path only. A third party
163
+ never installs it, and the root import works in a tree without it. Pass your
164
+ own provider and it is used in place of the default.
165
+ - **`baseUrl` and `logger` are the only other options.** Timeouts, retries, the
166
+ idempotency key and the envelope are the client's and are not re-asked here.
167
+ With a logger, the tenant is resolved once in the background and reported, so a
168
+ token minted for an Oxy application nobody bound is visible at boot rather than
169
+ on the first report. Nothing waits on it.
170
+ - **`resetCrowdSourceForOxyService()`** is the test hook.
171
+
146
172
  `oxyToken` is asked once per request attempt, so a cached token refreshed on
147
173
  expiry is the expected shape — which is what `getServiceToken()` returns. With it
148
174
  set, `CROWDSOURCE_SERVICE_KEY` is neither needed nor read, and there is nothing
package/dist/esm/index.js CHANGED
@@ -34,6 +34,12 @@
34
34
  * `Decision` or a `TaxonomyCode` has exactly one definition.
35
35
  */
36
36
  export { CrowdSource, SERVICE_KEY_ENV_VAR, BASE_URL_ENV_VAR } from './client.js';
37
+ /**
38
+ * The first-party shortcut. `@oxy.so/core` is an OPTIONAL peer and is required
39
+ * lazily inside the factory, so this export costs a consumer that never calls it
40
+ * nothing — the root entry point still loads in a tree that has no Oxy SDK.
41
+ */
42
+ export { crowdSourceForOxyService, resetCrowdSourceForOxyService } from './oxyService.js';
37
43
  export { formatServiceKey, parseServiceKey } from './credential.js';
38
44
  export { COMMUNITY_REVIEW_FORBIDDEN_ALLEGATIONS, DEFAULT_BASE_URL, DEFAULT_POLICY, DEFAULT_RETENTION_DAYS, } from './defaults.js';
39
45
  export { canonicalJson, resourceDigest, sha256Digest } from './digest.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAGjF,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAGpE,OAAO,EACL,sCAAsC,EACtC,gBAAgB,EAChB,cAAc,EACd,sBAAsB,GACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG1E;;;;;;;;;GASG;AACH,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAWnF,OAAO,EACL,2BAA2B,EAC3B,mBAAmB,EACnB,6BAA6B,EAC7B,gBAAgB,EAChB,yBAAyB,EACzB,qBAAqB,EACrB,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAGvC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI9C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAUzD,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAGjF;;;;GAIG;AACH,OAAO,EAAE,wBAAwB,EAAE,6BAA6B,EAAE,MAAM,iBAAiB,CAAC;AAG1F,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAGpE,OAAO,EACL,sCAAsC,EACtC,gBAAgB,EAChB,cAAc,EACd,sBAAsB,GACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG1E;;;;;;;;;GASG;AACH,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAWnF,OAAO,EACL,2BAA2B,EAC3B,mBAAmB,EACnB,6BAA6B,EAC7B,gBAAgB,EAChB,yBAAyB,EACzB,qBAAqB,EACrB,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAGvC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI9C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAUzD,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The two things this package borrows from `@oxy.so/core`, loaded only when a
3
+ * first-party Oxy service asks for them.
4
+ *
5
+ * `@oxy.so/core` is an OPTIONAL peer dependency. A third party integrating with
6
+ * CrowdSource holds a service key and must never be made to install Oxy's SDK to
7
+ * get one, so nothing here is imported at module load: the root entry point has
8
+ * to stay loadable in a tree where `@oxy.so/core` is simply absent, and a static
9
+ * import would make `import '@crowdsource.you/core'` throw there — for everyone,
10
+ * over a path almost nobody takes.
11
+ *
12
+ * ## Why `createRequire` and not `import()`
13
+ *
14
+ * `crowdSourceForOxyService()` answers synchronously, because it is called where
15
+ * a module-level `const` is assigned; an application that had to await its
16
+ * client would have to await every module that holds one. A dynamic `import()`
17
+ * is a promise in the ESM half of this build, so the load has to be a require.
18
+ *
19
+ * Resolution is anchored on the APPLICATION's directory rather than on this
20
+ * file. One anchor that works in both halves of a dual build is the reason:
21
+ * `__filename` does not exist in the ESM emit and `import.meta.url` does not
22
+ * compile in the CommonJS one, whereas `process.cwd()` is the same in both. An
23
+ * optional peer belongs to the application anyway — it is the application that
24
+ * declared and installed it.
25
+ *
26
+ * A specifier that does not resolve is not an error here. It is the honest
27
+ * answer to "is this an Oxy service": a process without Oxy's SDK cannot mint an
28
+ * Oxy service token, which is the same answer a local checkout gets.
29
+ */
30
+ import { createRequire } from 'node:module';
31
+ import { join } from 'node:path';
32
+ /** The api key half of an Oxy service credential. */
33
+ export const OXY_SERVICE_API_KEY_ENV_VAR = 'OXY_SERVICE_API_KEY';
34
+ /** Its secret half. Both or neither: one alone authenticates nothing. */
35
+ export const OXY_SERVICE_API_SECRET_ENV_VAR = 'OXY_SERVICE_API_SECRET';
36
+ /**
37
+ * Where to resolve the optional peer FROM, in the order worth trying.
38
+ *
39
+ * The application's directory first: an optional peer belongs to the
40
+ * application, which is what declared and installed it. Then this file, when the
41
+ * running half of the dual build has a `__filename` — the CommonJS one does, the
42
+ * ESM one does not, and `typeof` is what makes asking safe in both. That second
43
+ * anchor is not decoration: a process whose working directory is not its package
44
+ * root resolves nothing from the first, and the answer would be a silent
45
+ * "cannot attest" — a moderation client that is `undefined` for a reason nobody
46
+ * can see, which is precisely the failure this module is supposed to prevent.
47
+ */
48
+ function resolutionAnchors() {
49
+ const anchors = [join(process.cwd(), 'package.json')];
50
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
51
+ if (typeof __filename === 'string')
52
+ anchors.push(__filename);
53
+ return anchors;
54
+ }
55
+ /** Why the last load failed, for a caller that has somewhere to report it. */
56
+ let lastResolutionError = null;
57
+ /**
58
+ * The loader itself, for the one test that has to prove a successful resolution
59
+ * clears the recorded reason. Exported rather than re-implemented in the suite:
60
+ * a test that reimplemented it would pass while this one rotted.
61
+ */
62
+ export function loadOptionalModuleForTest(specifier) {
63
+ return loadOxyModule(specifier);
64
+ }
65
+ /** The reason `@oxy.so/core` could not be loaded, or `null` if it was. */
66
+ export function oxySdkResolutionError() {
67
+ return lastResolutionError;
68
+ }
69
+ function loadOxyModule(specifier) {
70
+ let failure;
71
+ for (const anchor of resolutionAnchors()) {
72
+ try {
73
+ const loaded = createRequire(anchor)(specifier);
74
+ lastResolutionError = null;
75
+ return loaded;
76
+ }
77
+ catch (error) {
78
+ failure = error;
79
+ }
80
+ }
81
+ lastResolutionError = `${specifier}: ${failure instanceof Error ? failure.message : String(failure)}`;
82
+ return null;
83
+ }
84
+ /**
85
+ * Whether this process can prove what it is to Oxy without a secret.
86
+ *
87
+ * In ECS the task role attests — a signed `GetCallerIdentity` that Oxy replays
88
+ * to AWS, with nothing stored anywhere (oxy ADR 0026). A local checkout has no
89
+ * attestation to offer and must fall back to a credential.
90
+ */
91
+ export function canAttestWorkloadIdentity() {
92
+ const server = loadOxyModule('@oxy.so/core/server');
93
+ return server?.canAttestWorkloadIdentity?.() === true;
94
+ }
95
+ /**
96
+ * The service credential pair this process was given, or `null`.
97
+ *
98
+ * Blank is absent. A task definition that declares the variable and leaves it
99
+ * empty is the shape this actually arrives in, and a pair of empty strings
100
+ * treated as present builds a client whose every request fails at the token
101
+ * call — the failure this whole module exists to answer before it happens.
102
+ */
103
+ export function oxyServiceCredentials() {
104
+ const apiKey = process.env[OXY_SERVICE_API_KEY_ENV_VAR]?.trim();
105
+ const apiSecret = process.env[OXY_SERVICE_API_SECRET_ENV_VAR]?.trim();
106
+ if (!apiKey || !apiSecret)
107
+ return null;
108
+ return { apiKey, apiSecret };
109
+ }
110
+ /**
111
+ * A current Oxy service token, minted the way `@oxy.so/core` mints one.
112
+ *
113
+ * The credential pair is passed rather than installed with
114
+ * `configureServiceAuth()`, so this never mutates the SDK's shared client out
115
+ * from under an application that configured it for itself. With no pair,
116
+ * `getServiceToken()` attests instead — the same order the SDK chose
117
+ * deliberately, so that a deployment still holding a credential keeps using it
118
+ * and dropping the two variables is the whole migration.
119
+ *
120
+ * The token is cached and re-minted on expiry INSIDE the SDK, which is why this
121
+ * can be asked once per request attempt.
122
+ */
123
+ export function oxyServiceToken() {
124
+ const core = loadOxyModule('@oxy.so/core');
125
+ const oxyClient = core?.oxyClient;
126
+ if (oxyClient === undefined) {
127
+ return Promise.reject(new Error("@oxy.so/core is not installed, so this process cannot mint an Oxy service token. Install it, or pass an 'oxyToken' provider of your own."));
128
+ }
129
+ const credentials = oxyServiceCredentials();
130
+ return credentials === null
131
+ ? oxyClient.getServiceToken()
132
+ : oxyClient.getServiceToken(credentials.apiKey, credentials.apiSecret);
133
+ }
134
+ //# sourceMappingURL=oxySdk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxySdk.js","sourceRoot":"","sources":["../../src/oxySdk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,qDAAqD;AACrD,MAAM,CAAC,MAAM,2BAA2B,GAAG,qBAAqB,CAAC;AAEjE,yEAAyE;AACzE,MAAM,CAAC,MAAM,8BAA8B,GAAG,wBAAwB,CAAC;AA0BvE;;;;;;;;;;;GAWG;AACH,SAAS,iBAAiB;IACxB,MAAM,OAAO,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC;IACtD,uEAAuE;IACvE,IAAI,OAAO,UAAU,KAAK,QAAQ;QAAE,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAC7D,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,8EAA8E;AAC9E,IAAI,mBAAmB,GAAkB,IAAI,CAAC;AAE9C;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,SAAiB;IACzD,OAAO,aAAa,CAAU,SAAS,CAAC,CAAC;AAC3C,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,qBAAqB;IACnC,OAAO,mBAAmB,CAAC;AAC7B,CAAC;AAED,SAAS,aAAa,CAAI,SAAiB;IACzC,IAAI,OAAgB,CAAC;IACrB,KAAK,MAAM,MAAM,IAAI,iBAAiB,EAAE,EAAE,CAAC;QACzC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,SAAS,CAAM,CAAC;YACrD,mBAAmB,GAAG,IAAI,CAAC;YAC3B,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,OAAO,GAAG,KAAK,CAAC;QAClB,CAAC;IACH,CAAC;IACD,mBAAmB,GAAG,GAAG,SAAS,KAAK,OAAO,YAAY,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;IACtG,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,yBAAyB;IACvC,MAAM,MAAM,GAAG,aAAa,CAAkB,qBAAqB,CAAC,CAAC;IACrE,OAAO,MAAM,EAAE,yBAAyB,EAAE,EAAE,KAAK,IAAI,CAAC;AACxD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB;IACnC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,2BAA2B,CAAC,EAAE,IAAI,EAAE,CAAC;IAChE,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,8BAA8B,CAAC,EAAE,IAAI,EAAE,CAAC;IACtE,IAAI,CAAC,MAAM,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IACvC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AAC/B,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,eAAe;IAC7B,MAAM,IAAI,GAAG,aAAa,CAAgB,cAAc,CAAC,CAAC;IAC1D,MAAM,SAAS,GAAG,IAAI,EAAE,SAAS,CAAC;IAClC,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,OAAO,OAAO,CAAC,MAAM,CACnB,IAAI,KAAK,CACP,0IAA0I,CAC3I,CACF,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG,qBAAqB,EAAE,CAAC;IAC5C,OAAO,WAAW,KAAK,IAAI;QACzB,CAAC,CAAC,SAAS,CAAC,eAAe,EAAE;QAC7B,CAAC,CAAC,SAAS,CAAC,eAAe,CAAC,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;AAC3E,CAAC"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The client a first-party Oxy service gets, with nothing to configure.
3
+ *
4
+ * Three Oxy applications each carried their own copy of this file — Mention at
5
+ * 110 lines, Homiio at 70, Allo at 69, the second being the first with the name
6
+ * swapped. All three answered the same two questions, in the same order, with
7
+ * the same doc comments: build the client once, and hand back `undefined`
8
+ * where it cannot be used. None of that is an application's decision, so none of
9
+ * it belongs in an application.
10
+ *
11
+ * What this deliberately does NOT do is re-answer anything the client already
12
+ * answers. The base URL, the per-attempt deadline, the bounded retries, the
13
+ * idempotency key, the envelope and the error classification are the client's,
14
+ * and a factory that re-exposed them would be a second answer to a question that
15
+ * has one. `baseUrl` is here because a developer pointing at a local backend has
16
+ * nowhere else to say so; everything else is reached by constructing
17
+ * {@link CrowdSource} yourself.
18
+ *
19
+ * A third party does not use this. It holds a CrowdSource service key — it runs
20
+ * where Oxy cannot vouch for it — and `new CrowdSource()` reads that key from
21
+ * the environment.
22
+ */
23
+ import { CrowdSource } from './client.js';
24
+ import { canAttestWorkloadIdentity, oxySdkResolutionError, oxyServiceCredentials, oxyServiceToken, } from './oxySdk.js';
25
+ let client = null;
26
+ let unavailable = null;
27
+ /**
28
+ * Whether this process can act as its Oxy application at all.
29
+ *
30
+ * Two ways, and a deployment has one of them without anybody configuring it: in
31
+ * ECS the task role attests (there is no secret), and elsewhere a service api
32
+ * key pair does. A local checkout has neither, which is the honest answer to "is
33
+ * the integration on here" — and the reason this is not a `CROWDSOURCE_ENABLED`
34
+ * flag. A flag says what somebody typed; this says what the process can do.
35
+ *
36
+ * Asked up front rather than discovered on the first report, and asked even when
37
+ * the caller brought its own `oxyToken`: that provider mints from the same two
38
+ * identities, so a process with neither would hand out a client whose every
39
+ * request fails at the token call.
40
+ */
41
+ function canAuthenticateAsOxyService() {
42
+ return canAttestWorkloadIdentity() || oxyServiceCredentials() !== null;
43
+ }
44
+ /**
45
+ * The client, or `undefined` where this process cannot authenticate as its Oxy
46
+ * application.
47
+ *
48
+ * `undefined` rather than a throw, and this is the property the three copies
49
+ * were written for: a local checkout has no workload identity and no credential
50
+ * pair, and a report filed there must still be STORED. What to do about a
51
+ * missing client is the caller's decision — the outbox row is durable either
52
+ * way, and the delivery worker is what notices there is nowhere to send it.
53
+ *
54
+ * Built once per process, and every later call returns that same instance
55
+ * whatever options it passes. A second client would ask CrowdSource which tenant
56
+ * this token names all over again and be told the same thing.
57
+ *
58
+ * The reason for `undefined` is logged once, not per call, because the
59
+ * alternative is a line per delivery attempt per report — which buries the cause
60
+ * it is meant to reveal.
61
+ */
62
+ export function crowdSourceForOxyService(options = {}) {
63
+ if (client)
64
+ return client;
65
+ if (unavailable !== null)
66
+ return undefined;
67
+ if (!canAuthenticateAsOxyService()) {
68
+ unavailable = 'this process cannot obtain an Oxy service token';
69
+ /**
70
+ * Say WHY when the reason is that Oxy's SDK could not be loaded at all.
71
+ *
72
+ * "Cannot obtain a token" is the right answer for a local checkout and the
73
+ * wrong-looking one for a deployment that has `@oxy.so/core` installed and
74
+ * a task role to attest with: there the cause is a resolution that failed,
75
+ * and without this line an application reads a switched-off integration as
76
+ * intended behaviour.
77
+ */
78
+ const resolution = oxySdkResolutionError();
79
+ options.logger?.info('[CrowdSource] client not built', {
80
+ reason: unavailable,
81
+ ...(resolution === null ? {} : { oxySdk: resolution }),
82
+ });
83
+ return undefined;
84
+ }
85
+ client = new CrowdSource({
86
+ oxyToken: options.oxyToken ?? oxyServiceToken,
87
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
88
+ });
89
+ if (options.logger !== undefined)
90
+ reportTenant(client, options.logger);
91
+ return client;
92
+ }
93
+ /**
94
+ * Resolve the tenant once, in the background, so a missing BINDING is visible at
95
+ * boot rather than on the first report.
96
+ *
97
+ * A token this deployment can mint for an Oxy application nobody bound to a
98
+ * CrowdSource tenant authenticates nothing, and that failure is otherwise
99
+ * indistinguishable from "no reports yet". Nothing waits on it: the client is
100
+ * returned already usable, the client resolves the same promise for its own
101
+ * calls, and a rejection here is a log line rather than a broken boot — which is
102
+ * why the rejection handler is attached in the same expression rather than left
103
+ * to an unhandled-rejection handler somebody else owns.
104
+ *
105
+ * Only with a logger. The resolution exists to be REPORTED; with nowhere to
106
+ * report it, it would be a request at boot whose answer nobody reads, and the
107
+ * client asks the same question on first use anyway.
108
+ */
109
+ function reportTenant(built, logger) {
110
+ void Promise.resolve(built.applicationId).then((applicationId) => {
111
+ logger.info('[CrowdSource] client ready', { applicationId });
112
+ }, (error) => {
113
+ logger.error('[CrowdSource] client built but the tenant did not resolve', {
114
+ reason: error instanceof Error ? error.message : String(error),
115
+ });
116
+ });
117
+ }
118
+ /** Test hook. Production builds the client once and keeps it for the process. */
119
+ export function resetCrowdSourceForOxyService() {
120
+ client = null;
121
+ unavailable = null;
122
+ }
123
+ //# sourceMappingURL=oxyService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxyService.js","sourceRoot":"","sources":["../../src/oxyService.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EACrB,qBAAqB,EACrB,eAAe,GAChB,MAAM,aAAa,CAAC;AA+BrB,IAAI,MAAM,GAAuB,IAAI,CAAC;AACtC,IAAI,WAAW,GAAkB,IAAI,CAAC;AAEtC;;;;;;;;;;;;;GAaG;AACH,SAAS,2BAA2B;IAClC,OAAO,yBAAyB,EAAE,IAAI,qBAAqB,EAAE,KAAK,IAAI,CAAC;AACzE,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,wBAAwB,CACtC,UAAmC,EAAE;IAErC,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAC1B,IAAI,WAAW,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAE3C,IAAI,CAAC,2BAA2B,EAAE,EAAE,CAAC;QACnC,WAAW,GAAG,iDAAiD,CAAC;QAChE;;;;;;;;WAQG;QACH,MAAM,UAAU,GAAG,qBAAqB,EAAE,CAAC;QAC3C,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,gCAAgC,EAAE;YACrD,MAAM,EAAE,WAAW;YACnB,GAAG,CAAC,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;SACvD,CAAC,CAAC;QACH,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,GAAG,IAAI,WAAW,CAAC;QACvB,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,eAAe;QAC7C,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC;KACvE,CAAC,CAAC;IAEH,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;QAAE,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAEvE,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,SAAS,YAAY,CAAC,KAAkB,EAAE,MAA8B;IACtE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,IAAI,CAC5C,CAAC,aAAa,EAAE,EAAE;QAChB,MAAM,CAAC,IAAI,CAAC,4BAA4B,EAAE,EAAE,aAAa,EAAE,CAAC,CAAC;IAC/D,CAAC,EACD,CAAC,KAAc,EAAE,EAAE;QACjB,MAAM,CAAC,KAAK,CAAC,2DAA2D,EAAE;YACxE,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC/D,CAAC,CAAC;IACL,CAAC,CACF,CAAC;AACJ,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,6BAA6B;IAC3C,MAAM,GAAG,IAAI,CAAC;IACd,WAAW,GAAG,IAAI,CAAC;AACrB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -35,6 +35,13 @@
35
35
  */
36
36
  export { CrowdSource, SERVICE_KEY_ENV_VAR, BASE_URL_ENV_VAR } from './client.js';
37
37
  export type { CrowdSourceOptions } from './client.js';
38
+ /**
39
+ * The first-party shortcut. `@oxy.so/core` is an OPTIONAL peer and is required
40
+ * lazily inside the factory, so this export costs a consumer that never calls it
41
+ * nothing — the root entry point still loads in a tree that has no Oxy SDK.
42
+ */
43
+ export { crowdSourceForOxyService, resetCrowdSourceForOxyService } from './oxyService.js';
44
+ export type { OxyServiceClientLogger, OxyServiceClientOptions } from './oxyService.js';
38
45
  export { formatServiceKey, parseServiceKey } from './credential.js';
39
46
  export type { ServiceCredential } from './credential.js';
40
47
  export { COMMUNITY_REVIEW_FORBIDDEN_ALLEGATIONS, DEFAULT_BASE_URL, DEFAULT_POLICY, DEFAULT_RETENTION_DAYS, } from './defaults.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EACL,sCAAsC,EACtC,gBAAgB,EAChB,cAAc,EACd,sBAAsB,GACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC1E,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;;GASG;AACH,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACnF,YAAY,EACV,eAAe,EACf,YAAY,EACZ,WAAW,EACX,cAAc,EACd,WAAW,EACX,kBAAkB,EAClB,aAAa,GACd,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,2BAA2B,EAC3B,mBAAmB,EACnB,6BAA6B,EAC7B,gBAAgB,EAChB,yBAAyB,EACzB,qBAAqB,EACrB,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAE3D,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,YAAY,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAExE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC9C,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAGxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,wBAAwB,EAAE,2BAA2B,EAAE,MAAM,qBAAqB,CAAC;AAEjG,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,YAAY,EACV,4BAA4B,EAC5B,mBAAmB,EACnB,oBAAoB,EACpB,eAAe,EACf,6BAA6B,EAC7B,aAAa,GACd,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC1E,YAAY,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD;;;;GAIG;AACH,OAAO,EAAE,wBAAwB,EAAE,6BAA6B,EAAE,MAAM,iBAAiB,CAAC;AAC1F,YAAY,EAAE,sBAAsB,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAEvF,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EACL,sCAAsC,EACtC,gBAAgB,EAChB,cAAc,EACd,sBAAsB,GACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC1E,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;;GASG;AACH,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACnF,YAAY,EACV,eAAe,EACf,YAAY,EACZ,WAAW,EACX,cAAc,EACd,WAAW,EACX,kBAAkB,EAClB,aAAa,GACd,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,2BAA2B,EAC3B,mBAAmB,EACnB,6BAA6B,EAC7B,gBAAgB,EAChB,yBAAyB,EACzB,qBAAqB,EACrB,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAE3D,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,YAAY,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAExE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC9C,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAGxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,wBAAwB,EAAE,2BAA2B,EAAE,MAAM,qBAAqB,CAAC;AAEjG,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,YAAY,EACV,4BAA4B,EAC5B,mBAAmB,EACnB,oBAAoB,EACpB,eAAe,EACf,6BAA6B,EAC7B,aAAa,GACd,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC1E,YAAY,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -35,11 +35,19 @@
35
35
  * `Decision` or a `TaxonomyCode` has exactly one definition.
36
36
  */
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.WebhookEndpoints = exports.CommunityNotes = exports.Decisions = exports.Cases = exports.Reports = exports.isCrowdSourceError = exports.isCrowdSourceApiErrorCode = exports.isCrowdSourceApiError = exports.CrowdSourceTransportError = exports.CrowdSourceError = exports.CrowdSourceConfigurationError = exports.CrowdSourceApiError = exports.CROWDSOURCE_API_ERROR_CODES = exports.defaultIdempotencyKey = exports.CrowdSourceReportInputError = exports.sha256Digest = exports.resourceDigest = exports.canonicalJson = exports.DEFAULT_RETENTION_DAYS = exports.DEFAULT_POLICY = exports.DEFAULT_BASE_URL = exports.COMMUNITY_REVIEW_FORBIDDEN_ALLEGATIONS = exports.parseServiceKey = exports.formatServiceKey = exports.BASE_URL_ENV_VAR = exports.SERVICE_KEY_ENV_VAR = exports.CrowdSource = void 0;
38
+ exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.WebhookEndpoints = exports.CommunityNotes = exports.Decisions = exports.Cases = exports.Reports = exports.isCrowdSourceError = exports.isCrowdSourceApiErrorCode = exports.isCrowdSourceApiError = exports.CrowdSourceTransportError = exports.CrowdSourceError = exports.CrowdSourceConfigurationError = exports.CrowdSourceApiError = exports.CROWDSOURCE_API_ERROR_CODES = exports.defaultIdempotencyKey = exports.CrowdSourceReportInputError = exports.sha256Digest = exports.resourceDigest = exports.canonicalJson = exports.DEFAULT_RETENTION_DAYS = exports.DEFAULT_POLICY = exports.DEFAULT_BASE_URL = exports.COMMUNITY_REVIEW_FORBIDDEN_ALLEGATIONS = exports.parseServiceKey = exports.formatServiceKey = exports.resetCrowdSourceForOxyService = exports.crowdSourceForOxyService = exports.BASE_URL_ENV_VAR = exports.SERVICE_KEY_ENV_VAR = exports.CrowdSource = void 0;
39
39
  var client_js_1 = require("./client.js");
40
40
  Object.defineProperty(exports, "CrowdSource", { enumerable: true, get: function () { return client_js_1.CrowdSource; } });
41
41
  Object.defineProperty(exports, "SERVICE_KEY_ENV_VAR", { enumerable: true, get: function () { return client_js_1.SERVICE_KEY_ENV_VAR; } });
42
42
  Object.defineProperty(exports, "BASE_URL_ENV_VAR", { enumerable: true, get: function () { return client_js_1.BASE_URL_ENV_VAR; } });
43
+ /**
44
+ * The first-party shortcut. `@oxy.so/core` is an OPTIONAL peer and is required
45
+ * lazily inside the factory, so this export costs a consumer that never calls it
46
+ * nothing — the root entry point still loads in a tree that has no Oxy SDK.
47
+ */
48
+ var oxyService_js_1 = require("./oxyService.js");
49
+ Object.defineProperty(exports, "crowdSourceForOxyService", { enumerable: true, get: function () { return oxyService_js_1.crowdSourceForOxyService; } });
50
+ Object.defineProperty(exports, "resetCrowdSourceForOxyService", { enumerable: true, get: function () { return oxyService_js_1.resetCrowdSourceForOxyService; } });
43
51
  var credential_js_1 = require("./credential.js");
44
52
  Object.defineProperty(exports, "formatServiceKey", { enumerable: true, get: function () { return credential_js_1.formatServiceKey; } });
45
53
  Object.defineProperty(exports, "parseServiceKey", { enumerable: true, get: function () { return credential_js_1.parseServiceKey; } });
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;;;AAEH,yCAAiF;AAAxE,wGAAA,WAAW,OAAA;AAAE,gHAAA,mBAAmB,OAAA;AAAE,6GAAA,gBAAgB,OAAA;AAG3D,iDAAoE;AAA3D,iHAAA,gBAAgB,OAAA;AAAE,gHAAA,eAAe,OAAA;AAG1C,6CAKuB;AAJrB,qIAAA,sCAAsC,OAAA;AACtC,+GAAA,gBAAgB,OAAA;AAChB,6GAAA,cAAc,OAAA;AACd,qHAAA,sBAAsB,OAAA;AAGxB,yCAA0E;AAAjE,0GAAA,aAAa,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,yGAAA,YAAY,OAAA;AAGpD;;;;;;;;;GASG;AACH,6CAAmF;AAA1E,0HAAA,2BAA2B,OAAA;AAAE,oHAAA,qBAAqB,OAAA;AAW3D,yCASqB;AARnB,wHAAA,2BAA2B,OAAA;AAC3B,gHAAA,mBAAmB,OAAA;AACnB,0HAAA,6BAA6B,OAAA;AAC7B,6GAAA,gBAAgB,OAAA;AAChB,sHAAA,yBAAyB,OAAA;AACzB,kHAAA,qBAAqB,OAAA;AACrB,sHAAA,yBAAyB,OAAA;AACzB,+GAAA,kBAAkB,OAAA;AAIpB,2CAAuC;AAA9B,qGAAA,OAAO,OAAA;AAGhB,uCAA8C;AAArC,iGAAA,KAAK,OAAA;AAAE,qGAAA,SAAS,OAAA;AAIzB,yDAAqD;AAA5C,mHAAA,cAAc,OAAA;AAGvB,6DAAyD;AAAhD,uHAAA,gBAAgB,OAAA;AAUzB,+CAA0E;AAAjE,oHAAA,oBAAoB,OAAA;AAAE,kHAAA,kBAAkB,OAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;;;AAEH,yCAAiF;AAAxE,wGAAA,WAAW,OAAA;AAAE,gHAAA,mBAAmB,OAAA;AAAE,6GAAA,gBAAgB,OAAA;AAG3D;;;;GAIG;AACH,iDAA0F;AAAjF,yHAAA,wBAAwB,OAAA;AAAE,8HAAA,6BAA6B,OAAA;AAGhE,iDAAoE;AAA3D,iHAAA,gBAAgB,OAAA;AAAE,gHAAA,eAAe,OAAA;AAG1C,6CAKuB;AAJrB,qIAAA,sCAAsC,OAAA;AACtC,+GAAA,gBAAgB,OAAA;AAChB,6GAAA,cAAc,OAAA;AACd,qHAAA,sBAAsB,OAAA;AAGxB,yCAA0E;AAAjE,0GAAA,aAAa,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,yGAAA,YAAY,OAAA;AAGpD;;;;;;;;;GASG;AACH,6CAAmF;AAA1E,0HAAA,2BAA2B,OAAA;AAAE,oHAAA,qBAAqB,OAAA;AAW3D,yCASqB;AARnB,wHAAA,2BAA2B,OAAA;AAC3B,gHAAA,mBAAmB,OAAA;AACnB,0HAAA,6BAA6B,OAAA;AAC7B,6GAAA,gBAAgB,OAAA;AAChB,sHAAA,yBAAyB,OAAA;AACzB,kHAAA,qBAAqB,OAAA;AACrB,sHAAA,yBAAyB,OAAA;AACzB,+GAAA,kBAAkB,OAAA;AAIpB,2CAAuC;AAA9B,qGAAA,OAAO,OAAA;AAGhB,uCAA8C;AAArC,iGAAA,KAAK,OAAA;AAAE,qGAAA,SAAS,OAAA;AAIzB,yDAAqD;AAA5C,mHAAA,cAAc,OAAA;AAGvB,6DAAyD;AAAhD,uHAAA,gBAAgB,OAAA;AAUzB,+CAA0E;AAAjE,oHAAA,oBAAoB,OAAA;AAAE,kHAAA,kBAAkB,OAAA"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The two things this package borrows from `@oxy.so/core`, loaded only when a
3
+ * first-party Oxy service asks for them.
4
+ *
5
+ * `@oxy.so/core` is an OPTIONAL peer dependency. A third party integrating with
6
+ * CrowdSource holds a service key and must never be made to install Oxy's SDK to
7
+ * get one, so nothing here is imported at module load: the root entry point has
8
+ * to stay loadable in a tree where `@oxy.so/core` is simply absent, and a static
9
+ * import would make `import '@crowdsource.you/core'` throw there — for everyone,
10
+ * over a path almost nobody takes.
11
+ *
12
+ * ## Why `createRequire` and not `import()`
13
+ *
14
+ * `crowdSourceForOxyService()` answers synchronously, because it is called where
15
+ * a module-level `const` is assigned; an application that had to await its
16
+ * client would have to await every module that holds one. A dynamic `import()`
17
+ * is a promise in the ESM half of this build, so the load has to be a require.
18
+ *
19
+ * Resolution is anchored on the APPLICATION's directory rather than on this
20
+ * file. One anchor that works in both halves of a dual build is the reason:
21
+ * `__filename` does not exist in the ESM emit and `import.meta.url` does not
22
+ * compile in the CommonJS one, whereas `process.cwd()` is the same in both. An
23
+ * optional peer belongs to the application anyway — it is the application that
24
+ * declared and installed it.
25
+ *
26
+ * A specifier that does not resolve is not an error here. It is the honest
27
+ * answer to "is this an Oxy service": a process without Oxy's SDK cannot mint an
28
+ * Oxy service token, which is the same answer a local checkout gets.
29
+ */
30
+ /** The api key half of an Oxy service credential. */
31
+ export declare const OXY_SERVICE_API_KEY_ENV_VAR = "OXY_SERVICE_API_KEY";
32
+ /** Its secret half. Both or neither: one alone authenticates nothing. */
33
+ export declare const OXY_SERVICE_API_SECRET_ENV_VAR = "OXY_SERVICE_API_SECRET";
34
+ /**
35
+ * The loader itself, for the one test that has to prove a successful resolution
36
+ * clears the recorded reason. Exported rather than re-implemented in the suite:
37
+ * a test that reimplemented it would pass while this one rotted.
38
+ */
39
+ export declare function loadOptionalModuleForTest(specifier: string): unknown;
40
+ /** The reason `@oxy.so/core` could not be loaded, or `null` if it was. */
41
+ export declare function oxySdkResolutionError(): string | null;
42
+ /**
43
+ * Whether this process can prove what it is to Oxy without a secret.
44
+ *
45
+ * In ECS the task role attests — a signed `GetCallerIdentity` that Oxy replays
46
+ * to AWS, with nothing stored anywhere (oxy ADR 0026). A local checkout has no
47
+ * attestation to offer and must fall back to a credential.
48
+ */
49
+ export declare function canAttestWorkloadIdentity(): boolean;
50
+ /**
51
+ * The service credential pair this process was given, or `null`.
52
+ *
53
+ * Blank is absent. A task definition that declares the variable and leaves it
54
+ * empty is the shape this actually arrives in, and a pair of empty strings
55
+ * treated as present builds a client whose every request fails at the token
56
+ * call — the failure this whole module exists to answer before it happens.
57
+ */
58
+ export declare function oxyServiceCredentials(): {
59
+ apiKey: string;
60
+ apiSecret: string;
61
+ } | null;
62
+ /**
63
+ * A current Oxy service token, minted the way `@oxy.so/core` mints one.
64
+ *
65
+ * The credential pair is passed rather than installed with
66
+ * `configureServiceAuth()`, so this never mutates the SDK's shared client out
67
+ * from under an application that configured it for itself. With no pair,
68
+ * `getServiceToken()` attests instead — the same order the SDK chose
69
+ * deliberately, so that a deployment still holding a credential keeps using it
70
+ * and dropping the two variables is the whole migration.
71
+ *
72
+ * The token is cached and re-minted on expiry INSIDE the SDK, which is why this
73
+ * can be asked once per request attempt.
74
+ */
75
+ export declare function oxyServiceToken(): Promise<string>;
76
+ //# sourceMappingURL=oxySdk.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxySdk.d.ts","sourceRoot":"","sources":["../src/oxySdk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAKH,qDAAqD;AACrD,eAAO,MAAM,2BAA2B,wBAAwB,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,8BAA8B,2BAA2B,CAAC;AAgDvE;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAEpE;AAED,0EAA0E;AAC1E,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,IAAI,CAErD;AAiBD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,OAAO,CAGnD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKpF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC,CAejD"}
package/dist/oxySdk.js ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * The two things this package borrows from `@oxy.so/core`, loaded only when a
4
+ * first-party Oxy service asks for them.
5
+ *
6
+ * `@oxy.so/core` is an OPTIONAL peer dependency. A third party integrating with
7
+ * CrowdSource holds a service key and must never be made to install Oxy's SDK to
8
+ * get one, so nothing here is imported at module load: the root entry point has
9
+ * to stay loadable in a tree where `@oxy.so/core` is simply absent, and a static
10
+ * import would make `import '@crowdsource.you/core'` throw there — for everyone,
11
+ * over a path almost nobody takes.
12
+ *
13
+ * ## Why `createRequire` and not `import()`
14
+ *
15
+ * `crowdSourceForOxyService()` answers synchronously, because it is called where
16
+ * a module-level `const` is assigned; an application that had to await its
17
+ * client would have to await every module that holds one. A dynamic `import()`
18
+ * is a promise in the ESM half of this build, so the load has to be a require.
19
+ *
20
+ * Resolution is anchored on the APPLICATION's directory rather than on this
21
+ * file. One anchor that works in both halves of a dual build is the reason:
22
+ * `__filename` does not exist in the ESM emit and `import.meta.url` does not
23
+ * compile in the CommonJS one, whereas `process.cwd()` is the same in both. An
24
+ * optional peer belongs to the application anyway — it is the application that
25
+ * declared and installed it.
26
+ *
27
+ * A specifier that does not resolve is not an error here. It is the honest
28
+ * answer to "is this an Oxy service": a process without Oxy's SDK cannot mint an
29
+ * Oxy service token, which is the same answer a local checkout gets.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.OXY_SERVICE_API_SECRET_ENV_VAR = exports.OXY_SERVICE_API_KEY_ENV_VAR = void 0;
33
+ exports.loadOptionalModuleForTest = loadOptionalModuleForTest;
34
+ exports.oxySdkResolutionError = oxySdkResolutionError;
35
+ exports.canAttestWorkloadIdentity = canAttestWorkloadIdentity;
36
+ exports.oxyServiceCredentials = oxyServiceCredentials;
37
+ exports.oxyServiceToken = oxyServiceToken;
38
+ const node_module_1 = require("node:module");
39
+ const node_path_1 = require("node:path");
40
+ /** The api key half of an Oxy service credential. */
41
+ exports.OXY_SERVICE_API_KEY_ENV_VAR = 'OXY_SERVICE_API_KEY';
42
+ /** Its secret half. Both or neither: one alone authenticates nothing. */
43
+ exports.OXY_SERVICE_API_SECRET_ENV_VAR = 'OXY_SERVICE_API_SECRET';
44
+ /**
45
+ * Where to resolve the optional peer FROM, in the order worth trying.
46
+ *
47
+ * The application's directory first: an optional peer belongs to the
48
+ * application, which is what declared and installed it. Then this file, when the
49
+ * running half of the dual build has a `__filename` — the CommonJS one does, the
50
+ * ESM one does not, and `typeof` is what makes asking safe in both. That second
51
+ * anchor is not decoration: a process whose working directory is not its package
52
+ * root resolves nothing from the first, and the answer would be a silent
53
+ * "cannot attest" — a moderation client that is `undefined` for a reason nobody
54
+ * can see, which is precisely the failure this module is supposed to prevent.
55
+ */
56
+ function resolutionAnchors() {
57
+ const anchors = [(0, node_path_1.join)(process.cwd(), 'package.json')];
58
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
59
+ if (typeof __filename === 'string')
60
+ anchors.push(__filename);
61
+ return anchors;
62
+ }
63
+ /** Why the last load failed, for a caller that has somewhere to report it. */
64
+ let lastResolutionError = null;
65
+ /**
66
+ * The loader itself, for the one test that has to prove a successful resolution
67
+ * clears the recorded reason. Exported rather than re-implemented in the suite:
68
+ * a test that reimplemented it would pass while this one rotted.
69
+ */
70
+ function loadOptionalModuleForTest(specifier) {
71
+ return loadOxyModule(specifier);
72
+ }
73
+ /** The reason `@oxy.so/core` could not be loaded, or `null` if it was. */
74
+ function oxySdkResolutionError() {
75
+ return lastResolutionError;
76
+ }
77
+ function loadOxyModule(specifier) {
78
+ let failure;
79
+ for (const anchor of resolutionAnchors()) {
80
+ try {
81
+ const loaded = (0, node_module_1.createRequire)(anchor)(specifier);
82
+ lastResolutionError = null;
83
+ return loaded;
84
+ }
85
+ catch (error) {
86
+ failure = error;
87
+ }
88
+ }
89
+ lastResolutionError = `${specifier}: ${failure instanceof Error ? failure.message : String(failure)}`;
90
+ return null;
91
+ }
92
+ /**
93
+ * Whether this process can prove what it is to Oxy without a secret.
94
+ *
95
+ * In ECS the task role attests — a signed `GetCallerIdentity` that Oxy replays
96
+ * to AWS, with nothing stored anywhere (oxy ADR 0026). A local checkout has no
97
+ * attestation to offer and must fall back to a credential.
98
+ */
99
+ function canAttestWorkloadIdentity() {
100
+ const server = loadOxyModule('@oxy.so/core/server');
101
+ return server?.canAttestWorkloadIdentity?.() === true;
102
+ }
103
+ /**
104
+ * The service credential pair this process was given, or `null`.
105
+ *
106
+ * Blank is absent. A task definition that declares the variable and leaves it
107
+ * empty is the shape this actually arrives in, and a pair of empty strings
108
+ * treated as present builds a client whose every request fails at the token
109
+ * call — the failure this whole module exists to answer before it happens.
110
+ */
111
+ function oxyServiceCredentials() {
112
+ const apiKey = process.env[exports.OXY_SERVICE_API_KEY_ENV_VAR]?.trim();
113
+ const apiSecret = process.env[exports.OXY_SERVICE_API_SECRET_ENV_VAR]?.trim();
114
+ if (!apiKey || !apiSecret)
115
+ return null;
116
+ return { apiKey, apiSecret };
117
+ }
118
+ /**
119
+ * A current Oxy service token, minted the way `@oxy.so/core` mints one.
120
+ *
121
+ * The credential pair is passed rather than installed with
122
+ * `configureServiceAuth()`, so this never mutates the SDK's shared client out
123
+ * from under an application that configured it for itself. With no pair,
124
+ * `getServiceToken()` attests instead — the same order the SDK chose
125
+ * deliberately, so that a deployment still holding a credential keeps using it
126
+ * and dropping the two variables is the whole migration.
127
+ *
128
+ * The token is cached and re-minted on expiry INSIDE the SDK, which is why this
129
+ * can be asked once per request attempt.
130
+ */
131
+ function oxyServiceToken() {
132
+ const core = loadOxyModule('@oxy.so/core');
133
+ const oxyClient = core?.oxyClient;
134
+ if (oxyClient === undefined) {
135
+ return Promise.reject(new Error("@oxy.so/core is not installed, so this process cannot mint an Oxy service token. Install it, or pass an 'oxyToken' provider of your own."));
136
+ }
137
+ const credentials = oxyServiceCredentials();
138
+ return credentials === null
139
+ ? oxyClient.getServiceToken()
140
+ : oxyClient.getServiceToken(credentials.apiKey, credentials.apiSecret);
141
+ }
142
+ //# sourceMappingURL=oxySdk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxySdk.js","sourceRoot":"","sources":["../src/oxySdk.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;;;AA8DH,8DAEC;AAGD,sDAEC;AAwBD,8DAGC;AAUD,sDAKC;AAeD,0CAeC;AA3ID,6CAA4C;AAC5C,yCAAiC;AAEjC,qDAAqD;AACxC,QAAA,2BAA2B,GAAG,qBAAqB,CAAC;AAEjE,yEAAyE;AAC5D,QAAA,8BAA8B,GAAG,wBAAwB,CAAC;AA0BvE;;;;;;;;;;;GAWG;AACH,SAAS,iBAAiB;IACxB,MAAM,OAAO,GAAG,CAAC,IAAA,gBAAI,EAAC,OAAO,CAAC,GAAG,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC;IACtD,uEAAuE;IACvE,IAAI,OAAO,UAAU,KAAK,QAAQ;QAAE,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAC7D,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,8EAA8E;AAC9E,IAAI,mBAAmB,GAAkB,IAAI,CAAC;AAE9C;;;;GAIG;AACH,SAAgB,yBAAyB,CAAC,SAAiB;IACzD,OAAO,aAAa,CAAU,SAAS,CAAC,CAAC;AAC3C,CAAC;AAED,0EAA0E;AAC1E,SAAgB,qBAAqB;IACnC,OAAO,mBAAmB,CAAC;AAC7B,CAAC;AAED,SAAS,aAAa,CAAI,SAAiB;IACzC,IAAI,OAAgB,CAAC;IACrB,KAAK,MAAM,MAAM,IAAI,iBAAiB,EAAE,EAAE,CAAC;QACzC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAA,2BAAa,EAAC,MAAM,CAAC,CAAC,SAAS,CAAM,CAAC;YACrD,mBAAmB,GAAG,IAAI,CAAC;YAC3B,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,OAAO,GAAG,KAAK,CAAC;QAClB,CAAC;IACH,CAAC;IACD,mBAAmB,GAAG,GAAG,SAAS,KAAK,OAAO,YAAY,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;IACtG,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,yBAAyB;IACvC,MAAM,MAAM,GAAG,aAAa,CAAkB,qBAAqB,CAAC,CAAC;IACrE,OAAO,MAAM,EAAE,yBAAyB,EAAE,EAAE,KAAK,IAAI,CAAC;AACxD,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,qBAAqB;IACnC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,mCAA2B,CAAC,EAAE,IAAI,EAAE,CAAC;IAChE,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,sCAA8B,CAAC,EAAE,IAAI,EAAE,CAAC;IACtE,IAAI,CAAC,MAAM,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IACvC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AAC/B,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAgB,eAAe;IAC7B,MAAM,IAAI,GAAG,aAAa,CAAgB,cAAc,CAAC,CAAC;IAC1D,MAAM,SAAS,GAAG,IAAI,EAAE,SAAS,CAAC;IAClC,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,OAAO,OAAO,CAAC,MAAM,CACnB,IAAI,KAAK,CACP,0IAA0I,CAC3I,CACF,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG,qBAAqB,EAAE,CAAC;IAC5C,OAAO,WAAW,KAAK,IAAI;QACzB,CAAC,CAAC,SAAS,CAAC,eAAe,EAAE;QAC7B,CAAC,CAAC,SAAS,CAAC,eAAe,CAAC,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;AAC3E,CAAC"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The client a first-party Oxy service gets, with nothing to configure.
3
+ *
4
+ * Three Oxy applications each carried their own copy of this file — Mention at
5
+ * 110 lines, Homiio at 70, Allo at 69, the second being the first with the name
6
+ * swapped. All three answered the same two questions, in the same order, with
7
+ * the same doc comments: build the client once, and hand back `undefined`
8
+ * where it cannot be used. None of that is an application's decision, so none of
9
+ * it belongs in an application.
10
+ *
11
+ * What this deliberately does NOT do is re-answer anything the client already
12
+ * answers. The base URL, the per-attempt deadline, the bounded retries, the
13
+ * idempotency key, the envelope and the error classification are the client's,
14
+ * and a factory that re-exposed them would be a second answer to a question that
15
+ * has one. `baseUrl` is here because a developer pointing at a local backend has
16
+ * nowhere else to say so; everything else is reached by constructing
17
+ * {@link CrowdSource} yourself.
18
+ *
19
+ * A third party does not use this. It holds a CrowdSource service key — it runs
20
+ * where Oxy cannot vouch for it — and `new CrowdSource()` reads that key from
21
+ * the environment.
22
+ */
23
+ import { CrowdSource } from './client.js';
24
+ /**
25
+ * Where this factory reports what it did.
26
+ *
27
+ * Structurally what every Oxy backend's logger already is, so an application
28
+ * passes the one it has. There is no default and no fallback to `console`: a
29
+ * library that writes to stdout on its own is a library that appears in
30
+ * somebody's logs without ever being asked.
31
+ */
32
+ export interface OxyServiceClientLogger {
33
+ info(message: string, context: Record<string, unknown>): void;
34
+ error(message: string, context: Record<string, unknown>): void;
35
+ }
36
+ export interface OxyServiceClientOptions {
37
+ /**
38
+ * A current Oxy service token, asked for once per request attempt.
39
+ *
40
+ * Defaults to `@oxy.so/core`'s `getServiceToken()`, which is what an Oxy
41
+ * service would pass anyway. Set it when the token comes from somewhere else —
42
+ * an SDK instance configured against a non-default Oxy host, say — and it is
43
+ * used in place of the default, never alongside it.
44
+ */
45
+ readonly oxyToken?: () => string | Promise<string>;
46
+ /** Defaults to the client's own. Set it only to point at a local backend. */
47
+ readonly baseUrl?: string;
48
+ /** Defaults to none, which is silence rather than `console`. */
49
+ readonly logger?: OxyServiceClientLogger;
50
+ }
51
+ /**
52
+ * The client, or `undefined` where this process cannot authenticate as its Oxy
53
+ * application.
54
+ *
55
+ * `undefined` rather than a throw, and this is the property the three copies
56
+ * were written for: a local checkout has no workload identity and no credential
57
+ * pair, and a report filed there must still be STORED. What to do about a
58
+ * missing client is the caller's decision — the outbox row is durable either
59
+ * way, and the delivery worker is what notices there is nowhere to send it.
60
+ *
61
+ * Built once per process, and every later call returns that same instance
62
+ * whatever options it passes. A second client would ask CrowdSource which tenant
63
+ * this token names all over again and be told the same thing.
64
+ *
65
+ * The reason for `undefined` is logged once, not per call, because the
66
+ * alternative is a line per delivery attempt per report — which buries the cause
67
+ * it is meant to reveal.
68
+ */
69
+ export declare function crowdSourceForOxyService(options?: OxyServiceClientOptions): CrowdSource | undefined;
70
+ /** Test hook. Production builds the client once and keeps it for the process. */
71
+ export declare function resetCrowdSourceForOxyService(): void;
72
+ //# sourceMappingURL=oxyService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxyService.d.ts","sourceRoot":"","sources":["../src/oxyService.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAQ1C;;;;;;;GAOG;AACH,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC9D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAChE;AAED,MAAM,WAAW,uBAAuB;IACtC;;;;;;;OAOG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,gEAAgE;IAChE,QAAQ,CAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;CAC1C;AAuBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,uBAA4B,GACpC,WAAW,GAAG,SAAS,CA+BzB;AA+BD,iFAAiF;AACjF,wBAAgB,6BAA6B,IAAI,IAAI,CAGpD"}
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * The client a first-party Oxy service gets, with nothing to configure.
4
+ *
5
+ * Three Oxy applications each carried their own copy of this file — Mention at
6
+ * 110 lines, Homiio at 70, Allo at 69, the second being the first with the name
7
+ * swapped. All three answered the same two questions, in the same order, with
8
+ * the same doc comments: build the client once, and hand back `undefined`
9
+ * where it cannot be used. None of that is an application's decision, so none of
10
+ * it belongs in an application.
11
+ *
12
+ * What this deliberately does NOT do is re-answer anything the client already
13
+ * answers. The base URL, the per-attempt deadline, the bounded retries, the
14
+ * idempotency key, the envelope and the error classification are the client's,
15
+ * and a factory that re-exposed them would be a second answer to a question that
16
+ * has one. `baseUrl` is here because a developer pointing at a local backend has
17
+ * nowhere else to say so; everything else is reached by constructing
18
+ * {@link CrowdSource} yourself.
19
+ *
20
+ * A third party does not use this. It holds a CrowdSource service key — it runs
21
+ * where Oxy cannot vouch for it — and `new CrowdSource()` reads that key from
22
+ * the environment.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.crowdSourceForOxyService = crowdSourceForOxyService;
26
+ exports.resetCrowdSourceForOxyService = resetCrowdSourceForOxyService;
27
+ const client_js_1 = require("./client.js");
28
+ const oxySdk_js_1 = require("./oxySdk.js");
29
+ let client = null;
30
+ let unavailable = null;
31
+ /**
32
+ * Whether this process can act as its Oxy application at all.
33
+ *
34
+ * Two ways, and a deployment has one of them without anybody configuring it: in
35
+ * ECS the task role attests (there is no secret), and elsewhere a service api
36
+ * key pair does. A local checkout has neither, which is the honest answer to "is
37
+ * the integration on here" — and the reason this is not a `CROWDSOURCE_ENABLED`
38
+ * flag. A flag says what somebody typed; this says what the process can do.
39
+ *
40
+ * Asked up front rather than discovered on the first report, and asked even when
41
+ * the caller brought its own `oxyToken`: that provider mints from the same two
42
+ * identities, so a process with neither would hand out a client whose every
43
+ * request fails at the token call.
44
+ */
45
+ function canAuthenticateAsOxyService() {
46
+ return (0, oxySdk_js_1.canAttestWorkloadIdentity)() || (0, oxySdk_js_1.oxyServiceCredentials)() !== null;
47
+ }
48
+ /**
49
+ * The client, or `undefined` where this process cannot authenticate as its Oxy
50
+ * application.
51
+ *
52
+ * `undefined` rather than a throw, and this is the property the three copies
53
+ * were written for: a local checkout has no workload identity and no credential
54
+ * pair, and a report filed there must still be STORED. What to do about a
55
+ * missing client is the caller's decision — the outbox row is durable either
56
+ * way, and the delivery worker is what notices there is nowhere to send it.
57
+ *
58
+ * Built once per process, and every later call returns that same instance
59
+ * whatever options it passes. A second client would ask CrowdSource which tenant
60
+ * this token names all over again and be told the same thing.
61
+ *
62
+ * The reason for `undefined` is logged once, not per call, because the
63
+ * alternative is a line per delivery attempt per report — which buries the cause
64
+ * it is meant to reveal.
65
+ */
66
+ function crowdSourceForOxyService(options = {}) {
67
+ if (client)
68
+ return client;
69
+ if (unavailable !== null)
70
+ return undefined;
71
+ if (!canAuthenticateAsOxyService()) {
72
+ unavailable = 'this process cannot obtain an Oxy service token';
73
+ /**
74
+ * Say WHY when the reason is that Oxy's SDK could not be loaded at all.
75
+ *
76
+ * "Cannot obtain a token" is the right answer for a local checkout and the
77
+ * wrong-looking one for a deployment that has `@oxy.so/core` installed and
78
+ * a task role to attest with: there the cause is a resolution that failed,
79
+ * and without this line an application reads a switched-off integration as
80
+ * intended behaviour.
81
+ */
82
+ const resolution = (0, oxySdk_js_1.oxySdkResolutionError)();
83
+ options.logger?.info('[CrowdSource] client not built', {
84
+ reason: unavailable,
85
+ ...(resolution === null ? {} : { oxySdk: resolution }),
86
+ });
87
+ return undefined;
88
+ }
89
+ client = new client_js_1.CrowdSource({
90
+ oxyToken: options.oxyToken ?? oxySdk_js_1.oxyServiceToken,
91
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
92
+ });
93
+ if (options.logger !== undefined)
94
+ reportTenant(client, options.logger);
95
+ return client;
96
+ }
97
+ /**
98
+ * Resolve the tenant once, in the background, so a missing BINDING is visible at
99
+ * boot rather than on the first report.
100
+ *
101
+ * A token this deployment can mint for an Oxy application nobody bound to a
102
+ * CrowdSource tenant authenticates nothing, and that failure is otherwise
103
+ * indistinguishable from "no reports yet". Nothing waits on it: the client is
104
+ * returned already usable, the client resolves the same promise for its own
105
+ * calls, and a rejection here is a log line rather than a broken boot — which is
106
+ * why the rejection handler is attached in the same expression rather than left
107
+ * to an unhandled-rejection handler somebody else owns.
108
+ *
109
+ * Only with a logger. The resolution exists to be REPORTED; with nowhere to
110
+ * report it, it would be a request at boot whose answer nobody reads, and the
111
+ * client asks the same question on first use anyway.
112
+ */
113
+ function reportTenant(built, logger) {
114
+ void Promise.resolve(built.applicationId).then((applicationId) => {
115
+ logger.info('[CrowdSource] client ready', { applicationId });
116
+ }, (error) => {
117
+ logger.error('[CrowdSource] client built but the tenant did not resolve', {
118
+ reason: error instanceof Error ? error.message : String(error),
119
+ });
120
+ });
121
+ }
122
+ /** Test hook. Production builds the client once and keeps it for the process. */
123
+ function resetCrowdSourceForOxyService() {
124
+ client = null;
125
+ unavailable = null;
126
+ }
127
+ //# sourceMappingURL=oxyService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxyService.js","sourceRoot":"","sources":["../src/oxyService.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;;AA8EH,4DAiCC;AAgCD,sEAGC;AAhJD,2CAA0C;AAC1C,2CAKqB;AA+BrB,IAAI,MAAM,GAAuB,IAAI,CAAC;AACtC,IAAI,WAAW,GAAkB,IAAI,CAAC;AAEtC;;;;;;;;;;;;;GAaG;AACH,SAAS,2BAA2B;IAClC,OAAO,IAAA,qCAAyB,GAAE,IAAI,IAAA,iCAAqB,GAAE,KAAK,IAAI,CAAC;AACzE,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,SAAgB,wBAAwB,CACtC,UAAmC,EAAE;IAErC,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAC1B,IAAI,WAAW,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAE3C,IAAI,CAAC,2BAA2B,EAAE,EAAE,CAAC;QACnC,WAAW,GAAG,iDAAiD,CAAC;QAChE;;;;;;;;WAQG;QACH,MAAM,UAAU,GAAG,IAAA,iCAAqB,GAAE,CAAC;QAC3C,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,gCAAgC,EAAE;YACrD,MAAM,EAAE,WAAW;YACnB,GAAG,CAAC,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;SACvD,CAAC,CAAC;QACH,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,GAAG,IAAI,uBAAW,CAAC;QACvB,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,2BAAe;QAC7C,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC;KACvE,CAAC,CAAC;IAEH,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;QAAE,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAEvE,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,SAAS,YAAY,CAAC,KAAkB,EAAE,MAA8B;IACtE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,IAAI,CAC5C,CAAC,aAAa,EAAE,EAAE;QAChB,MAAM,CAAC,IAAI,CAAC,4BAA4B,EAAE,EAAE,aAAa,EAAE,CAAC,CAAC;IAC/D,CAAC,EACD,CAAC,KAAc,EAAE,EAAE;QACjB,MAAM,CAAC,KAAK,CAAC,2DAA2D,EAAE;YACxE,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC/D,CAAC,CAAC;IACL,CAAC,CACF,CAAC;AACJ,CAAC;AAED,iFAAiF;AACjF,SAAgB,6BAA6B;IAC3C,MAAM,GAAG,IAAI,CAAC;IACd,WAAW,GAAG,IAAI,CAAC;AACrB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdsource.you/core",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "The CrowdSource integration: the API client at the root, and the Express webhook receiver, the transactional outbox and the test sandbox behind subpaths",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -105,12 +105,16 @@
105
105
  ],
106
106
  "peerDependencies": {
107
107
  "@crowdsource.you/contracts": "^1.1.0",
108
+ "@oxy.so/core": "^1.6.0",
108
109
  "@oxy.so/db": "^1.0.0",
109
110
  "drizzle-orm": "^0.45.2",
110
111
  "express": ">=4.18.0 <6",
111
112
  "postgres": "^3.4.9"
112
113
  },
113
114
  "peerDependenciesMeta": {
115
+ "@oxy.so/core": {
116
+ "optional": true
117
+ },
114
118
  "@oxy.so/db": {
115
119
  "optional": true
116
120
  },
package/src/index.ts CHANGED
@@ -37,6 +37,14 @@
37
37
  export { CrowdSource, SERVICE_KEY_ENV_VAR, BASE_URL_ENV_VAR } from './client.js';
38
38
  export type { CrowdSourceOptions } from './client.js';
39
39
 
40
+ /**
41
+ * The first-party shortcut. `@oxy.so/core` is an OPTIONAL peer and is required
42
+ * lazily inside the factory, so this export costs a consumer that never calls it
43
+ * nothing — the root entry point still loads in a tree that has no Oxy SDK.
44
+ */
45
+ export { crowdSourceForOxyService, resetCrowdSourceForOxyService } from './oxyService.js';
46
+ export type { OxyServiceClientLogger, OxyServiceClientOptions } from './oxyService.js';
47
+
40
48
  export { formatServiceKey, parseServiceKey } from './credential.js';
41
49
  export type { ServiceCredential } from './credential.js';
42
50
 
package/src/oxySdk.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The two things this package borrows from `@oxy.so/core`, loaded only when a
3
+ * first-party Oxy service asks for them.
4
+ *
5
+ * `@oxy.so/core` is an OPTIONAL peer dependency. A third party integrating with
6
+ * CrowdSource holds a service key and must never be made to install Oxy's SDK to
7
+ * get one, so nothing here is imported at module load: the root entry point has
8
+ * to stay loadable in a tree where `@oxy.so/core` is simply absent, and a static
9
+ * import would make `import '@crowdsource.you/core'` throw there — for everyone,
10
+ * over a path almost nobody takes.
11
+ *
12
+ * ## Why `createRequire` and not `import()`
13
+ *
14
+ * `crowdSourceForOxyService()` answers synchronously, because it is called where
15
+ * a module-level `const` is assigned; an application that had to await its
16
+ * client would have to await every module that holds one. A dynamic `import()`
17
+ * is a promise in the ESM half of this build, so the load has to be a require.
18
+ *
19
+ * Resolution is anchored on the APPLICATION's directory rather than on this
20
+ * file. One anchor that works in both halves of a dual build is the reason:
21
+ * `__filename` does not exist in the ESM emit and `import.meta.url` does not
22
+ * compile in the CommonJS one, whereas `process.cwd()` is the same in both. An
23
+ * optional peer belongs to the application anyway — it is the application that
24
+ * declared and installed it.
25
+ *
26
+ * A specifier that does not resolve is not an error here. It is the honest
27
+ * answer to "is this an Oxy service": a process without Oxy's SDK cannot mint an
28
+ * Oxy service token, which is the same answer a local checkout gets.
29
+ */
30
+
31
+ import { createRequire } from 'node:module';
32
+ import { join } from 'node:path';
33
+
34
+ /** The api key half of an Oxy service credential. */
35
+ export const OXY_SERVICE_API_KEY_ENV_VAR = 'OXY_SERVICE_API_KEY';
36
+
37
+ /** Its secret half. Both or neither: one alone authenticates nothing. */
38
+ export const OXY_SERVICE_API_SECRET_ENV_VAR = 'OXY_SERVICE_API_SECRET';
39
+
40
+ /**
41
+ * `@oxy.so/core/server`, narrowed to the one function this package calls.
42
+ *
43
+ * Declared rather than imported as a type. `import type` from an optional peer
44
+ * compiles only where that peer is installed, so typing this from the package
45
+ * would make `@crowdsource.you/core` fail to build in a tree that deliberately
46
+ * does not have it — including this one.
47
+ *
48
+ * The member is optional because a peer RANGE is advice, not enforcement:
49
+ * `^1.6.0` is what this package asks for, and a tree that resolved an older
50
+ * copy — one with no attestation path at all — has to read as "cannot attest"
51
+ * rather than as a `TypeError` thrown from inside a moderation client.
52
+ */
53
+ interface OxyServerModule {
54
+ readonly canAttestWorkloadIdentity?: () => boolean;
55
+ }
56
+
57
+ /** `@oxy.so/core`, narrowed to the one object this package calls. */
58
+ interface OxyCoreModule {
59
+ readonly oxyClient?: {
60
+ getServiceToken(apiKey?: string, apiSecret?: string): Promise<string>;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Where to resolve the optional peer FROM, in the order worth trying.
66
+ *
67
+ * The application's directory first: an optional peer belongs to the
68
+ * application, which is what declared and installed it. Then this file, when the
69
+ * running half of the dual build has a `__filename` — the CommonJS one does, the
70
+ * ESM one does not, and `typeof` is what makes asking safe in both. That second
71
+ * anchor is not decoration: a process whose working directory is not its package
72
+ * root resolves nothing from the first, and the answer would be a silent
73
+ * "cannot attest" — a moderation client that is `undefined` for a reason nobody
74
+ * can see, which is precisely the failure this module is supposed to prevent.
75
+ */
76
+ function resolutionAnchors(): string[] {
77
+ const anchors = [join(process.cwd(), 'package.json')];
78
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
79
+ if (typeof __filename === 'string') anchors.push(__filename);
80
+ return anchors;
81
+ }
82
+
83
+ /** Why the last load failed, for a caller that has somewhere to report it. */
84
+ let lastResolutionError: string | null = null;
85
+
86
+ /**
87
+ * The loader itself, for the one test that has to prove a successful resolution
88
+ * clears the recorded reason. Exported rather than re-implemented in the suite:
89
+ * a test that reimplemented it would pass while this one rotted.
90
+ */
91
+ export function loadOptionalModuleForTest(specifier: string): unknown {
92
+ return loadOxyModule<unknown>(specifier);
93
+ }
94
+
95
+ /** The reason `@oxy.so/core` could not be loaded, or `null` if it was. */
96
+ export function oxySdkResolutionError(): string | null {
97
+ return lastResolutionError;
98
+ }
99
+
100
+ function loadOxyModule<T>(specifier: string): T | null {
101
+ let failure: unknown;
102
+ for (const anchor of resolutionAnchors()) {
103
+ try {
104
+ const loaded = createRequire(anchor)(specifier) as T;
105
+ lastResolutionError = null;
106
+ return loaded;
107
+ } catch (error: unknown) {
108
+ failure = error;
109
+ }
110
+ }
111
+ lastResolutionError = `${specifier}: ${failure instanceof Error ? failure.message : String(failure)}`;
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Whether this process can prove what it is to Oxy without a secret.
117
+ *
118
+ * In ECS the task role attests — a signed `GetCallerIdentity` that Oxy replays
119
+ * to AWS, with nothing stored anywhere (oxy ADR 0026). A local checkout has no
120
+ * attestation to offer and must fall back to a credential.
121
+ */
122
+ export function canAttestWorkloadIdentity(): boolean {
123
+ const server = loadOxyModule<OxyServerModule>('@oxy.so/core/server');
124
+ return server?.canAttestWorkloadIdentity?.() === true;
125
+ }
126
+
127
+ /**
128
+ * The service credential pair this process was given, or `null`.
129
+ *
130
+ * Blank is absent. A task definition that declares the variable and leaves it
131
+ * empty is the shape this actually arrives in, and a pair of empty strings
132
+ * treated as present builds a client whose every request fails at the token
133
+ * call — the failure this whole module exists to answer before it happens.
134
+ */
135
+ export function oxyServiceCredentials(): { apiKey: string; apiSecret: string } | null {
136
+ const apiKey = process.env[OXY_SERVICE_API_KEY_ENV_VAR]?.trim();
137
+ const apiSecret = process.env[OXY_SERVICE_API_SECRET_ENV_VAR]?.trim();
138
+ if (!apiKey || !apiSecret) return null;
139
+ return { apiKey, apiSecret };
140
+ }
141
+
142
+ /**
143
+ * A current Oxy service token, minted the way `@oxy.so/core` mints one.
144
+ *
145
+ * The credential pair is passed rather than installed with
146
+ * `configureServiceAuth()`, so this never mutates the SDK's shared client out
147
+ * from under an application that configured it for itself. With no pair,
148
+ * `getServiceToken()` attests instead — the same order the SDK chose
149
+ * deliberately, so that a deployment still holding a credential keeps using it
150
+ * and dropping the two variables is the whole migration.
151
+ *
152
+ * The token is cached and re-minted on expiry INSIDE the SDK, which is why this
153
+ * can be asked once per request attempt.
154
+ */
155
+ export function oxyServiceToken(): Promise<string> {
156
+ const core = loadOxyModule<OxyCoreModule>('@oxy.so/core');
157
+ const oxyClient = core?.oxyClient;
158
+ if (oxyClient === undefined) {
159
+ return Promise.reject(
160
+ new Error(
161
+ "@oxy.so/core is not installed, so this process cannot mint an Oxy service token. Install it, or pass an 'oxyToken' provider of your own.",
162
+ ),
163
+ );
164
+ }
165
+
166
+ const credentials = oxyServiceCredentials();
167
+ return credentials === null
168
+ ? oxyClient.getServiceToken()
169
+ : oxyClient.getServiceToken(credentials.apiKey, credentials.apiSecret);
170
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The client a first-party Oxy service gets, with nothing to configure.
3
+ *
4
+ * Three Oxy applications each carried their own copy of this file — Mention at
5
+ * 110 lines, Homiio at 70, Allo at 69, the second being the first with the name
6
+ * swapped. All three answered the same two questions, in the same order, with
7
+ * the same doc comments: build the client once, and hand back `undefined`
8
+ * where it cannot be used. None of that is an application's decision, so none of
9
+ * it belongs in an application.
10
+ *
11
+ * What this deliberately does NOT do is re-answer anything the client already
12
+ * answers. The base URL, the per-attempt deadline, the bounded retries, the
13
+ * idempotency key, the envelope and the error classification are the client's,
14
+ * and a factory that re-exposed them would be a second answer to a question that
15
+ * has one. `baseUrl` is here because a developer pointing at a local backend has
16
+ * nowhere else to say so; everything else is reached by constructing
17
+ * {@link CrowdSource} yourself.
18
+ *
19
+ * A third party does not use this. It holds a CrowdSource service key — it runs
20
+ * where Oxy cannot vouch for it — and `new CrowdSource()` reads that key from
21
+ * the environment.
22
+ */
23
+
24
+ import { CrowdSource } from './client.js';
25
+ import {
26
+ canAttestWorkloadIdentity,
27
+ oxySdkResolutionError,
28
+ oxyServiceCredentials,
29
+ oxyServiceToken,
30
+ } from './oxySdk.js';
31
+
32
+ /**
33
+ * Where this factory reports what it did.
34
+ *
35
+ * Structurally what every Oxy backend's logger already is, so an application
36
+ * passes the one it has. There is no default and no fallback to `console`: a
37
+ * library that writes to stdout on its own is a library that appears in
38
+ * somebody's logs without ever being asked.
39
+ */
40
+ export interface OxyServiceClientLogger {
41
+ info(message: string, context: Record<string, unknown>): void;
42
+ error(message: string, context: Record<string, unknown>): void;
43
+ }
44
+
45
+ export interface OxyServiceClientOptions {
46
+ /**
47
+ * A current Oxy service token, asked for once per request attempt.
48
+ *
49
+ * Defaults to `@oxy.so/core`'s `getServiceToken()`, which is what an Oxy
50
+ * service would pass anyway. Set it when the token comes from somewhere else —
51
+ * an SDK instance configured against a non-default Oxy host, say — and it is
52
+ * used in place of the default, never alongside it.
53
+ */
54
+ readonly oxyToken?: () => string | Promise<string>;
55
+ /** Defaults to the client's own. Set it only to point at a local backend. */
56
+ readonly baseUrl?: string;
57
+ /** Defaults to none, which is silence rather than `console`. */
58
+ readonly logger?: OxyServiceClientLogger;
59
+ }
60
+
61
+ let client: CrowdSource | null = null;
62
+ let unavailable: string | null = null;
63
+
64
+ /**
65
+ * Whether this process can act as its Oxy application at all.
66
+ *
67
+ * Two ways, and a deployment has one of them without anybody configuring it: in
68
+ * ECS the task role attests (there is no secret), and elsewhere a service api
69
+ * key pair does. A local checkout has neither, which is the honest answer to "is
70
+ * the integration on here" — and the reason this is not a `CROWDSOURCE_ENABLED`
71
+ * flag. A flag says what somebody typed; this says what the process can do.
72
+ *
73
+ * Asked up front rather than discovered on the first report, and asked even when
74
+ * the caller brought its own `oxyToken`: that provider mints from the same two
75
+ * identities, so a process with neither would hand out a client whose every
76
+ * request fails at the token call.
77
+ */
78
+ function canAuthenticateAsOxyService(): boolean {
79
+ return canAttestWorkloadIdentity() || oxyServiceCredentials() !== null;
80
+ }
81
+
82
+ /**
83
+ * The client, or `undefined` where this process cannot authenticate as its Oxy
84
+ * application.
85
+ *
86
+ * `undefined` rather than a throw, and this is the property the three copies
87
+ * were written for: a local checkout has no workload identity and no credential
88
+ * pair, and a report filed there must still be STORED. What to do about a
89
+ * missing client is the caller's decision — the outbox row is durable either
90
+ * way, and the delivery worker is what notices there is nowhere to send it.
91
+ *
92
+ * Built once per process, and every later call returns that same instance
93
+ * whatever options it passes. A second client would ask CrowdSource which tenant
94
+ * this token names all over again and be told the same thing.
95
+ *
96
+ * The reason for `undefined` is logged once, not per call, because the
97
+ * alternative is a line per delivery attempt per report — which buries the cause
98
+ * it is meant to reveal.
99
+ */
100
+ export function crowdSourceForOxyService(
101
+ options: OxyServiceClientOptions = {},
102
+ ): CrowdSource | undefined {
103
+ if (client) return client;
104
+ if (unavailable !== null) return undefined;
105
+
106
+ if (!canAuthenticateAsOxyService()) {
107
+ unavailable = 'this process cannot obtain an Oxy service token';
108
+ /**
109
+ * Say WHY when the reason is that Oxy's SDK could not be loaded at all.
110
+ *
111
+ * "Cannot obtain a token" is the right answer for a local checkout and the
112
+ * wrong-looking one for a deployment that has `@oxy.so/core` installed and
113
+ * a task role to attest with: there the cause is a resolution that failed,
114
+ * and without this line an application reads a switched-off integration as
115
+ * intended behaviour.
116
+ */
117
+ const resolution = oxySdkResolutionError();
118
+ options.logger?.info('[CrowdSource] client not built', {
119
+ reason: unavailable,
120
+ ...(resolution === null ? {} : { oxySdk: resolution }),
121
+ });
122
+ return undefined;
123
+ }
124
+
125
+ client = new CrowdSource({
126
+ oxyToken: options.oxyToken ?? oxyServiceToken,
127
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
128
+ });
129
+
130
+ if (options.logger !== undefined) reportTenant(client, options.logger);
131
+
132
+ return client;
133
+ }
134
+
135
+ /**
136
+ * Resolve the tenant once, in the background, so a missing BINDING is visible at
137
+ * boot rather than on the first report.
138
+ *
139
+ * A token this deployment can mint for an Oxy application nobody bound to a
140
+ * CrowdSource tenant authenticates nothing, and that failure is otherwise
141
+ * indistinguishable from "no reports yet". Nothing waits on it: the client is
142
+ * returned already usable, the client resolves the same promise for its own
143
+ * calls, and a rejection here is a log line rather than a broken boot — which is
144
+ * why the rejection handler is attached in the same expression rather than left
145
+ * to an unhandled-rejection handler somebody else owns.
146
+ *
147
+ * Only with a logger. The resolution exists to be REPORTED; with nowhere to
148
+ * report it, it would be a request at boot whose answer nobody reads, and the
149
+ * client asks the same question on first use anyway.
150
+ */
151
+ function reportTenant(built: CrowdSource, logger: OxyServiceClientLogger): void {
152
+ void Promise.resolve(built.applicationId).then(
153
+ (applicationId) => {
154
+ logger.info('[CrowdSource] client ready', { applicationId });
155
+ },
156
+ (error: unknown) => {
157
+ logger.error('[CrowdSource] client built but the tenant did not resolve', {
158
+ reason: error instanceof Error ? error.message : String(error),
159
+ });
160
+ },
161
+ );
162
+ }
163
+
164
+ /** Test hook. Production builds the client once and keeps it for the process. */
165
+ export function resetCrowdSourceForOxyService(): void {
166
+ client = null;
167
+ unavailable = null;
168
+ }