@ozvi/sdk 0.0.0-stage → 1.0.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 Ozvi contributors
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 CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @ozvi/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sign [Ozvi](https://ozvi.io) SSO tokens in your backend, so people who are signed in to your app are signed in to the Ozvi widget and board too. Node.js 18+, zero dependencies, MIT.
4
+
5
+ ```sh
6
+ npm i @ozvi/sdk
7
+ ```
8
+
9
+ ```js
10
+ import { signOzviToken } from "@ozvi/sdk";
11
+
12
+ // The widget's token-url, e.g. app.get("/api/ozvi-token", …) or app/api/ozvi-token/route.ts in Next.js
13
+ const token = signOzviToken(
14
+ { id: user.id, email: user.email, name: user.name,
15
+ traits: { company: org.name, plan: org.plan, mrr: 2900 } }, // shown to your team only
16
+ process.env.OZVI_SSO_SECRET, // board settings → Sign-in
17
+ );
18
+ ```
19
+
20
+ - `id` (required) is the person's stable id in your app; `role` can be `member` (default), `team` or `admin`; `locale` sets the board language.
21
+ - Tokens live 10 minutes by default (`{ expiresIn }` in seconds); the widget asks for a fresh one when it needs it.
22
+ - `verifyOzviToken(token, secret)` checks a token the way Ozvi does, handy in tests.
23
+
24
+ The token is a standard HS256 JWT, so any language can make one without this package: see [Sign people in](https://ozvi.io/docs/widget#sign-in) for Python and PHP.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Host-app SSO: your backend signs who the user is, Ozvi trusts it.
3
+ * HS256 JWT with a shared secret (OZVI_SSO_SECRET). No round-trip, no Ozvi account needed.
4
+ *
5
+ * const token = signOzviToken({ id: user.id, email: user.email, name: user.name,
6
+ * locale: "en", traits: { company: "Acme", plan: "pro", mrr: 2900 } }, process.env.OZVI_SSO_SECRET!);
7
+ * // pass it to the widget: <ozvi-widget sso-token="..."> or https://feedback.example.com/sso?token=...
8
+ *
9
+ * `locale` (optional) forces the board language for this user ("sk" | "cs" | "en"), so the board
10
+ * matches your app. Ignored when the board does not have that language enabled.
11
+ */
12
+ export interface OzviUser {
13
+ /** Stable id in YOUR system. Ozvi keys the user on (project, id), never on e-mail alone. */
14
+ id: string;
15
+ email?: string;
16
+ /**
17
+ * true only if YOUR app has verified that this person owns `email` (a confirmation link or code).
18
+ * Ozvi then treats them as the same person as someone who signs in to the board with that e-mail
19
+ * by code (one account, not two); accounts with team or admin rights are never joined. Omit it (or
20
+ * false) for addresses you have not verified: Ozvi then keeps the two accounts apart.
21
+ */
22
+ emailVerified?: boolean;
23
+ name?: string;
24
+ avatarUrl?: string;
25
+ /** "team" can triage and write internal notes, "admin" can also change settings. Defaults to "member". */
26
+ role?: "member" | "team" | "admin";
27
+ /** UI language on the board and in e-mails: "sk", "cs" or "en". Omit to let the browser decide. */
28
+ locale?: "sk" | "cs" | "en";
29
+ /**
30
+ * Customer data shown only to your team (item page "who wants it", ideas sorted by MRR, plan filter, MCP for
31
+ * triage tokens). Known names: `company`, `companyId` (people with the same id count as one company),
32
+ * `plan`, `mrr` (monthly revenue in cents, integer), `currency` (ISO 4217, default "EUR"). Other keys are
33
+ * stored as they are (max 30, strings up to 200 chars). Omit `traits` to keep the stored ones; `{}` clears them.
34
+ */
35
+ traits?: OzviTraits;
36
+ }
37
+ export interface OzviTraits {
38
+ company?: string;
39
+ companyId?: string;
40
+ plan?: string;
41
+ /** Monthly recurring revenue in cents (2900 = 29.00). */
42
+ mrr?: number;
43
+ /** ISO 4217 currency of `mrr`; "EUR" when missing. */
44
+ currency?: string;
45
+ [key: string]: string | number | boolean | null | undefined;
46
+ }
47
+ export interface OzviClaims extends OzviUser {
48
+ iat: number;
49
+ exp: number;
50
+ }
51
+ export interface SignOptions {
52
+ /** Token lifetime in seconds. Short by default — tokens are minted per page load. */
53
+ expiresIn?: number;
54
+ now?: () => number;
55
+ }
56
+ export declare function signOzviToken(user: OzviUser, secret: string, opts?: SignOptions): string;
57
+ export declare class OzviTokenError extends Error {
58
+ readonly reason: "malformed" | "algorithm" | "signature" | "expired" | "claims";
59
+ constructor(reason: "malformed" | "algorithm" | "signature" | "expired" | "claims");
60
+ }
61
+ /** Used by the Ozvi server. Exported so integrators can test their setup locally. */
62
+ export declare function verifyOzviToken(token: string, secret: string, opts?: {
63
+ now?: () => number;
64
+ leewaySeconds?: number;
65
+ }): OzviClaims;
package/dist/index.js ADDED
@@ -0,0 +1,59 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ const MIN_SECRET = 32;
3
+ const b64url = (b) => Buffer.from(b).toString("base64url");
4
+ const HEADER = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
5
+ function assertSecret(secret) {
6
+ if (typeof secret !== "string" || secret.length < MIN_SECRET) {
7
+ throw new Error(`Ozvi SSO secret must be at least ${MIN_SECRET} characters`);
8
+ }
9
+ }
10
+ export function signOzviToken(user, secret, opts = {}) {
11
+ assertSecret(secret);
12
+ if (!user.id)
13
+ throw new Error("Ozvi SSO token needs user.id");
14
+ const iat = Math.floor((opts.now?.() ?? Date.now()) / 1000);
15
+ const claims = { ...user, iat, exp: iat + (opts.expiresIn ?? 600) };
16
+ const body = `${HEADER}.${b64url(JSON.stringify(claims))}`;
17
+ return `${body}.${createHmac("sha256", secret).update(body).digest("base64url")}`;
18
+ }
19
+ export class OzviTokenError extends Error {
20
+ reason;
21
+ constructor(reason) {
22
+ super(`Invalid Ozvi SSO token: ${reason}`);
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ /** Used by the Ozvi server. Exported so integrators can test their setup locally. */
27
+ export function verifyOzviToken(token, secret, opts = {}) {
28
+ assertSecret(secret);
29
+ const parts = token.split(".");
30
+ if (parts.length !== 3)
31
+ throw new OzviTokenError("malformed");
32
+ const [h, p, s] = parts;
33
+ let header;
34
+ try {
35
+ header = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
36
+ }
37
+ catch {
38
+ throw new OzviTokenError("malformed");
39
+ }
40
+ if (header.alg !== "HS256")
41
+ throw new OzviTokenError("algorithm");
42
+ const expected = createHmac("sha256", secret).update(`${h}.${p}`).digest();
43
+ const given = Buffer.from(s, "base64url");
44
+ if (given.length !== expected.length || !timingSafeEqual(given, expected))
45
+ throw new OzviTokenError("signature");
46
+ let claims;
47
+ try {
48
+ claims = JSON.parse(Buffer.from(p, "base64url").toString("utf8"));
49
+ }
50
+ catch {
51
+ throw new OzviTokenError("malformed");
52
+ }
53
+ if (typeof claims.id !== "string" || !claims.id || typeof claims.exp !== "number")
54
+ throw new OzviTokenError("claims");
55
+ const now = Math.floor((opts.now?.() ?? Date.now()) / 1000);
56
+ if (claims.exp + (opts.leewaySeconds ?? 30) < now)
57
+ throw new OzviTokenError("expired");
58
+ return claims;
59
+ }
package/dist/mask.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Masking for what the widget captures (network log, action timeline). Runs in the browser BEFORE
3
+ * anything is kept in memory, and again on the Ozvi server as a second line of defence.
4
+ * Dependency-free and small: it ships inside widget.js (20 kB gzip budget).
5
+ */
6
+ export declare const MASK = "***";
7
+ /** Names of query parameters, JSON keys and form fields whose values never leave the browser. */
8
+ export declare const SECRET_NAME: RegExp;
9
+ /** Free text: JWTs, Authorization-style credentials, e-mail addresses, IBANs and card numbers. */
10
+ export declare function maskText(s: string): string;
11
+ /**
12
+ * A request or page URL with secrets removed: user:password@ dropped, secret-named query and fragment
13
+ * parameters masked, tokens/e-mails/card numbers in the path or values masked. Relative URLs stay relative.
14
+ */
15
+ export declare function maskUrl(raw: string): string;
16
+ /**
17
+ * A request or response body: JSON gets secret-named keys masked (at any depth) and secret-looking
18
+ * values masked; a URL-encoded form gets the same per field; anything else is masked as free text.
19
+ * The result is cut to `limit` characters.
20
+ */
21
+ export declare function maskBody(body: string, limit?: number): string;
22
+ /**
23
+ * Where a text contains something that must not appear on a screenshot: JWTs, credentials,
24
+ * e-mail addresses, IBANs, card numbers (Luhn-checked) and phone numbers. Sorted, non-overlapping
25
+ * [start, end) ranges; the widget covers them with a solid box in the screenshot's copy of the page.
26
+ */
27
+ export declare function sensitiveRanges(s: string): [number, number][];
package/dist/mask.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Masking for what the widget captures (network log, action timeline). Runs in the browser BEFORE
3
+ * anything is kept in memory, and again on the Ozvi server as a second line of defence.
4
+ * Dependency-free and small: it ships inside widget.js (20 kB gzip budget).
5
+ */
6
+ export const MASK = "***";
7
+ /** Names of query parameters, JSON keys and form fields whose values never leave the browser. */
8
+ export const SECRET_NAME = /token|secret|passw|pwd|key|auth|session|card|iban|cvv|cvc|otp|pin$|signature|cookie|csrf|xsrf|jwt|bearer|ssn|credential/i;
9
+ const JWT = /\beyJ[\w-]{6,}\.[\w-]{6,}\.[\w-]*/g;
10
+ const BEARER = /\b(Bearer|Basic|Token)\s+[\w~+/.=-]{8,}/gi;
11
+ const EMAIL = /[\w.+-]+@[\w-]+(\.[\w-]+)+/g;
12
+ const IBAN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,4})?\b/g;
13
+ const CARD = /\b\d(?:[ -]?\d){12,18}\b/g;
14
+ /** Luhn check, so long numbers (timestamps, ids) are not mistaken for card numbers. */
15
+ function luhn(digits) {
16
+ let sum = 0;
17
+ for (let i = 0; i < digits.length; i++) {
18
+ let d = +digits[digits.length - 1 - i];
19
+ if (i % 2) {
20
+ d *= 2;
21
+ if (d > 9)
22
+ d -= 9;
23
+ }
24
+ sum += d;
25
+ }
26
+ return sum % 10 === 0;
27
+ }
28
+ /** Free text: JWTs, Authorization-style credentials, e-mail addresses, IBANs and card numbers. */
29
+ export function maskText(s) {
30
+ return s
31
+ .replace(JWT, MASK)
32
+ .replace(BEARER, (_, kind) => `${kind} ${MASK}`)
33
+ .replace(EMAIL, MASK)
34
+ .replace(IBAN, MASK)
35
+ .replace(CARD, (m) => {
36
+ const d = m.replace(/\D/g, "");
37
+ return d.length >= 13 && d.length <= 19 && luhn(d) ? MASK : m;
38
+ });
39
+ }
40
+ /** `a=1&token=x` → `a=1&token=***`. Keeps the parameter names (useful for debugging), masks values. */
41
+ function maskParams(qs) {
42
+ return qs.split("&").map((pair) => {
43
+ const i = pair.indexOf("=");
44
+ if (i < 0)
45
+ return maskText(pair);
46
+ let name = pair.slice(0, i);
47
+ try {
48
+ name = decodeURIComponent(name.replace(/\+/g, " "));
49
+ }
50
+ catch { /* keep raw */ }
51
+ if (SECRET_NAME.test(name))
52
+ return `${pair.slice(0, i)}=${MASK}`;
53
+ let v = pair.slice(i + 1);
54
+ try {
55
+ v = decodeURIComponent(v.replace(/\+/g, " "));
56
+ }
57
+ catch { /* keep raw */ }
58
+ const masked = maskText(v);
59
+ return masked === v ? pair : `${pair.slice(0, i)}=${encodeURIComponent(masked).replace(/%2A/g, "*")}`;
60
+ }).join("&");
61
+ }
62
+ /**
63
+ * A request or page URL with secrets removed: user:password@ dropped, secret-named query and fragment
64
+ * parameters masked, tokens/e-mails/card numbers in the path or values masked. Relative URLs stay relative.
65
+ */
66
+ export function maskUrl(raw) {
67
+ const s = String(raw);
68
+ const m = /^([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/s.exec(s);
69
+ if (!m)
70
+ return maskText(s);
71
+ let [, path = "", query, hash] = m;
72
+ path = path.replace(/^([a-z][\w+.-]*:\/\/)[^/@]*@/i, "$1");
73
+ let decoded = path;
74
+ try {
75
+ decoded = decodeURIComponent(path);
76
+ }
77
+ catch { /* keep raw */ }
78
+ // An e-mail or token in the path (`/users/jane@x.com`, `/reset/eyJ…`) is masked even when percent-encoded.
79
+ let out = maskText(decoded) === decoded ? path : maskText(decoded);
80
+ if (query !== undefined)
81
+ out += `?${maskParams(query)}`;
82
+ if (hash !== undefined) {
83
+ // OAuth implicit flow puts tokens in the fragment: `#access_token=…` or `#/cb?token=…`.
84
+ const q = hash.indexOf("?");
85
+ const [lead, params] = q >= 0 ? [hash.slice(0, q + 1), hash.slice(q + 1)] : /^[!/]*[^=&/]*=/.test(hash) ? ["", hash] : [hash, ""];
86
+ out += `#${maskText(lead)}${params ? maskParams(params) : ""}`;
87
+ }
88
+ return out;
89
+ }
90
+ function maskValue(v, depth) {
91
+ if (typeof v === "string")
92
+ return maskText(v);
93
+ if (!v || typeof v !== "object" || depth > 20)
94
+ return v;
95
+ if (Array.isArray(v))
96
+ return v.map((x) => maskValue(x, depth + 1));
97
+ const out = {};
98
+ for (const [k, x] of Object.entries(v))
99
+ out[k] = SECRET_NAME.test(k) && x !== null && x !== "" ? MASK : maskValue(x, depth + 1);
100
+ return out;
101
+ }
102
+ /**
103
+ * A request or response body: JSON gets secret-named keys masked (at any depth) and secret-looking
104
+ * values masked; a URL-encoded form gets the same per field; anything else is masked as free text.
105
+ * The result is cut to `limit` characters.
106
+ */
107
+ export function maskBody(body, limit = 2000) {
108
+ const s = String(body);
109
+ let out;
110
+ const t = s.trim();
111
+ if (t.startsWith("{") || t.startsWith("[")) {
112
+ try {
113
+ out = JSON.stringify(maskValue(JSON.parse(t), 0));
114
+ }
115
+ catch {
116
+ out = maskText(s);
117
+ }
118
+ }
119
+ else if (/^[\w.%+\-[\]]+=[^\s]*(&[\w.%+\-[\]]+=[^\s]*)*$/.test(t)) {
120
+ out = maskParams(t);
121
+ }
122
+ else
123
+ out = maskText(s);
124
+ return out.length > limit ? `${out.slice(0, limit)}…` : out;
125
+ }
126
+ /**
127
+ * Phone numbers for screenshots: 9–15 digits, optional +/00 prefix, spaces, dashes, dots, slashes
128
+ * or brackets between (dates like "12. 11. 2026" have 8 digits and stay readable).
129
+ */
130
+ const PHONE = /(^|[^\w+(])((?:\+|00)?\(?\d[\d ()./-]{7,20}\d)(?!\w)/g;
131
+ /**
132
+ * Where a text contains something that must not appear on a screenshot: JWTs, credentials,
133
+ * e-mail addresses, IBANs, card numbers (Luhn-checked) and phone numbers. Sorted, non-overlapping
134
+ * [start, end) ranges; the widget covers them with a solid box in the screenshot's copy of the page.
135
+ */
136
+ export function sensitiveRanges(s) {
137
+ const found = [];
138
+ const add = (re, ok = () => true) => {
139
+ for (const m of s.matchAll(re))
140
+ if (ok(m[0]))
141
+ found.push([m.index, m.index + m[0].length]);
142
+ };
143
+ add(JWT);
144
+ add(BEARER);
145
+ add(EMAIL);
146
+ add(IBAN);
147
+ add(CARD, (m) => { const d = m.replace(/\D/g, ""); return d.length >= 13 && d.length <= 19 && luhn(d); });
148
+ for (const m of s.matchAll(PHONE)) {
149
+ const d = m[2].replace(/\D/g, "").length;
150
+ if (d >= 9 && d <= 15) {
151
+ const at = m.index + m[1].length;
152
+ found.push([at, at + m[2].length]);
153
+ }
154
+ }
155
+ found.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
156
+ const out = [];
157
+ for (const r of found) {
158
+ const last = out.at(-1);
159
+ if (last && r[0] < last[1])
160
+ last[1] = Math.max(last[1], r[1]);
161
+ else
162
+ out.push([r[0], r[1]]);
163
+ }
164
+ return out;
165
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@ozvi/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "description": "Sign Ozvi SSO tokens in your backend (Node.js, zero dependencies). MIT.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./mask": {
13
+ "types": "./dist/mask.d.ts",
14
+ "import": "./dist/mask.js"
15
+ }
16
+ },
17
+ "homepage": "https://ozvi.io/docs/widget#sign-in",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/xzamiska/ozvi.git",
21
+ "directory": "packages/sdk"
22
+ },
23
+ "keywords": [
24
+ "ozvi",
25
+ "sso",
26
+ "jwt",
27
+ "feedback"
28
+ ],
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc -p .",
42
+ "test": "vitest run",
43
+ "build": "tsc -p tsconfig.build.json"
44
+ }
6
45
  }