@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 +36 -10
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/oxySdk.js +134 -0
- package/dist/esm/oxySdk.js.map +1 -0
- package/dist/esm/oxyService.js +123 -0
- package/dist/esm/oxyService.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/oxySdk.d.ts +76 -0
- package/dist/oxySdk.d.ts.map +1 -0
- package/dist/oxySdk.js +142 -0
- package/dist/oxySdk.js.map +1 -0
- package/dist/oxyService.d.ts +72 -0
- package/dist/oxyService.d.ts.map +1 -0
- package/dist/oxyService.js +127 -0
- package/dist/oxyService.js.map +1 -0
- package/package.json +5 -1
- package/src/index.ts +8 -0
- package/src/oxySdk.ts +170 -0
- package/src/oxyService.ts +168 -0
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
|
|
18
|
-
`@oxy.so/db`.** They are OPTIONAL peer dependencies, reached
|
|
19
|
-
`/express
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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';
|
package/dist/esm/index.js.map
CHANGED
|
@@ -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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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"}
|
package/dist/oxySdk.d.ts
ADDED
|
@@ -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.
|
|
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
|
+
}
|