@nxgt/janus-telemetry 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @nxgt/janus-telemetry
2
+
3
+ [`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus) on
4
+ [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry): a span per
5
+ flow and per permission check, and the security events an audit reads — who
6
+ signed up, in and out, why a sign-in was refused, which users were deleted or
7
+ deactivated, whose password changed, and who was granted what. **Never a
8
+ login, an e-mail, a password, a session token, a one-time token or a
9
+ session id.**
10
+
11
+ ```ts
12
+ import { janus } from '@nxgt/janus';
13
+ import { permissions } from '@nxgt/janus/permissions';
14
+ import { instrumentJanus, instrumentPermissions } from '@nxgt/janus-telemetry';
15
+
16
+ export const auth = instrumentJanus(janus({ users, hasher, ...mongo }));
17
+ export const access = instrumentPermissions(permissions({ model, store: mongo.relations }));
18
+ ```
19
+
20
+ The instances answer exactly as before, with the same types; everything they
21
+ do now shows up in the telemetry you installed —
22
+ `createTelemetry(…).install()`, or `@nxgt/telemetry-hono`'s server span, which
23
+ the flows' spans nest under.
24
+
25
+ > **0.x.** A minor version may still change the surface; the changelog says how.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ bun add @nxgt/janus-telemetry @nxgt/janus @nxgt/telemetry
31
+ bun add -d typescript
32
+ ```
33
+
34
+ Every peer is required: `@nxgt/janus`, `@nxgt/telemetry` (0.2.1 or later) and
35
+ `typescript` (6). `@nxgt/janus` and `@nxgt/telemetry` are **peers**: one copy of `@nxgt/janus`, so
36
+ `instanceof JanusError` holds, and one of `@nxgt/telemetry`, so there is one
37
+ current span. Like `@nxgt/janus`, it expects `"moduleResolution": "bundler"`.
38
+
39
+ ## API
40
+
41
+ | Export | What it is |
42
+ | --- | --- |
43
+ | `instrumentJanus(auth)` | The same `janus()` instance, frozen, with every flow traced under its call path — `auth.signIn` as `janus.signIn`, `auth.patient.signIn` as `janus.patient.signIn` — and the security events written as logs. `cookie` stays synchronous and untraced |
44
+ | `instrumentPermissions(access)` | The same `permissions()` instance, frozen, with `can`, `list`, `grant` and `revoke` traced, and one audit event per tuple written |
45
+ | `JanusLike`, `PermissionsLike` | What each accepts: `instrumentJanus(access)` and `instrumentPermissions(auth)` do not compile |
46
+
47
+ ## What is written
48
+
49
+ ### Spans
50
+
51
+ | Span | Attributes |
52
+ | --- | --- |
53
+ | `janus.<flow>`, `janus.<type>.<flow>` | `janus.user.type`; `user.id` once the answer names a user; `janus.session.renewed` for `authenticate` |
54
+ | `janus.can` | `janus.subject.type`, `janus.subject.id`, `janus.permission`, `janus.object.type`, `janus.object.id`, and the answer, `janus.allowed` |
55
+ | `janus.list` | the subject, `janus.permission`, `janus.object.type`, and `janus.page.items`, how many it found |
56
+ | `janus.grant`, `janus.revoke` | the object, `janus.relation`, and the subject — `janus.subject.relation` for a subject set |
57
+
58
+ **A refusal is an answer, not a failure.** A wrong password, a taken login, a
59
+ spent token, a denial: the span is `ok`, and a refusal carries
60
+ `janus.refusal`, its code. A failure — `STORE_FAILED`, `UNSUPPORTED`,
61
+ `PERMISSION_DEPTH`, `HASH_UNSUPPORTED`, or anything that is not a `JanusError`
62
+ — fails the span, with `janus.error.code`, and `janus.store.slot` and
63
+ `janus.store.operation` naming the store that could not answer.
64
+
65
+ ### Events
66
+
67
+ Logs from the source `@nxgt/janus`, each with `janus.user.type` and `user.id`
68
+ when the flow knows them — `janus.signOut` carries neither:
69
+
70
+ | Event | Severity | When |
71
+ | --- | --- | --- |
72
+ | `janus.signUp` | info | a user signed up |
73
+ | `janus.signIn` | info | a user signed in |
74
+ | `janus.signIn.refused` | **warn** | a sign-in was refused, with `janus.refusal` — `CREDENTIALS_INVALID` with `janus.refusal.reason` (`unknownLogin`, `noPassword`, `wrongPassword`), or `USER_INACTIVE` with the `user.id` of the deactivated user |
75
+ | `janus.signOut` | info | a session was signed out |
76
+ | `janus.signOutEverywhere` | info | every session of a user was revoked |
77
+ | `janus.user.deleted` | info | a user was deleted |
78
+ | `janus.user.activated`, `janus.user.deactivated` | info | `setActive` |
79
+ | `janus.password.changed`, `janus.password.set`, `janus.password.reset` | info | the user's change, an admin's, a reset |
80
+ | `janus.email.verified` | info | an e-mail was confirmed |
81
+ | `janus.tuple.granted`, `janus.tuple.revoked` | info | a tuple was written, with the object, relation and subject |
82
+
83
+ ## Traps
84
+
85
+ - **Instrument the instance you hand out.** Wrap `janus()` and
86
+ `permissions()` where they are created, and export the wrapped ones: a
87
+ route given the unwrapped instance is not traced.
88
+ - **The instances are copies, frozen.** `instrumentJanus(auth) !== auth`, and
89
+ nothing can be added to it. Everything `janus()` answered is there.
90
+ - **Nothing is written without a telemetry.** With none installed, the spans
91
+ and events go nowhere, as `@nxgt/telemetry` does everywhere: the flows still
92
+ answer.
93
+ - **A refused sign-in for bad credentials names no user.** `janus.signIn.refused`
94
+ with `CREDENTIALS_INVALID` carries the reason and the user type, never the
95
+ login that was tried: rate limiting per login is the application's, from the
96
+ request. Only `USER_INACTIVE` — the password was right, the user is
97
+ deactivated — carries `user.id`.
98
+ - **One copy of `@nxgt/janus`.** A refusal is told from a failure by
99
+ `instanceof JanusError`: with a second copy installed, every wrong password
100
+ fails its span.
101
+
102
+ ## Documentation
103
+
104
+ - [Tracing Janus](docs/guide/tracing.md) — wiring it with a Hono app, reading the spans and events, the audit trail
105
+ - [Troubleshooting](docs/troubleshooting.md) — what you see, why, and the fix
106
+ - [Roadmap](docs/roadmap.md) — what is next, and what is not planned
107
+
108
+ ## Licence
109
+
110
+ MIT
@@ -0,0 +1,24 @@
1
+ /** What `janus()` answers, as far as it is read: its user types and its flows. */
2
+ export interface JanusLike {
3
+ readonly types: readonly string[];
4
+ readonly authenticate: (...args: never[]) => Promise<unknown>;
5
+ }
6
+ /**
7
+ * The same `janus()` instance, with every flow traced — a span per call,
8
+ * named `janus.signIn` or `janus.patient.signIn` — and the events a security
9
+ * review reads written as logs: sign-ups, sign-ins and the reason one was
10
+ * refused, sign-outs, deleted and deactivated users, changed passwords.
11
+ *
12
+ * ```ts
13
+ * const auth = instrumentJanus(janus({ users, store, hasher }));
14
+ * ```
15
+ *
16
+ * **A refusal is an answer**: a wrong password leaves its span `ok`, with
17
+ * `janus.refusal` set, and is a `janus.signIn.refused` warning — never a
18
+ * failed span. A store that cannot answer is one, with `janus.store.slot`.
19
+ *
20
+ * Nothing written carries a login, an e-mail, a password, a token or a
21
+ * session id: user types, user ids, codes and reasons only.
22
+ */
23
+ export declare function instrumentJanus<A extends JanusLike>(auth: A): A;
24
+ //# sourceMappingURL=flows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flows.d.ts","sourceRoot":"","sources":["../src/flows.ts"],"names":[],"mappings":"AA6BA,kFAAkF;AAClF,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9D;AA2GD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,SAAS,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,CAQ/D"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@nxgt/janus-telemetry` — `@nxgt/janus` on `@nxgt/telemetry`.
3
+ *
4
+ * - `instrumentJanus(auth)` — a span per flow, and the security events worth
5
+ * an audit trail: sign-ups, sign-ins and why one was refused, sign-outs,
6
+ * deleted and deactivated users, changed passwords;
7
+ * - `instrumentPermissions(access)` — a span per `can` and `list`, and an event
8
+ * per tuple granted or revoked.
9
+ *
10
+ * A refusal is an answer, never a failed span; nothing written carries a
11
+ * login, an e-mail, a password or a token.
12
+ */
13
+ export { instrumentJanus, type JanusLike } from './flows';
14
+ export { instrumentPermissions, type PermissionsLike } from './permissions';
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,eAAe,EAAE,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAE,qBAAqB,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,286 @@
1
+ // src/flows.ts
2
+ import { createLogger, event } from "@nxgt/telemetry";
3
+
4
+ // src/traced.ts
5
+ import { JanusError } from "@nxgt/janus";
6
+ import { span } from "@nxgt/telemetry";
7
+ function kindOf(code) {
8
+ switch (code) {
9
+ case "STORE_FAILED":
10
+ case "UNSUPPORTED":
11
+ case "PERMISSION_DEPTH":
12
+ case "HASH_UNSUPPORTED":
13
+ return "failure";
14
+ case "NOT_FOUND":
15
+ case "LOGIN_TAKEN":
16
+ case "VERSION_CONFLICT":
17
+ case "USER_INVALID":
18
+ case "PASSWORD_TOO_SHORT":
19
+ case "CREDENTIALS_INVALID":
20
+ case "USER_INACTIVE":
21
+ case "TOKEN_UNKNOWN":
22
+ case "TOKEN_SPENT":
23
+ case "TOKEN_EXPIRED":
24
+ case "TOKEN_STALE":
25
+ case "INVALID_CURSOR":
26
+ return "refusal";
27
+ }
28
+ }
29
+ function isRefusal(error) {
30
+ return error instanceof JanusError && kindOf(error.code) === "refusal";
31
+ }
32
+ function fieldsOf(fields) {
33
+ const kept = {};
34
+ for (const [key, value] of Object.entries(fields)) {
35
+ if (value !== undefined)
36
+ kept[key] = value;
37
+ }
38
+ return kept;
39
+ }
40
+ function idOf(value) {
41
+ if (typeof value === "string")
42
+ return value;
43
+ if (typeof value === "object" && value !== null && "id" in value) {
44
+ const id = value.id;
45
+ if (typeof id === "string")
46
+ return id;
47
+ }
48
+ return;
49
+ }
50
+ function typeOf(value) {
51
+ if (typeof value === "object" && value !== null && "type" in value) {
52
+ const type = value.type;
53
+ if (typeof type === "string")
54
+ return type;
55
+ }
56
+ return;
57
+ }
58
+ async function traced(name, fields, call, after) {
59
+ const ended = await span(name, { attributes: fields }, async (scope) => {
60
+ let outcome;
61
+ try {
62
+ outcome = { ok: true, value: await call() };
63
+ } catch (error) {
64
+ if (error instanceof JanusError) {
65
+ scope.attribute("janus.error.code", error.code);
66
+ if (error.slot !== undefined)
67
+ scope.attribute("janus.store.slot", error.slot);
68
+ if (error.operation !== undefined) {
69
+ scope.attribute("janus.store.operation", error.operation);
70
+ }
71
+ }
72
+ if (!isRefusal(error))
73
+ throw error;
74
+ scope.attribute("janus.refusal", error.code);
75
+ outcome = { ok: false, refusal: error };
76
+ }
77
+ try {
78
+ after(scope, outcome);
79
+ } catch {}
80
+ return outcome;
81
+ });
82
+ if (!ended.ok)
83
+ throw ended.refusal;
84
+ return ended.value;
85
+ }
86
+
87
+ // src/flows.ts
88
+ var log = createLogger("@nxgt/janus");
89
+ var events = {
90
+ signedUp: event("janus.signUp"),
91
+ signedIn: event("janus.signIn"),
92
+ signInRefused: event("janus.signIn.refused"),
93
+ signedOut: event("janus.signOut"),
94
+ signedOutEverywhere: event("janus.signOutEverywhere"),
95
+ userDeleted: event("janus.user.deleted"),
96
+ userActivated: event("janus.user.activated"),
97
+ userDeactivated: event("janus.user.deactivated"),
98
+ passwordChanged: event("janus.password.changed"),
99
+ passwordSet: event("janus.password.set"),
100
+ passwordReset: event("janus.password.reset"),
101
+ emailVerified: event("janus.email.verified")
102
+ };
103
+ var WRITTEN = {
104
+ signUp: (call, outcome) => {
105
+ if (outcome.ok)
106
+ log.info(events.signedUp(userFields(call, outcome.value)));
107
+ },
108
+ signIn: (call, outcome) => {
109
+ if (outcome.ok) {
110
+ log.info(events.signedIn(userFields(call, outcome.value)));
111
+ } else {
112
+ log.warn(events.signInRefused(refusalFields(call, outcome.refusal)));
113
+ }
114
+ },
115
+ signOut: (_call, outcome) => {
116
+ if (outcome.ok && outcome.value === true)
117
+ log.info(events.signedOut());
118
+ },
119
+ signOutEverywhere: (call, outcome) => {
120
+ if (outcome.ok)
121
+ log.info(events.signedOutEverywhere(argumentUser(call)));
122
+ },
123
+ delete: (call, outcome) => {
124
+ if (outcome.ok && outcome.value === true) {
125
+ log.info(events.userDeleted(argumentUser(call)));
126
+ }
127
+ },
128
+ setActive: (call, outcome) => {
129
+ if (!outcome.ok)
130
+ return;
131
+ const written = call.args[1] === true ? events.userActivated : events.userDeactivated;
132
+ log.info(written(argumentUser(call)));
133
+ },
134
+ changePassword: (call, outcome) => {
135
+ if (outcome.ok)
136
+ log.info(events.passwordChanged(argumentUser(call)));
137
+ },
138
+ setPassword: (call, outcome) => {
139
+ if (outcome.ok)
140
+ log.info(events.passwordSet(argumentUser(call)));
141
+ },
142
+ "resetPassword.confirm": (call, outcome) => {
143
+ if (outcome.ok)
144
+ log.info(events.passwordReset(userFields(call, outcome.value)));
145
+ },
146
+ "verifyEmail.confirm": (call, outcome) => {
147
+ if (outcome.ok)
148
+ log.info(events.emailVerified(userFields(call, outcome.value)));
149
+ }
150
+ };
151
+ function userFields(call, value) {
152
+ const user = typeof value === "object" && value !== null && "user" in value ? value.user : value;
153
+ return fieldsOf({
154
+ "janus.user.type": typeOf(user) ?? call.userType,
155
+ "user.id": idOf(user)
156
+ });
157
+ }
158
+ function argumentUser(call) {
159
+ return fieldsOf({
160
+ "janus.user.type": call.userType,
161
+ "user.id": idOf(call.args[0])
162
+ });
163
+ }
164
+ function refusalFields(call, refusal) {
165
+ return fieldsOf({
166
+ "janus.user.type": refusal.userType ?? call.userType,
167
+ "janus.refusal": refusal.code,
168
+ "janus.refusal.reason": refusal.reason,
169
+ "user.id": refusal.userId
170
+ });
171
+ }
172
+ function answered(scope, call, outcome) {
173
+ if (outcome.ok) {
174
+ const fields = userFields(call, outcome.value);
175
+ for (const [key, value] of Object.entries(fields))
176
+ scope.attribute(key, value);
177
+ const value = outcome.value;
178
+ if (typeof value === "object" && value !== null && "renewed" in value) {
179
+ scope.attribute("janus.session.renewed", value.renewed === true);
180
+ }
181
+ }
182
+ WRITTEN[call.flow]?.(call, outcome);
183
+ }
184
+ var UNTRACED = new Set(["cookie"]);
185
+ function instrumentJanus(auth) {
186
+ const types = auth.types.filter((type) => typeof type === "string");
187
+ const only = types.length === 1 ? types[0] : undefined;
188
+ return traceObject(auth, [], (path) => path[0] !== undefined && types.includes(path[0]) ? { userType: path[0], flow: path.slice(1) } : { userType: only, flow: path });
189
+ }
190
+ function traceObject(target, path, split) {
191
+ const copy = {};
192
+ for (const [key, value] of Object.entries(target)) {
193
+ if (UNTRACED.has(key)) {
194
+ copy[key] = value;
195
+ } else if (typeof value === "function") {
196
+ copy[key] = traceFunction(value, target, [...path, key], split);
197
+ } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
198
+ copy[key] = traceObject(value, [...path, key], split);
199
+ } else {
200
+ copy[key] = value;
201
+ }
202
+ }
203
+ return Object.freeze(copy);
204
+ }
205
+ function traceFunction(fn, self, path, split) {
206
+ const { userType, flow } = split(path);
207
+ const name = `janus.${path.join(".")}`;
208
+ const fields = fieldsOf({ "janus.user.type": userType });
209
+ return (...args) => {
210
+ const call = { flow: flow.join("."), userType, args };
211
+ return traced(name, fields, () => Promise.resolve(fn.apply(self, args)), (scope, outcome) => answered(scope, call, outcome));
212
+ };
213
+ }
214
+ // src/permissions.ts
215
+ import { createLogger as createLogger2, event as event2 } from "@nxgt/telemetry";
216
+ var log2 = createLogger2("@nxgt/janus");
217
+ var granted = event2("janus.tuple.granted");
218
+ var revoked = event2("janus.tuple.revoked");
219
+ function subjectFields(subject) {
220
+ const relation = typeof subject === "object" && subject !== null && "relation" in subject ? subject.relation : undefined;
221
+ return fieldsOf({
222
+ "janus.subject.type": typeOf(subject),
223
+ "janus.subject.id": idOf(subject),
224
+ "janus.subject.relation": typeof relation === "string" ? relation : undefined
225
+ });
226
+ }
227
+ function objectFields(object) {
228
+ return fieldsOf({
229
+ "janus.object.type": typeOf(object),
230
+ "janus.object.id": idOf(object)
231
+ });
232
+ }
233
+ var TRACERS = {
234
+ can: (fn) => (...args) => traced("janus.can", fieldsOf({
235
+ ...subjectFields(args[0]),
236
+ "janus.permission": typeof args[1] === "string" ? args[1] : undefined,
237
+ ...objectFields(args[2])
238
+ }), () => fn(...args), (scope, outcome) => {
239
+ if (outcome.ok)
240
+ scope.attribute("janus.allowed", outcome.value === true);
241
+ }),
242
+ list: (fn) => (...args) => traced("janus.list", fieldsOf({
243
+ ...subjectFields(args[0]),
244
+ "janus.permission": typeof args[1] === "string" ? args[1] : undefined,
245
+ "janus.object.type": typeof args[2] === "string" ? args[2] : undefined
246
+ }), () => fn(...args), (scope, outcome) => {
247
+ const page = outcome.ok ? outcome.value : undefined;
248
+ if (typeof page === "object" && page !== null && "items" in page) {
249
+ const items = page.items;
250
+ if (Array.isArray(items))
251
+ scope.attribute("janus.page.items", items.length);
252
+ }
253
+ }),
254
+ grant: (fn) => tupleWrite("janus.grant", granted, fn),
255
+ revoke: (fn) => tupleWrite("janus.revoke", revoked, fn)
256
+ };
257
+ function tupleWrite(name, written, fn) {
258
+ return (...args) => {
259
+ const fields = fieldsOf({
260
+ ...objectFields(args[0]),
261
+ "janus.relation": typeof args[1] === "string" ? args[1] : undefined,
262
+ ...subjectFields(args[2])
263
+ });
264
+ return traced(name, fields, () => fn(...args), (_scope, outcome) => {
265
+ if (outcome.ok)
266
+ log2.info(written(fields));
267
+ });
268
+ };
269
+ }
270
+ function instrumentPermissions(access) {
271
+ const copy = Object.fromEntries(Object.entries(access));
272
+ for (const [key, tracer] of Object.entries(TRACERS)) {
273
+ const method = copy[key];
274
+ if (typeof method !== "function")
275
+ continue;
276
+ copy[key] = tracer((...args) => Promise.resolve(method.apply(access, args)));
277
+ }
278
+ return Object.freeze(copy);
279
+ }
280
+ export {
281
+ instrumentJanus,
282
+ instrumentPermissions
283
+ };
284
+
285
+ //# debugId=D02888C2C0C3C5D064756E2164756E21
286
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/flows.ts", "../src/traced.ts", "../src/permissions.ts"],
4
+ "sourcesContent": [
5
+ "import type { JanusError } from '@nxgt/janus';\nimport { createLogger, event, type SpanScope } from '@nxgt/telemetry';\nimport {\n\ttype Fields,\n\tfieldsOf,\n\tidOf,\n\ttype Outcome,\n\ttraced,\n\ttypeOf,\n} from './traced';\n\nconst log = createLogger('@nxgt/janus');\n\n/** The events a security review reads: who signed in, out, up, and what changed. */\nconst events = {\n\tsignedUp: event('janus.signUp'),\n\tsignedIn: event('janus.signIn'),\n\tsignInRefused: event('janus.signIn.refused'),\n\tsignedOut: event('janus.signOut'),\n\tsignedOutEverywhere: event('janus.signOutEverywhere'),\n\tuserDeleted: event('janus.user.deleted'),\n\tuserActivated: event('janus.user.activated'),\n\tuserDeactivated: event('janus.user.deactivated'),\n\tpasswordChanged: event('janus.password.changed'),\n\tpasswordSet: event('janus.password.set'),\n\tpasswordReset: event('janus.password.reset'),\n\temailVerified: event('janus.email.verified'),\n};\n\n/** What `janus()` answers, as far as it is read: its user types and its flows. */\nexport interface JanusLike {\n\treadonly types: readonly string[];\n\treadonly authenticate: (...args: never[]) => Promise<unknown>;\n}\n\n/** One call of a flow, as the events read it. */\ninterface Call {\n\t/** `signIn`, `verifyEmail.confirm`: the flow without the user type. */\n\treadonly flow: string;\n\treadonly userType: string | undefined;\n\treadonly args: readonly unknown[];\n}\n\n/**\n * What each flow writes once it answered. Nothing here reads a login, an\n * e-mail, a password or a token: only user types, ids, codes and reasons.\n */\nconst WRITTEN: Readonly<\n\tRecord<string, (call: Call, outcome: Outcome) => void>\n> = {\n\tsignUp: (call, outcome) => {\n\t\tif (outcome.ok) log.info(events.signedUp(userFields(call, outcome.value)));\n\t},\n\tsignIn: (call, outcome) => {\n\t\tif (outcome.ok) {\n\t\t\tlog.info(events.signedIn(userFields(call, outcome.value)));\n\t\t} else {\n\t\t\tlog.warn(events.signInRefused(refusalFields(call, outcome.refusal)));\n\t\t}\n\t},\n\tsignOut: (_call, outcome) => {\n\t\tif (outcome.ok && outcome.value === true) log.info(events.signedOut());\n\t},\n\tsignOutEverywhere: (call, outcome) => {\n\t\tif (outcome.ok) log.info(events.signedOutEverywhere(argumentUser(call)));\n\t},\n\tdelete: (call, outcome) => {\n\t\tif (outcome.ok && outcome.value === true) {\n\t\t\tlog.info(events.userDeleted(argumentUser(call)));\n\t\t}\n\t},\n\tsetActive: (call, outcome) => {\n\t\tif (!outcome.ok) return;\n\t\tconst written =\n\t\t\tcall.args[1] === true ? events.userActivated : events.userDeactivated;\n\t\tlog.info(written(argumentUser(call)));\n\t},\n\tchangePassword: (call, outcome) => {\n\t\tif (outcome.ok) log.info(events.passwordChanged(argumentUser(call)));\n\t},\n\tsetPassword: (call, outcome) => {\n\t\tif (outcome.ok) log.info(events.passwordSet(argumentUser(call)));\n\t},\n\t'resetPassword.confirm': (call, outcome) => {\n\t\tif (outcome.ok)\n\t\t\tlog.info(events.passwordReset(userFields(call, outcome.value)));\n\t},\n\t'verifyEmail.confirm': (call, outcome) => {\n\t\tif (outcome.ok)\n\t\t\tlog.info(events.emailVerified(userFields(call, outcome.value)));\n\t},\n};\n\n/** The user an answer names — `{ user }` or the user itself — as fields. */\nfunction userFields(call: Call, value: unknown): Fields {\n\tconst user =\n\t\ttypeof value === 'object' && value !== null && 'user' in value\n\t\t\t? value.user\n\t\t\t: value;\n\treturn fieldsOf({\n\t\t'janus.user.type': typeOf(user) ?? call.userType,\n\t\t'user.id': idOf(user),\n\t});\n}\n\n/** The user a flow was called for: its first argument. */\nfunction argumentUser(call: Call): Fields {\n\treturn fieldsOf({\n\t\t'janus.user.type': call.userType,\n\t\t'user.id': idOf(call.args[0]),\n\t});\n}\n\n/** Why a sign-in was refused: the code, the reason, and the user when one holds the login. */\nfunction refusalFields(call: Call, refusal: JanusError): Fields {\n\treturn fieldsOf({\n\t\t'janus.user.type': refusal.userType ?? call.userType,\n\t\t'janus.refusal': refusal.code,\n\t\t'janus.refusal.reason': refusal.reason,\n\t\t'user.id': refusal.userId,\n\t});\n}\n\n/** What a span learns from an answer: whose it is, never what it holds. */\nfunction answered(scope: SpanScope, call: Call, outcome: Outcome): void {\n\tif (outcome.ok) {\n\t\tconst fields = userFields(call, outcome.value);\n\t\tfor (const [key, value] of Object.entries(fields))\n\t\t\tscope.attribute(key, value);\n\t\tconst value = outcome.value;\n\t\tif (typeof value === 'object' && value !== null && 'renewed' in value) {\n\t\t\tscope.attribute('janus.session.renewed', value.renewed === true);\n\t\t}\n\t}\n\tWRITTEN[call.flow]?.(call, outcome);\n}\n\n/** `cookie` answers synchronously: it is the one part of `janus()` not traced. */\nconst UNTRACED = new Set(['cookie']);\n\n/**\n * The same `janus()` instance, with every flow traced — a span per call,\n * named `janus.signIn` or `janus.patient.signIn` — and the events a security\n * review reads written as logs: sign-ups, sign-ins and the reason one was\n * refused, sign-outs, deleted and deactivated users, changed passwords.\n *\n * ```ts\n * const auth = instrumentJanus(janus({ users, store, hasher }));\n * ```\n *\n * **A refusal is an answer**: a wrong password leaves its span `ok`, with\n * `janus.refusal` set, and is a `janus.signIn.refused` warning — never a\n * failed span. A store that cannot answer is one, with `janus.store.slot`.\n *\n * Nothing written carries a login, an e-mail, a password, a token or a\n * session id: user types, user ids, codes and reasons only.\n */\nexport function instrumentJanus<A extends JanusLike>(auth: A): A {\n\tconst types = auth.types.filter((type) => typeof type === 'string');\n\tconst only = types.length === 1 ? types[0] : undefined;\n\treturn traceObject(auth, [], (path) =>\n\t\tpath[0] !== undefined && types.includes(path[0])\n\t\t\t? { userType: path[0], flow: path.slice(1) }\n\t\t\t: { userType: only, flow: path },\n\t);\n}\n\ntype Split = (path: readonly string[]) => {\n\treadonly userType: string | undefined;\n\treadonly flow: readonly string[];\n};\n\n/**\n * A traced copy of `target`, frozen: its functions traced, its plain objects —\n * a user type's flows, `verifyEmail` — copied the same way. A copy rather\n * than a Proxy, which may not answer differently for a frozen property.\n */\nfunction traceObject<O extends object>(\n\ttarget: O,\n\tpath: readonly string[],\n\tsplit: Split,\n): O {\n\tconst copy: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(target)) {\n\t\tif (UNTRACED.has(key)) {\n\t\t\tcopy[key] = value;\n\t\t} else if (typeof value === 'function') {\n\t\t\tcopy[key] = traceFunction(\n\t\t\t\tvalue as (...args: unknown[]) => unknown,\n\t\t\t\ttarget,\n\t\t\t\t[...path, key],\n\t\t\t\tsplit,\n\t\t\t);\n\t\t} else if (\n\t\t\ttypeof value === 'object' &&\n\t\t\tvalue !== null &&\n\t\t\t!Array.isArray(value)\n\t\t) {\n\t\t\tcopy[key] = traceObject(value, [...path, key], split);\n\t\t} else {\n\t\t\tcopy[key] = value;\n\t\t}\n\t}\n\treturn Object.freeze(copy) as O;\n}\n\nfunction traceFunction(\n\tfn: (...args: unknown[]) => unknown,\n\tself: object,\n\tpath: readonly string[],\n\tsplit: Split,\n): (...args: unknown[]) => Promise<unknown> {\n\tconst { userType, flow } = split(path);\n\tconst name = `janus.${path.join('.')}`;\n\tconst fields = fieldsOf({ 'janus.user.type': userType });\n\treturn (...args) => {\n\t\tconst call: Call = { flow: flow.join('.'), userType, args };\n\t\treturn traced(\n\t\t\tname,\n\t\t\tfields,\n\t\t\t() => Promise.resolve(fn.apply(self, args)),\n\t\t\t(scope, outcome) => answered(scope, call, outcome),\n\t\t);\n\t};\n}\n",
6
+ "import { JanusError, type JanusErrorCode } from '@nxgt/janus';\nimport { type SpanScope, span } from '@nxgt/telemetry';\n\n/**\n * What each code is. A **failure** is the server's to fix, and fails its span;\n * a **refusal** — a wrong password, a taken login, a spent token — is an\n * answer, and leaves it `ok`. Exhaustive, so a new code does not compile until\n * it is classified.\n */\nfunction kindOf(code: JanusErrorCode): 'failure' | 'refusal' {\n\tswitch (code) {\n\t\tcase 'STORE_FAILED':\n\t\tcase 'UNSUPPORTED':\n\t\tcase 'PERMISSION_DEPTH':\n\t\tcase 'HASH_UNSUPPORTED':\n\t\t\treturn 'failure';\n\t\tcase 'NOT_FOUND':\n\t\tcase 'LOGIN_TAKEN':\n\t\tcase 'VERSION_CONFLICT':\n\t\tcase 'USER_INVALID':\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\tcase 'CREDENTIALS_INVALID':\n\t\tcase 'USER_INACTIVE':\n\t\tcase 'TOKEN_UNKNOWN':\n\t\tcase 'TOKEN_SPENT':\n\t\tcase 'TOKEN_EXPIRED':\n\t\tcase 'TOKEN_STALE':\n\t\tcase 'INVALID_CURSOR':\n\t\t\treturn 'refusal';\n\t}\n}\n\n/** A `JanusError` the caller caused; anything else, or a failure code, is not. */\nexport function isRefusal(error: unknown): error is JanusError {\n\treturn error instanceof JanusError && kindOf(error.code) === 'refusal';\n}\n\n/** What a span or an event carries: scalars only, and never an absent one. */\nexport type Fields = Readonly<Record<string, string | number | boolean>>;\n\n/** `fields` without its `undefined` values. */\nexport function fieldsOf(\n\tfields: Readonly<Record<string, string | number | boolean | undefined>>,\n): Fields {\n\tconst kept: Record<string, string | number | boolean> = {};\n\tfor (const [key, value] of Object.entries(fields)) {\n\t\tif (value !== undefined) kept[key] = value;\n\t}\n\treturn kept;\n}\n\n/** The `id` of a user reference, a subject or an object — or nothing. */\nexport function idOf(value: unknown): string | undefined {\n\tif (typeof value === 'string') return value;\n\tif (typeof value === 'object' && value !== null && 'id' in value) {\n\t\tconst id: unknown = value.id;\n\t\tif (typeof id === 'string') return id;\n\t}\n\treturn undefined;\n}\n\n/** The `type` of a subject or an object — or nothing. */\nexport function typeOf(value: unknown): string | undefined {\n\tif (typeof value === 'object' && value !== null && 'type' in value) {\n\t\tconst type: unknown = value.type;\n\t\tif (typeof type === 'string') return type;\n\t}\n\treturn undefined;\n}\n\n/** How a traced call ended, for what is written after it. */\nexport type Outcome =\n\t| { readonly ok: true; readonly value: unknown }\n\t| { readonly ok: false; readonly refusal: JanusError };\n\n/**\n * Runs `call` in a span. **A refusal is an answer**: the span ends `ok`, with\n * `janus.refusal` set to its code, and the error is rethrown once the span is\n * over. A failure is rethrown inside it, so the span is `error`, with the\n * store slot and operation a `StoreFailure` names.\n *\n * `after` sees the outcome inside the span, outside the flow's `try`, and a\n * throw from it is dropped: telemetry never changes what a flow answers.\n */\nexport async function traced<T>(\n\tname: string,\n\tfields: Fields,\n\tcall: () => Promise<T>,\n\tafter: (scope: SpanScope, outcome: Outcome) => void,\n): Promise<T> {\n\tconst ended = await span(name, { attributes: fields }, async (scope) => {\n\t\tlet outcome: { ok: true; value: T } | { ok: false; refusal: JanusError };\n\t\ttry {\n\t\t\toutcome = { ok: true, value: await call() };\n\t\t} catch (error) {\n\t\t\tif (error instanceof JanusError) {\n\t\t\t\tscope.attribute('janus.error.code', error.code);\n\t\t\t\tif (error.slot !== undefined)\n\t\t\t\t\tscope.attribute('janus.store.slot', error.slot);\n\t\t\t\tif (error.operation !== undefined) {\n\t\t\t\t\tscope.attribute('janus.store.operation', error.operation);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!isRefusal(error)) throw error;\n\t\t\tscope.attribute('janus.refusal', error.code);\n\t\t\toutcome = { ok: false, refusal: error };\n\t\t}\n\t\ttry {\n\t\t\tafter(scope, outcome);\n\t\t} catch {\n\t\t\t// A signal is never worth a flow's answer.\n\t\t}\n\t\treturn outcome;\n\t});\n\tif (!ended.ok) throw ended.refusal;\n\treturn ended.value;\n}\n",
7
+ "import { createLogger, event, type TelemetryEvent } from '@nxgt/telemetry';\nimport { type Fields, fieldsOf, idOf, traced, typeOf } from './traced';\n\nconst log = createLogger('@nxgt/janus');\n\n/** The audit trail of who was given what: one event per tuple written. */\nconst granted = event('janus.tuple.granted');\nconst revoked = event('janus.tuple.revoked');\n\n/** A subject — a user, an object, or a subject set — as fields. */\nfunction subjectFields(subject: unknown): Fields {\n\tconst relation =\n\t\ttypeof subject === 'object' && subject !== null && 'relation' in subject\n\t\t\t? subject.relation\n\t\t\t: undefined;\n\treturn fieldsOf({\n\t\t'janus.subject.type': typeOf(subject),\n\t\t'janus.subject.id': idOf(subject),\n\t\t'janus.subject.relation':\n\t\t\ttypeof relation === 'string' ? relation : undefined,\n\t});\n}\n\nfunction objectFields(object: unknown): Fields {\n\treturn fieldsOf({\n\t\t'janus.object.type': typeOf(object),\n\t\t'janus.object.id': idOf(object),\n\t});\n}\n\n/** What `permissions()` answers, as far as it is traced. */\nexport interface PermissionsLike {\n\treadonly can: (...args: never[]) => Promise<boolean>;\n\treadonly list: (...args: never[]) => Promise<unknown>;\n\treadonly grant: (...args: never[]) => Promise<unknown>;\n\treadonly revoke: (...args: never[]) => Promise<unknown>;\n}\n\ntype Traced = (...args: unknown[]) => Promise<unknown>;\n\n/** How each method of `permissions()` is traced. */\nconst TRACERS: Readonly<Record<string, (fn: Traced) => Traced>> = {\n\tcan:\n\t\t(fn) =>\n\t\t(...args) =>\n\t\t\ttraced(\n\t\t\t\t'janus.can',\n\t\t\t\tfieldsOf({\n\t\t\t\t\t...subjectFields(args[0]),\n\t\t\t\t\t'janus.permission': typeof args[1] === 'string' ? args[1] : undefined,\n\t\t\t\t\t...objectFields(args[2]),\n\t\t\t\t}),\n\t\t\t\t() => fn(...args),\n\t\t\t\t(scope, outcome) => {\n\t\t\t\t\tif (outcome.ok)\n\t\t\t\t\t\tscope.attribute('janus.allowed', outcome.value === true);\n\t\t\t\t},\n\t\t\t),\n\tlist:\n\t\t(fn) =>\n\t\t(...args) =>\n\t\t\ttraced(\n\t\t\t\t'janus.list',\n\t\t\t\tfieldsOf({\n\t\t\t\t\t...subjectFields(args[0]),\n\t\t\t\t\t'janus.permission': typeof args[1] === 'string' ? args[1] : undefined,\n\t\t\t\t\t'janus.object.type':\n\t\t\t\t\t\ttypeof args[2] === 'string' ? args[2] : undefined,\n\t\t\t\t}),\n\t\t\t\t() => fn(...args),\n\t\t\t\t(scope, outcome) => {\n\t\t\t\t\tconst page = outcome.ok ? outcome.value : undefined;\n\t\t\t\t\tif (typeof page === 'object' && page !== null && 'items' in page) {\n\t\t\t\t\t\tconst items = page.items;\n\t\t\t\t\t\tif (Array.isArray(items))\n\t\t\t\t\t\t\tscope.attribute('janus.page.items', items.length);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t),\n\tgrant: (fn) => tupleWrite('janus.grant', granted, fn),\n\trevoke: (fn) => tupleWrite('janus.revoke', revoked, fn),\n};\n\nfunction tupleWrite(\n\tname: string,\n\twritten: (fields: Fields) => TelemetryEvent,\n\tfn: Traced,\n): Traced {\n\treturn (...args) => {\n\t\tconst fields = fieldsOf({\n\t\t\t...objectFields(args[0]),\n\t\t\t'janus.relation': typeof args[1] === 'string' ? args[1] : undefined,\n\t\t\t...subjectFields(args[2]),\n\t\t});\n\t\treturn traced(\n\t\t\tname,\n\t\t\tfields,\n\t\t\t() => fn(...args),\n\t\t\t(_scope, outcome) => {\n\t\t\t\tif (outcome.ok) log.info(written(fields));\n\t\t\t},\n\t\t);\n\t};\n}\n\n/**\n * The same `permissions()` instance, traced: a span per `can` — its subject,\n * permission, object and whether it was allowed — and per `list`, and a\n * `janus.tuple.granted` or `janus.tuple.revoked` event for every tuple\n * written, the audit trail of who was given what.\n *\n * ```ts\n * const access = instrumentPermissions(permissions({ model, store }));\n * ```\n *\n * A denial is an answer, not a failure: `janus.allowed` is `false` and the\n * span is `ok`. A relation store that cannot answer fails the span.\n */\nexport function instrumentPermissions<A extends PermissionsLike>(access: A): A {\n\t// `permissions()` answers a frozen object, which a Proxy may not answer\n\t// differently for: the traced methods go on a copy, frozen in turn.\n\tconst copy: Record<string, unknown> = Object.fromEntries(\n\t\tObject.entries(access),\n\t);\n\tfor (const [key, tracer] of Object.entries(TRACERS)) {\n\t\tconst method: unknown = copy[key];\n\t\tif (typeof method !== 'function') continue;\n\t\tcopy[key] = tracer((...args) =>\n\t\t\tPromise.resolve(method.apply(access, args)),\n\t\t);\n\t}\n\treturn Object.freeze(copy) as A;\n}\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "D02888C2C0C3C5D064756E2164756E21",
11
+ "names": []
12
+ }
@@ -0,0 +1,22 @@
1
+ /** What `permissions()` answers, as far as it is traced. */
2
+ export interface PermissionsLike {
3
+ readonly can: (...args: never[]) => Promise<boolean>;
4
+ readonly list: (...args: never[]) => Promise<unknown>;
5
+ readonly grant: (...args: never[]) => Promise<unknown>;
6
+ readonly revoke: (...args: never[]) => Promise<unknown>;
7
+ }
8
+ /**
9
+ * The same `permissions()` instance, traced: a span per `can` — its subject,
10
+ * permission, object and whether it was allowed — and per `list`, and a
11
+ * `janus.tuple.granted` or `janus.tuple.revoked` event for every tuple
12
+ * written, the audit trail of who was given what.
13
+ *
14
+ * ```ts
15
+ * const access = instrumentPermissions(permissions({ model, store }));
16
+ * ```
17
+ *
18
+ * A denial is an answer, not a failure: `janus.allowed` is `false` and the
19
+ * span is `ok`. A relation store that cannot answer fails the span.
20
+ */
21
+ export declare function instrumentPermissions<A extends PermissionsLike>(access: A): A;
22
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AA8BA,4DAA4D;AAC5D,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACrD,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACtD,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,QAAQ,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxD;AAqED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,eAAe,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC,CAc7E"}
@@ -0,0 +1,31 @@
1
+ import { JanusError } from '@nxgt/janus';
2
+ import { type SpanScope } from '@nxgt/telemetry';
3
+ /** A `JanusError` the caller caused; anything else, or a failure code, is not. */
4
+ export declare function isRefusal(error: unknown): error is JanusError;
5
+ /** What a span or an event carries: scalars only, and never an absent one. */
6
+ export type Fields = Readonly<Record<string, string | number | boolean>>;
7
+ /** `fields` without its `undefined` values. */
8
+ export declare function fieldsOf(fields: Readonly<Record<string, string | number | boolean | undefined>>): Fields;
9
+ /** The `id` of a user reference, a subject or an object — or nothing. */
10
+ export declare function idOf(value: unknown): string | undefined;
11
+ /** The `type` of a subject or an object — or nothing. */
12
+ export declare function typeOf(value: unknown): string | undefined;
13
+ /** How a traced call ended, for what is written after it. */
14
+ export type Outcome = {
15
+ readonly ok: true;
16
+ readonly value: unknown;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly refusal: JanusError;
20
+ };
21
+ /**
22
+ * Runs `call` in a span. **A refusal is an answer**: the span ends `ok`, with
23
+ * `janus.refusal` set to its code, and the error is rethrown once the span is
24
+ * over. A failure is rethrown inside it, so the span is `error`, with the
25
+ * store slot and operation a `StoreFailure` names.
26
+ *
27
+ * `after` sees the outcome inside the span, outside the flow's `try`, and a
28
+ * throw from it is dropped: telemetry never changes what a flow answers.
29
+ */
30
+ export declare function traced<T>(name: string, fields: Fields, call: () => Promise<T>, after: (scope: SpanScope, outcome: Outcome) => void): Promise<T>;
31
+ //# sourceMappingURL=traced.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"traced.d.ts","sourceRoot":"","sources":["../src/traced.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAuB,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAE,KAAK,SAAS,EAAQ,MAAM,iBAAiB,CAAC;AA+BvD,kFAAkF;AAClF,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAE7D;AAED,8EAA8E;AAC9E,MAAM,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;AAEzE,+CAA+C;AAC/C,wBAAgB,QAAQ,CACvB,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAC,GACrE,MAAM,CAMR;AAED,yEAAyE;AACzE,wBAAgB,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAOvD;AAED,yDAAyD;AACzD,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAMzD;AAED,6DAA6D;AAC7D,MAAM,MAAM,OAAO,GAChB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC9C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAAE,CAAC;AAExD;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,KAAK,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,GACjD,OAAO,CAAC,CAAC,CAAC,CA2BZ"}
package/docs/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @nxgt/janus-telemetry — documentation
2
+
3
+ The [README](../README.md) lists every span and event; these pages give the
4
+ detail.
5
+
6
+ | Page | Read it when |
7
+ | --- | --- |
8
+ | [Tracing Janus](guide/tracing.md) | You are wiring it into an application, or reading what it wrote |
9
+ | [Troubleshooting](troubleshooting.md) | A span or an event is missing, or says something you did not expect |
10
+ | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
11
+
12
+ The words — user, session, object, permission, refusal, failure — are
13
+ [`@nxgt/janus`'s vocabulary](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/vocabulary.md),
14
+ and mean the same here.
@@ -0,0 +1,117 @@
1
+ # Tracing Janus
2
+
3
+ This page is for making what `@nxgt/janus` does visible — its flows as spans,
4
+ its security events as logs — in an application that already installs
5
+ `@nxgt/telemetry`.
6
+
7
+ ## Wiring
8
+
9
+ Wrap the instances where they are created, and export only the wrapped ones:
10
+
11
+ ```ts
12
+ // auth.ts
13
+ import { janus, scryptHasher } from '@nxgt/janus';
14
+ import { defineModel, permissions } from '@nxgt/janus/permissions';
15
+ import { createMongoAdapter } from '@nxgt/janus-mongo';
16
+ import { instrumentJanus, instrumentPermissions } from '@nxgt/janus-telemetry';
17
+
18
+ const mongo = createMongoAdapter(db);
19
+
20
+ export const auth = instrumentJanus(
21
+ janus({ users, hasher: scryptHasher(), ...mongo }),
22
+ );
23
+
24
+ export const access = instrumentPermissions(
25
+ permissions({ model: defineModel({ subjects: auth.types, types }), store: mongo.relations }),
26
+ );
27
+ ```
28
+
29
+ `auth.types` reads the same through the wrapper, so the model is defined from
30
+ the wrapped instance as from the plain one.
31
+
32
+ With Hono, `@nxgt/telemetry-hono` opens a server span per request, and every
33
+ flow a route calls nests under it:
34
+
35
+ ```ts
36
+ import { telemetry } from '@nxgt/telemetry-hono';
37
+ import { bindJanus } from '@nxgt/janus-hono';
38
+
39
+ const j = bindJanus({ auth, access });
40
+
41
+ const app = new Hono()
42
+ .use(telemetry({ service: 'clinic' }), j.session(), j.provide())
43
+ .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));
44
+ ```
45
+
46
+ A request to `/records/r1` is then one trace:
47
+
48
+ ```
49
+ GET /records/:id server
50
+ ├─ janus.authenticate user.id, janus.user.type, janus.session.renewed
51
+ └─ janus.can janus.permission=view, janus.object.id=r1, janus.allowed=true
52
+ ```
53
+
54
+ ## Refusals and failures
55
+
56
+ A span answers one question: **did Janus work?** A wrong password is Janus
57
+ working — it answered a refusal — so the span is `ok` and carries
58
+ `janus.refusal: 'CREDENTIALS_INVALID'`. A store that cannot answer is Janus
59
+ failing, so the span is `error`, with the store named:
60
+
61
+ ```
62
+ janus.patient.signIn error janus.error.code=STORE_FAILED janus.store.slot=users janus.store.operation=findUserByLogin
63
+ ```
64
+
65
+ The failures are `STORE_FAILED`, `UNSUPPORTED`, `PERMISSION_DEPTH`,
66
+ `HASH_UNSUPPORTED`, and anything thrown that is not a `JanusError`. Every other
67
+ code is a refusal. An alert on failed `janus.*` spans therefore fires on an
68
+ outage, never on users mistyping their password.
69
+
70
+ ## The audit trail
71
+
72
+ The events are logs, from the source `@nxgt/janus`, written inside the flow's
73
+ span — so each carries its `traceId`, and the request that caused it is one
74
+ query away. Refused sign-ins are **warnings**; everything else is info:
75
+
76
+ ```ts
77
+ // Every refused sign-in of the last hour, in a collector that stores logs:
78
+ // name = 'janus.signIn.refused' AND at > now() - 1h, grouped by janus.refusal.reason
79
+ ```
80
+
81
+ `janus.tuple.granted` and `janus.tuple.revoked` are the record of who was
82
+ given what:
83
+
84
+ ```
85
+ janus.tuple.granted janus.object.type=record janus.object.id=r1 janus.relation=owners janus.subject.type=patient janus.subject.id=u1
86
+ ```
87
+
88
+ ## What is never written
89
+
90
+ A login, an e-mail, a password, a session token, a one-time token, a session id — nothing a log reader
91
+ could sign in with, or use to tell who holds an account. A refused sign-in by
92
+ an unknown login says `janus.refusal.reason: 'unknownLogin'`, not which login
93
+ was tried; only `USER_INACTIVE` names the user, whose password was right.
94
+ The spec that holds this runs every flow and searches every signal
95
+ for each of them.
96
+
97
+ ## In a test
98
+
99
+ Give the test a telemetry of its own, as `@nxgt/telemetry` recommends, and read
100
+ what it received:
101
+
102
+ ```ts
103
+ import { createTelemetry, type Signal, withTelemetry } from '@nxgt/telemetry';
104
+
105
+ const received: Signal[] = [];
106
+ const telemetry = createTelemetry('test', {
107
+ exporters: [{ export: (_resource, batch) => void received.push(...batch) }],
108
+ });
109
+ await withTelemetry(telemetry, () => auth.patient.signIn({ email, password }));
110
+ await telemetry.close(); // flushes
111
+ received.find((signal) => signal.name === 'janus.signIn'); // the event
112
+ ```
113
+
114
+ ## See also
115
+
116
+ - [`@nxgt/telemetry`](https://www.npmjs.com/package/@nxgt/telemetry) — installing a telemetry, exporters, sampling
117
+ - [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono) — the routes these flows run in
@@ -0,0 +1,35 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/janus-telemetry` is heading. A direction, not a commitment: there
4
+ are no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing yet.
9
+
10
+ ## Next
11
+
12
+ Nothing yet.
13
+
14
+ ## Later
15
+
16
+ - **Metrics** — sign-ins, refusals and checks as counters, once
17
+ `@nxgt/telemetry` carries metrics as a third signal.
18
+ - **Store spans** — a span per store call, for an adapter with no
19
+ instrumentation of its own. MongoDB has one:
20
+ `@nxgt/telemetry-mongo`'s `instrumentMongo`.
21
+
22
+ ## Not planned
23
+
24
+ - **An OpenTelemetry SDK** — like `@nxgt/telemetry`, it speaks OTLP through its
25
+ exporters and depends on no `@opentelemetry/*` package.
26
+ - **Writing a login, an e-mail, a password, a session token, a one-time token
27
+ or a session id** — in any span or event, on any setting.
28
+ - **`moduleResolution: "nodenext"`** — like `@nxgt/janus`, the package imports
29
+ without extensions. Use `"moduleResolution": "bundler"`.
30
+
31
+ ## Shipped
32
+
33
+ - **The first release, v0.1.0** — `instrumentJanus` and
34
+ `instrumentPermissions`: a span per flow and per permission check, and the
35
+ security events worth an audit trail.
@@ -0,0 +1,86 @@
1
+ # Troubleshooting `@nxgt/janus-telemetry`
2
+
3
+ Each entry is headed by what you see. This package defines no error class and
4
+ throws nothing of its own: an error that reaches you is `@nxgt/janus`'s, as it
5
+ would be without it.
6
+
7
+ ## Index
8
+
9
+ - [No `janus.*` span or event at all](#no-janus-span-or-event-at-all)
10
+ - [Some routes are traced, others not](#some-routes-are-traced-others-not)
11
+ - [`TypeError: Cannot add property …, object is not extensible` (Node), `Attempting to define property on object that is not extensible.` (Bun)](#typeerror-cannot-add-property--object-is-not-extensible)
12
+ - [A wrong password does not fail the span](#a-wrong-password-does-not-fail-the-span)
13
+ - [`janus.signIn.refused` has no `user.id`](#janussigninrefused-has-no-userid)
14
+ - [Every wrong password fails its span](#every-wrong-password-fails-its-span)
15
+
16
+ ### No `janus.*` span or event at all
17
+
18
+ **When:** the flows answer, and the exporter receives nothing from them.
19
+
20
+ **Why:** no telemetry is installed, or the flows run outside it. Without one,
21
+ `@nxgt/telemetry` drops signals in silence, by design.
22
+
23
+ **Fix:** install one at startup, before the first request.
24
+
25
+ ```ts
26
+ createTelemetry('clinic', { exporters: [consoleExporter()] }).install();
27
+ ```
28
+
29
+ ### Some routes are traced, others not
30
+
31
+ **When:** `janus.*` spans appear for some requests only.
32
+
33
+ **Why:** those routes use the unwrapped instance — imported from where
34
+ `janus()` was called, rather than the wrapped export.
35
+
36
+ **Fix:** export only the wrapped instance, and create it once:
37
+ `export const auth = instrumentJanus(janus({ … }))`.
38
+
39
+ ### `TypeError: Cannot add property …, object is not extensible`
40
+
41
+ Under Bun: `TypeError: Attempting to define property on object that is not
42
+ extensible.`
43
+
44
+ **When:** assigning to the instance `instrumentJanus` or
45
+ `instrumentPermissions` answered.
46
+
47
+ **Why:** they answer a frozen copy, as `permissions()` itself does.
48
+
49
+ **Fix:** keep what you want to add beside the instance, not on it.
50
+
51
+ ### A wrong password does not fail the span
52
+
53
+ **When:** a `janus.signIn` span is `ok` although the sign-in threw
54
+ `CREDENTIALS_INVALID`.
55
+
56
+ **Why:** a refusal is an answer — Janus worked. The span carries
57
+ `janus.refusal`, and the `janus.signIn.refused` warning says why. Only a
58
+ failure, such as `STORE_FAILED`, fails a span.
59
+
60
+ **Fix:** nothing. Query `janus.refusal` or the warning to count refusals.
61
+
62
+ ### `janus.signIn.refused` has no `user.id`
63
+
64
+ **When:** a sign-in refused with `CREDENTIALS_INVALID` carries the reason and
65
+ the user type, and no user. (`USER_INACTIVE` does carry `user.id`: the
66
+ password was right, so the user is known.)
67
+
68
+ **Why:** the error `@nxgt/janus` throws for bad credentials names no user —
69
+ `unknownLogin` has none to name, and the others do not say which, so a
70
+ response cannot tell which accounts exist. The login tried is never written.
71
+
72
+ **Fix:** to count refusals per account, do it where the login is known — in
73
+ your sign-in route, from the request — not from telemetry.
74
+
75
+ ### Every wrong password fails its span
76
+
77
+ **When:** `janus.signIn` spans fail with `janus.error.code` absent, for
78
+ refusals such as `CREDENTIALS_INVALID`.
79
+
80
+ **Why:** two copies of `@nxgt/janus` are installed — the application's and
81
+ another one, nested under a dependency. A refusal is recognised with
82
+ `instanceof JanusError`, which fails across copies, so it is taken for a
83
+ failure.
84
+
85
+ **Fix:** keep one copy: `bun pm ls | grep @nxgt/janus` should print one
86
+ version. Align the versions, or dedupe the lockfile.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@nxgt/janus-telemetry",
3
+ "version": "0.1.0",
4
+ "description": "Observability for @nxgt/janus on @nxgt/telemetry: a span per flow and per permission check, and the security events worth an audit trail — never a login, an e-mail, a password, a session token, a one-time token or a session id",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "keywords": [
25
+ "identity",
26
+ "authentication",
27
+ "observability",
28
+ "telemetry",
29
+ "janus",
30
+ "typescript"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/softistx/nxgt-janus.git",
35
+ "directory": "packages/janus-telemetry"
36
+ },
37
+ "publishConfig": {
38
+ "registry": "https://registry.npmjs.org",
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "bun run ../../build.ts",
43
+ "test": "bun test src",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "nxgt": {
47
+ "entrypoints": [
48
+ "src/index.ts"
49
+ ]
50
+ },
51
+ "devDependencies": {
52
+ "@nxgt/janus": "^0.2.2",
53
+ "@nxgt/telemetry": "0.2.1",
54
+ "@types/bun": "^1.4.2",
55
+ "zod": "^4.6.5"
56
+ },
57
+ "peerDependencies": {
58
+ "@nxgt/janus": "^0.2.2",
59
+ "@nxgt/telemetry": "^0.2.1",
60
+ "typescript": "^6.0.3"
61
+ }
62
+ }