@quatronic/sdk 0.7.0-rc.6 → 0.7.0-rc.7
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/dist/auth/index.js +1 -1
- package/dist/{chunk-G7VPNC6M.js → chunk-5MNPBJYS.js} +11 -5
- package/dist/{chunk-G7VPNC6M.js.map → chunk-5MNPBJYS.js.map} +1 -1
- package/dist/{chunk-MET4SIMB.js → chunk-EK6TGWWT.js} +8 -2
- package/dist/chunk-EK6TGWWT.js.map +1 -0
- package/dist/{chunk-TEBDKNUB.js → chunk-ROHIN74F.js} +2 -2
- package/dist/{chunk-HQT57ITV.js → chunk-ZZTO5Z3N.js} +2 -2
- package/dist/eslint/index.js +22 -2
- package/dist/eslint/index.js.map +1 -1
- package/dist/next/auth/index.js +1 -1
- package/dist/next/auth/index.js.map +1 -1
- package/dist/next/better-auth/index.js +1 -1
- package/dist/next/connections/index.js +3 -3
- package/dist/next/security/index.js +3 -3
- package/dist/next/sentry/client.d.ts +1 -0
- package/dist/next/sentry/client.js +2 -2
- package/dist/next/sentry/register.js +2 -2
- package/dist/node/connections/index.js +3 -3
- package/dist/node/identity/index.js +2 -2
- package/dist/node/sentry/register.js +2 -2
- package/dist/observability/index.d.ts +10 -0
- package/dist/observability/index.js +7 -0
- package/dist/observability/index.js.map +1 -0
- package/migrations/no-raw-fetch/codemod.mjs +97 -0
- package/migrations/no-raw-fetch/instructions.md +68 -0
- package/migrations/no-raw-fetch/meta.json +6 -0
- package/migrations/report-error/codemod.mjs +94 -0
- package/migrations/report-error/instructions.md +46 -0
- package/migrations/report-error/meta.json +6 -0
- package/package.json +12 -3
- package/dist/chunk-MET4SIMB.js.map +0 -1
- package/dist/next/auth/migrations/0001_init.sql +0 -31
- package/dist/next/auth/migrations/0002_id_token.sql +0 -1
- /package/dist/{chunk-TEBDKNUB.js.map → chunk-ROHIN74F.js.map} +0 -0
- /package/dist/{chunk-HQT57ITV.js.map → chunk-ZZTO5Z3N.js.map} +0 -0
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
assertConnections,
|
|
4
4
|
assertInboundConnection,
|
|
5
5
|
connection
|
|
6
|
-
} from "../../chunk-
|
|
6
|
+
} from "../../chunk-ZZTO5Z3N.js";
|
|
7
7
|
import {
|
|
8
8
|
CONNECTION_REJECTED_ATTR,
|
|
9
9
|
CONNECTION_REJECT_REASON_ATTR,
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
ConnectionError,
|
|
12
12
|
DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
13
13
|
requireConnection
|
|
14
|
-
} from "../../chunk-
|
|
14
|
+
} from "../../chunk-ROHIN74F.js";
|
|
15
15
|
import "../../chunk-5XMTUPIY.js";
|
|
16
|
-
import "../../chunk-
|
|
16
|
+
import "../../chunk-EK6TGWWT.js";
|
|
17
17
|
export {
|
|
18
18
|
CONNECTION_REJECTED_ATTR,
|
|
19
19
|
CONNECTION_REJECT_REASON_ATTR,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
parseJsonBounded
|
|
3
3
|
} from "../../chunk-FUPTRHT7.js";
|
|
4
|
-
import {
|
|
5
|
-
proxyMatcher
|
|
6
|
-
} from "../../chunk-X6D3TBNS.js";
|
|
7
4
|
import {
|
|
8
5
|
STATIC_SECURITY_HEADERS
|
|
9
6
|
} from "../../chunk-2HE2D4SY.js";
|
|
7
|
+
import {
|
|
8
|
+
proxyMatcher
|
|
9
|
+
} from "../../chunk-X6D3TBNS.js";
|
|
10
10
|
|
|
11
11
|
// src/next/security/index.ts
|
|
12
12
|
import { NextResponse } from "next/server";
|
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
} from "../../chunk-DYOSRJH3.js";
|
|
4
4
|
import {
|
|
5
5
|
initSentry
|
|
6
|
-
} from "../../chunk-
|
|
7
|
-
import "../../chunk-MET4SIMB.js";
|
|
6
|
+
} from "../../chunk-5MNPBJYS.js";
|
|
8
7
|
import "../../chunk-7ASQHY7O.js";
|
|
8
|
+
import "../../chunk-EK6TGWWT.js";
|
|
9
9
|
|
|
10
10
|
// src/next/sentry/client.ts
|
|
11
11
|
import * as Sentry from "@sentry/nextjs";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
initSentry
|
|
3
|
-
} from "../../chunk-
|
|
4
|
-
import "../../chunk-MET4SIMB.js";
|
|
3
|
+
} from "../../chunk-5MNPBJYS.js";
|
|
5
4
|
import {
|
|
6
5
|
readSentryConfig
|
|
7
6
|
} from "../../chunk-7ASQHY7O.js";
|
|
7
|
+
import "../../chunk-EK6TGWWT.js";
|
|
8
8
|
|
|
9
9
|
// src/next/sentry/register.ts
|
|
10
10
|
import * as Sentry from "@sentry/nextjs";
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
assertConnections,
|
|
4
4
|
assertInboundConnection,
|
|
5
5
|
connection
|
|
6
|
-
} from "../../chunk-
|
|
6
|
+
} from "../../chunk-ZZTO5Z3N.js";
|
|
7
7
|
import {
|
|
8
8
|
CONNECTION_REJECTED_ATTR,
|
|
9
9
|
CONNECTION_REJECT_REASON_ATTR,
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
ConnectionError,
|
|
12
12
|
DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
13
13
|
requireConnection
|
|
14
|
-
} from "../../chunk-
|
|
14
|
+
} from "../../chunk-ROHIN74F.js";
|
|
15
15
|
import "../../chunk-5XMTUPIY.js";
|
|
16
|
-
import "../../chunk-
|
|
16
|
+
import "../../chunk-EK6TGWWT.js";
|
|
17
17
|
export {
|
|
18
18
|
CONNECTION_REJECTED_ATTR,
|
|
19
19
|
CONNECTION_REJECT_REASON_ATTR,
|
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
ConnectionError,
|
|
6
6
|
requireConnection
|
|
7
|
-
} from "../../chunk-
|
|
7
|
+
} from "../../chunk-ROHIN74F.js";
|
|
8
8
|
import {
|
|
9
9
|
defaultEnv,
|
|
10
10
|
readConn
|
|
11
11
|
} from "../../chunk-5XMTUPIY.js";
|
|
12
|
-
import "../../chunk-
|
|
12
|
+
import "../../chunk-EK6TGWWT.js";
|
|
13
13
|
|
|
14
14
|
// src/identity-core/jwksCache.ts
|
|
15
15
|
import { createRemoteJWKSet } from "jose";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
initSentry
|
|
3
|
-
} from "../../chunk-
|
|
4
|
-
import "../../chunk-MET4SIMB.js";
|
|
3
|
+
} from "../../chunk-5MNPBJYS.js";
|
|
5
4
|
import {
|
|
6
5
|
readSentryConfig
|
|
7
6
|
} from "../../chunk-7ASQHY7O.js";
|
|
7
|
+
import "../../chunk-EK6TGWWT.js";
|
|
8
8
|
|
|
9
9
|
// src/node/sentry/register.ts
|
|
10
10
|
import * as Sentry from "@sentry/node";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type LogAttrs = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Hands an error the app caught to the error tracker, with flat context.
|
|
4
|
+
*
|
|
5
|
+
* Without a tracker it writes the same JSON line `getLogger().error` would, so
|
|
6
|
+
* a caught error is never silently dropped in an app that has no Sentry.
|
|
7
|
+
*/
|
|
8
|
+
declare function reportError(err: unknown, ctx?: LogAttrs): void;
|
|
9
|
+
|
|
10
|
+
export { reportError };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// jscodeshift transform for migration no-raw-fetch. Moves the server-side
|
|
2
|
+
// fetch calls that QDC-NET-001 (safe-fetch) flags onto safeFetch, where the
|
|
3
|
+
// move cannot change behaviour beyond what safeFetch exists to change.
|
|
4
|
+
//
|
|
5
|
+
// npx jscodeshift@17.3.0 --parser=tsx \
|
|
6
|
+
// -t node_modules/@quatronic/sdk/migrations/no-raw-fetch/codemod.mjs src
|
|
7
|
+
//
|
|
8
|
+
// It rewrites a call only when it is the global `fetch`, in a server path, with
|
|
9
|
+
// a URL the server did not fix, and an init that SafeFetchInit can hold. Every
|
|
10
|
+
// other call is left exactly as it was; instructions.md covers those.
|
|
11
|
+
|
|
12
|
+
const SERVER_PATHS = ["src/app/api", "src/lib/server", "src/proxy.ts"]
|
|
13
|
+
const MODULE = "@quatronic/sdk/node/net"
|
|
14
|
+
// SafeFetchInit's keys. `cache`, `next`, `redirect`, `credentials` and the
|
|
15
|
+
// rest have no safeFetch equivalent, so a call carrying one needs a person.
|
|
16
|
+
const INIT_KEYS = new Set(["method", "headers", "body", "signal"])
|
|
17
|
+
|
|
18
|
+
function isServerFile(path) {
|
|
19
|
+
const file = path.replace(/\\/g, "/")
|
|
20
|
+
return SERVER_PATHS.some((p) => file.endsWith(`/${p}`) || file.includes(`/${p}/`) || file.startsWith(`${p}/`) || file === p)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mirrors isFixedTarget in src/eslint/ast.ts: a URL the rule does not flag. */
|
|
24
|
+
function isFixed(node) {
|
|
25
|
+
if (!node) return false
|
|
26
|
+
if (node.type === "StringLiteral" || node.type === "Literal") return typeof node.value === "string"
|
|
27
|
+
if (node.type === "TemplateLiteral") return node.expressions.length === 0
|
|
28
|
+
if (node.type === "NewExpression") return node.callee.type === "Identifier" && node.callee.name === "URL" && isFixed(node.arguments[0])
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function keyName(prop) {
|
|
33
|
+
if (prop.computed) return null
|
|
34
|
+
if (prop.key.type === "Identifier") return prop.key.name
|
|
35
|
+
return typeof prop.key.value === "string" ? prop.key.value : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An init safeFetch accepts as is: absent, or a literal of SafeFetchInit keys. */
|
|
39
|
+
function isPortableInit(node) {
|
|
40
|
+
if (!node) return true
|
|
41
|
+
if (node.type !== "ObjectExpression") return false
|
|
42
|
+
return node.properties.every((p) => {
|
|
43
|
+
if (p.type !== "ObjectProperty" && p.type !== "Property") return false
|
|
44
|
+
const key = keyName(p)
|
|
45
|
+
if (!key || !INIT_KEYS.has(key)) return false
|
|
46
|
+
// `headers: new Headers(...)` or a variable may not be a plain record.
|
|
47
|
+
return key !== "headers" || p.value.type === "ObjectExpression"
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function transformer(file, api) {
|
|
52
|
+
if (!isServerFile(file.path)) return undefined
|
|
53
|
+
const j = api.jscodeshift
|
|
54
|
+
const root = j(file.source)
|
|
55
|
+
|
|
56
|
+
// A local or imported `fetch` is not the global one.
|
|
57
|
+
const shadowed =
|
|
58
|
+
root.find(j.Identifier, { name: "fetch" }).filter((p) => {
|
|
59
|
+
const parent = p.parent.node
|
|
60
|
+
return (
|
|
61
|
+
(parent.type === "VariableDeclarator" && parent.id === p.node) ||
|
|
62
|
+
(parent.type.startsWith("Function") && parent.id === p.node) ||
|
|
63
|
+
parent.type === "ImportSpecifier" ||
|
|
64
|
+
parent.type === "ImportDefaultSpecifier" ||
|
|
65
|
+
(parent.type.startsWith("Function") && parent.params.includes(p.node))
|
|
66
|
+
)
|
|
67
|
+
}).length > 0
|
|
68
|
+
if (shadowed) return undefined
|
|
69
|
+
|
|
70
|
+
const calls = root
|
|
71
|
+
.find(j.CallExpression, { callee: { type: "Identifier", name: "fetch" } })
|
|
72
|
+
.filter((p) => p.node.arguments.length >= 1 && p.node.arguments.length <= 2)
|
|
73
|
+
.filter((p) => !isFixed(p.node.arguments[0]) && p.node.arguments[0].type !== "SpreadElement")
|
|
74
|
+
.filter((p) => isPortableInit(p.node.arguments[1]))
|
|
75
|
+
if (calls.length === 0) return undefined
|
|
76
|
+
|
|
77
|
+
calls.forEach((p) => {
|
|
78
|
+
p.node.callee = j.identifier("safeFetch")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const existing = root.find(j.ImportDeclaration, { source: { value: MODULE } })
|
|
82
|
+
const has = existing.find(j.ImportSpecifier, { imported: { name: "safeFetch" } }).length > 0
|
|
83
|
+
if (!has) {
|
|
84
|
+
const spec = j.importSpecifier(j.identifier("safeFetch"))
|
|
85
|
+
const into = existing.filter((p) => p.node.importKind !== "type")
|
|
86
|
+
if (into.length > 0) into.get().node.specifiers.push(spec)
|
|
87
|
+
else {
|
|
88
|
+
const decl = j.importDeclaration([spec], j.literal(MODULE))
|
|
89
|
+
const imports = root.find(j.ImportDeclaration)
|
|
90
|
+
if (imports.length > 0) imports.at(imports.length - 1).insertAfter(decl)
|
|
91
|
+
else root.get().node.program.body.unshift(decl)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return root.toSource({ quote: "double" })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const parser = "tsx"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# no-raw-fetch
|
|
2
|
+
|
|
3
|
+
Server code that fetches a URL it did not fix itself uses `safeFetch` from
|
|
4
|
+
`@quatronic/sdk/node/net`, not the global `fetch`.
|
|
5
|
+
|
|
6
|
+
**Rule:** `qdc/safe-fetch` (QDC-NET-001), in `configs.recommended`. It covers
|
|
7
|
+
`src/app/api`, `src/lib/server` and `src/proxy.ts`.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
A URL built from input can point at the private network: the metadata service,
|
|
12
|
+
another tenant's internal service, the database. `safeFetch` resolves the host,
|
|
13
|
+
refuses private, loopback and link-local addresses, and re-checks every
|
|
14
|
+
redirect. The global `fetch` does none of that.
|
|
15
|
+
|
|
16
|
+
## First, pick the right client
|
|
17
|
+
|
|
18
|
+
| The call goes to | Use |
|
|
19
|
+
|---|---|
|
|
20
|
+
| another module of this app | `connection(alias).fetch` from `@quatronic/sdk/node/connections` |
|
|
21
|
+
| a third-party API the app declares | `integration(alias)` from `@quatronic/sdk/node/integrations` |
|
|
22
|
+
| a URL that comes from input or data | `safeFetch` (this migration) |
|
|
23
|
+
|
|
24
|
+
The codemod only knows the last case. Move the other two by hand first.
|
|
25
|
+
|
|
26
|
+
## Run the codemod
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx jscodeshift@17.3.0 --parser=tsx \
|
|
30
|
+
-t node_modules/@quatronic/sdk/migrations/no-raw-fetch/codemod.mjs src
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It rewrites `fetch(url)` and `fetch(url, init)` to `safeFetch(...)` and adds the
|
|
34
|
+
import, when all of these hold:
|
|
35
|
+
|
|
36
|
+
- the file is in one of the paths above;
|
|
37
|
+
- `fetch` is the global one (not imported or declared in the file);
|
|
38
|
+
- the URL is not a fixed string, which the rule does not flag;
|
|
39
|
+
- `init` is absent or an object literal with only `method`, `headers` (an
|
|
40
|
+
object literal), `body` and `signal`.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const res = await fetch(target, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })
|
|
44
|
+
// →
|
|
45
|
+
const res = await safeFetch(target, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The printer may add semicolons to the lines it touched; run your formatter. Then
|
|
49
|
+
run `pnpm lint` to see the calls it left.
|
|
50
|
+
|
|
51
|
+
## Behaviour that changes
|
|
52
|
+
|
|
53
|
+
Check each rewritten call against these. The codemod cannot.
|
|
54
|
+
|
|
55
|
+
- **https only.** An `http:` URL is refused. Pass `{ protocols: ["https:", "http:"] }` as the third argument only if the target cannot do TLS.
|
|
56
|
+
- **No redirects.** Pass `{ maxRedirects: 3 }` if the call relied on following them. Each hop is checked again.
|
|
57
|
+
- **5 s timeout and 5 MiB response cap** for the whole request. Raise them with `timeoutMs` and `maxBytes`.
|
|
58
|
+
- **A refused target throws `SafeFetchError`** (`reason` says why). Catch it where the old code handled a network error, and answer the caller with a 4xx, not a 500.
|
|
59
|
+
|
|
60
|
+
## By hand
|
|
61
|
+
|
|
62
|
+
| The call has | Do this |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `headers: new Headers(...)` or a variable | Pass a plain object: `Object.fromEntries(headers)`. |
|
|
65
|
+
| a `ReadableStream` or `FormData` body | Read it into a string or `Uint8Array` first. `safeFetch` replays the body on a redirect, so it cannot stream. |
|
|
66
|
+
| `cache`, `next: { revalidate }` | `safeFetch` does not use Next's data cache. Cache the result yourself, or keep `fetch` for a fixed URL (the rule allows that). |
|
|
67
|
+
| `redirect: "manual"` | That is the default. Drop the key. |
|
|
68
|
+
| `credentials`, `mode`, `keepalive` | Browser options. Server code does not need them. Drop the key. |
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// jscodeshift transform for migration report-error. Moves the mechanical
|
|
2
|
+
// Sentry.captureException calls that QDC-OBS-004 flags onto reportError.
|
|
3
|
+
//
|
|
4
|
+
// npx jscodeshift@17.3.0 --parser=tsx \
|
|
5
|
+
// -t node_modules/@quatronic/sdk/migrations/report-error/codemod.mjs src
|
|
6
|
+
//
|
|
7
|
+
// Rewritten: `captureException(err)` and `captureException(err, { extra })`,
|
|
8
|
+
// through a namespace or a named import of any @sentry/* package. A call that
|
|
9
|
+
// passes tags, a level, a scope callback or anything else is left for a person;
|
|
10
|
+
// instructions.md says what to do with each.
|
|
11
|
+
|
|
12
|
+
const SENTRY = /^@sentry\//
|
|
13
|
+
const MODULE = "@quatronic/sdk/observability"
|
|
14
|
+
|
|
15
|
+
function keyName(prop) {
|
|
16
|
+
if (prop.computed) return null
|
|
17
|
+
if (prop.key.type === "Identifier") return prop.key.name
|
|
18
|
+
return typeof prop.key.value === "string" ? prop.key.value : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The context to hand reportError, or false when the hint does not map. */
|
|
22
|
+
function contextOf(hint) {
|
|
23
|
+
if (!hint) return null
|
|
24
|
+
if (hint.type !== "ObjectExpression" || hint.properties.length !== 1) return false
|
|
25
|
+
const only = hint.properties[0]
|
|
26
|
+
if ((only.type !== "ObjectProperty" && only.type !== "Property") || keyName(only) !== "extra") return false
|
|
27
|
+
return only.value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function transformer(file, api) {
|
|
31
|
+
const j = api.jscodeshift
|
|
32
|
+
const root = j(file.source)
|
|
33
|
+
|
|
34
|
+
const namespaces = new Set()
|
|
35
|
+
const named = new Set()
|
|
36
|
+
const sentryImports = root.find(j.ImportDeclaration).filter((p) => SENTRY.test(String(p.node.source.value)))
|
|
37
|
+
sentryImports.forEach((p) => {
|
|
38
|
+
for (const s of p.node.specifiers ?? []) {
|
|
39
|
+
if (s.type === "ImportNamespaceSpecifier" || s.type === "ImportDefaultSpecifier") namespaces.add(s.local.name)
|
|
40
|
+
else if (s.type === "ImportSpecifier" && s.imported.name === "captureException") named.add(s.local.name)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
if (namespaces.size === 0 && named.size === 0) return undefined
|
|
44
|
+
|
|
45
|
+
let changed = 0
|
|
46
|
+
root.find(j.CallExpression).forEach((p) => {
|
|
47
|
+
const c = p.node.callee
|
|
48
|
+
const isCapture =
|
|
49
|
+
(c.type === "Identifier" && named.has(c.name)) ||
|
|
50
|
+
(c.type === "MemberExpression" &&
|
|
51
|
+
!c.computed &&
|
|
52
|
+
c.object.type === "Identifier" &&
|
|
53
|
+
namespaces.has(c.object.name) &&
|
|
54
|
+
c.property.type === "Identifier" &&
|
|
55
|
+
c.property.name === "captureException")
|
|
56
|
+
if (!isCapture) return
|
|
57
|
+
const [err, hint, ...rest] = p.node.arguments
|
|
58
|
+
if (!err || rest.length > 0 || err.type === "SpreadElement") return
|
|
59
|
+
const ctx = contextOf(hint)
|
|
60
|
+
if (ctx === false) return
|
|
61
|
+
p.node.callee = j.identifier("reportError")
|
|
62
|
+
p.node.arguments = ctx ? [err, ctx] : [err]
|
|
63
|
+
changed++
|
|
64
|
+
})
|
|
65
|
+
if (changed === 0) return undefined
|
|
66
|
+
|
|
67
|
+
// Drop a named captureException import nothing uses any more.
|
|
68
|
+
const stillUsed = (name) =>
|
|
69
|
+
root
|
|
70
|
+
.find(j.Identifier, { name })
|
|
71
|
+
.filter((p) => j(p).closest(j.ImportDeclaration).length === 0).length > 0
|
|
72
|
+
sentryImports.forEach((p) => {
|
|
73
|
+
const before = p.node.specifiers.length
|
|
74
|
+
p.node.specifiers = p.node.specifiers.filter(
|
|
75
|
+
(s) => s.type !== "ImportSpecifier" || s.imported.name !== "captureException" || stillUsed(s.local.name),
|
|
76
|
+
)
|
|
77
|
+
if (before > 0 && p.node.specifiers.length === 0) j(p).remove()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const existing = root.find(j.ImportDeclaration, { source: { value: MODULE } })
|
|
81
|
+
if (existing.find(j.ImportSpecifier, { imported: { name: "reportError" } }).length === 0) {
|
|
82
|
+
const spec = j.importSpecifier(j.identifier("reportError"))
|
|
83
|
+
if (existing.length > 0) existing.get().node.specifiers.push(spec)
|
|
84
|
+
else {
|
|
85
|
+
const decl = j.importDeclaration([spec], j.literal(MODULE))
|
|
86
|
+
const imports = root.find(j.ImportDeclaration)
|
|
87
|
+
if (imports.length > 0) imports.at(imports.length - 1).insertAfter(decl)
|
|
88
|
+
else root.get().node.program.body.unshift(decl)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return root.toSource({ quote: "double" })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const parser = "tsx"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# report-error
|
|
2
|
+
|
|
3
|
+
Report a caught error with `reportError` from `@quatronic/sdk/observability`,
|
|
4
|
+
not with `Sentry.captureException`.
|
|
5
|
+
|
|
6
|
+
**Rule:** `qdc/use-report-error` (QDC-OBS-004). This is a test-only migration, so no shipped config switches the rule on; run it explicitly with `eslint --rule "qdc/use-report-error: warn"`.
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
The SDK owns the error tracker: it initialises Sentry and wires it into its
|
|
11
|
+
telemetry hooks. An app that calls `reportError` keeps reporting when that
|
|
12
|
+
wiring changes (another tracker, a different scrubbing policy, a test fake).
|
|
13
|
+
An app that calls Sentry directly has to be edited for each of those changes.
|
|
14
|
+
Without a tracker, `reportError` writes an error line to the console instead of
|
|
15
|
+
dropping the error.
|
|
16
|
+
|
|
17
|
+
Spans, `Sentry.logger` and tags stay `Sentry.*`. Only the capture call moves.
|
|
18
|
+
|
|
19
|
+
## Run the codemod
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx jscodeshift@17.3.0 --parser=tsx \
|
|
23
|
+
-t node_modules/@quatronic/sdk/migrations/report-error/codemod.mjs src
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It rewrites these calls:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
Sentry.captureException(err) // → reportError(err)
|
|
30
|
+
Sentry.captureException(err, { extra: { "qore.invoice.id": id } })
|
|
31
|
+
// → reportError(err, { "qore.invoice.id": id })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It adds the import and removes a named `captureException` import that nothing
|
|
35
|
+
uses any more. Run your formatter (the printer may add semicolons), then
|
|
36
|
+
`pnpm lint` to see what is left.
|
|
37
|
+
|
|
38
|
+
## By hand
|
|
39
|
+
|
|
40
|
+
| You pass | Do this |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `{ tags: {...} }` | Put the tags in the context: `reportError(err, { "qore.area.name": v })`. Keys follow QDC-OBS-002 (`qore.<area>.<name>`) and QDC-OBS-003 (no personal data). |
|
|
43
|
+
| `{ level: "warning" }` or `captureMessage` | It is not an error. Use `Sentry.logger.warn(...)`. |
|
|
44
|
+
| `{ user: ... }` | Leave it out. The SDK sets the user from the session; the context carries ids only. |
|
|
45
|
+
| a scope callback `(scope) => ...` | Move what the callback sets into the context object. |
|
|
46
|
+
| `{ fingerprint: [...] }` | Keep that call as `Sentry.captureException` and suppress the line with a reason: `// eslint-disable-next-line qdc/use-report-error -- custom grouping for <why>`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quatronic/sdk",
|
|
3
|
-
"version": "0.7.0-rc.
|
|
3
|
+
"version": "0.7.0-rc.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"migrations",
|
|
12
13
|
"semgrep",
|
|
13
14
|
"skills",
|
|
14
15
|
"postinstall.mjs"
|
|
@@ -50,6 +51,10 @@
|
|
|
50
51
|
"types": "./dist/node/sentry/register.d.ts",
|
|
51
52
|
"import": "./dist/node/sentry/register.js"
|
|
52
53
|
},
|
|
54
|
+
"./observability": {
|
|
55
|
+
"types": "./dist/observability/index.d.ts",
|
|
56
|
+
"import": "./dist/observability/index.js"
|
|
57
|
+
},
|
|
53
58
|
"./next/config": {
|
|
54
59
|
"types": "./dist/next/config/index.d.ts",
|
|
55
60
|
"import": "./dist/next/config/index.js"
|
|
@@ -127,6 +132,8 @@
|
|
|
127
132
|
"build": "tsup",
|
|
128
133
|
"surface": "node scripts/api-surface.mjs",
|
|
129
134
|
"surface:check": "node scripts/api-surface.mjs --check",
|
|
135
|
+
"migration:lint": "node scripts/migration-lint.mjs",
|
|
136
|
+
"release:manifest": "node scripts/release-manifest.mjs",
|
|
130
137
|
"prepublishOnly": "pnpm build",
|
|
131
138
|
"postinstall": "node postinstall.mjs",
|
|
132
139
|
"test": "vitest run",
|
|
@@ -185,12 +192,13 @@
|
|
|
185
192
|
},
|
|
186
193
|
"devDependencies": {
|
|
187
194
|
"@better-auth/core": "^1.6.24",
|
|
188
|
-
"@sentry/nextjs": "10.75.2",
|
|
189
|
-
"@sentry/node": "10.75.2",
|
|
190
195
|
"@eslint/js": "^10.0.1",
|
|
191
196
|
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
|
197
|
+
"@sentry/nextjs": "10.75.2",
|
|
198
|
+
"@sentry/node": "10.75.2",
|
|
192
199
|
"@testcontainers/postgresql": "^12.0.0",
|
|
193
200
|
"@testing-library/react": "^16.1.0",
|
|
201
|
+
"@types/jscodeshift": "17.3.0",
|
|
194
202
|
"@types/node": "^22.10.2",
|
|
195
203
|
"@types/pg": "^8.11.10",
|
|
196
204
|
"@types/react": "^18.3.17",
|
|
@@ -200,6 +208,7 @@
|
|
|
200
208
|
"drizzle-kit": "^0.30.1",
|
|
201
209
|
"drizzle-orm": "^0.38.2",
|
|
202
210
|
"eslint": "^10.5.0",
|
|
211
|
+
"jscodeshift": "17.3.0",
|
|
203
212
|
"jsdom": "^25.0.1",
|
|
204
213
|
"msw": "^2.7.0",
|
|
205
214
|
"next": "^15.1.0",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/telemetry-hooks/index.ts"],"sourcesContent":["export type LogAttrs = Record<string, unknown>\ntype Flat = Record<string, string | number | boolean>\ntype Level = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nexport interface QoreLogger {\n debug(attrs: LogAttrs, msg: string): void\n info(attrs: LogAttrs, msg: string): void\n warn(attrs: LogAttrs, msg: string): void\n error(attrs: LogAttrs, msg: string): void\n}\n\nexport interface TelemetryHooks {\n log?: Record<Level, (msg: string, attrs?: Flat) => void>\n setUser?: (user: { id: string } | null) => void\n}\n\nconst KEY = Symbol.for(\"@quatronic/sdk.telemetry-hooks\")\ntype Holder = { [KEY]?: TelemetryHooks }\nconst holder = globalThis as Holder\n\nexport function setTelemetryHooks(hooks: TelemetryHooks): void {\n holder[KEY] = hooks\n}\n\nexport function resetTelemetryHooks(): void {\n delete holder[KEY]\n}\n\nexport function flattenAttrs(attrs: LogAttrs): Flat {\n const out: Flat = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null) continue\n if (value instanceof Error) {\n out[\"error.type\"] = value.name\n out[\"error.message\"] = value.message\n } else if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\") {\n out[key] = value\n } else {\n out[key] = JSON.stringify(value)\n }\n }\n return out\n}\n\nfunction emit(level: Level, attrs: LogAttrs, msg: string): void {\n const flat = flattenAttrs(attrs)\n const sink = holder[KEY]?.log\n if (sink) return sink[level](msg, flat)\n const line = JSON.stringify({ level, msg, ...flat })\n if (level === \"error\") console.error(line)\n else if (level === \"warn\") console.warn(line)\n else console.log(line)\n}\n\nconst logger: QoreLogger = {\n debug: (a, m) => emit(\"debug\", a, m),\n info: (a, m) => emit(\"info\", a, m),\n warn: (a, m) => emit(\"warn\", a, m),\n error: (a, m) => emit(\"error\", a, m),\n}\n\nexport function getLogger(): QoreLogger {\n return logger\n}\n\nexport function setUserContext(id: string | null): void {\n holder[KEY]?.setUser?.(id ? { id } : null)\n}\n"],"mappings":";AAgBA,IAAM,MAAM,uBAAO,IAAI,gCAAgC;AAEvD,IAAM,SAAS;AAER,SAAS,kBAAkB,OAA6B;AAC7D,SAAO,GAAG,IAAI;AAChB;AAMO,SAAS,aAAa,OAAuB;AAClD,QAAM,MAAY,CAAC;AACnB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,UAAa,UAAU,KAAM;AAC3C,QAAI,iBAAiB,OAAO;AAC1B,UAAI,YAAY,IAAI,MAAM;AAC1B,UAAI,eAAe,IAAI,MAAM;AAAA,IAC/B,WAAW,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC/F,UAAI,GAAG,IAAI;AAAA,IACb,OAAO;AACL,UAAI,GAAG,IAAI,KAAK,UAAU,KAAK;AAAA,IACjC;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,KAAK,OAAc,OAAiB,KAAmB;AAC9D,QAAM,OAAO,aAAa,KAAK;AAC/B,QAAM,OAAO,OAAO,GAAG,GAAG;AAC1B,MAAI,KAAM,QAAO,KAAK,KAAK,EAAE,KAAK,IAAI;AACtC,QAAM,OAAO,KAAK,UAAU,EAAE,OAAO,KAAK,GAAG,KAAK,CAAC;AACnD,MAAI,UAAU,QAAS,SAAQ,MAAM,IAAI;AAAA,WAChC,UAAU,OAAQ,SAAQ,KAAK,IAAI;AAAA,MACvC,SAAQ,IAAI,IAAI;AACvB;AAEA,IAAM,SAAqB;AAAA,EACzB,OAAO,CAAC,GAAG,MAAM,KAAK,SAAS,GAAG,CAAC;AAAA,EACnC,MAAM,CAAC,GAAG,MAAM,KAAK,QAAQ,GAAG,CAAC;AAAA,EACjC,MAAM,CAAC,GAAG,MAAM,KAAK,QAAQ,GAAG,CAAC;AAAA,EACjC,OAAO,CAAC,GAAG,MAAM,KAAK,SAAS,GAAG,CAAC;AACrC;AAEO,SAAS,YAAwB;AACtC,SAAO;AACT;AAEO,SAAS,eAAe,IAAyB;AACtD,SAAO,GAAG,GAAG,UAAU,KAAK,EAAE,GAAG,IAAI,IAAI;AAC3C;","names":[]}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
CREATE SCHEMA IF NOT EXISTS auth;
|
|
2
|
-
|
|
3
|
-
CREATE TABLE IF NOT EXISTS auth.sessions (
|
|
4
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
5
|
-
user_sub varchar(64) NOT NULL,
|
|
6
|
-
user_email varchar(320),
|
|
7
|
-
organization_id varchar(64),
|
|
8
|
-
organization_alias varchar(255),
|
|
9
|
-
access_token text NOT NULL,
|
|
10
|
-
access_expires_at timestamptz NOT NULL,
|
|
11
|
-
refresh_ct bytea NOT NULL,
|
|
12
|
-
refresh_iv bytea NOT NULL,
|
|
13
|
-
refresh_expires_at timestamptz NOT NULL,
|
|
14
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
15
|
-
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
16
|
-
revoked_at timestamptz
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
CREATE INDEX IF NOT EXISTS ix_auth_sessions_user_sub
|
|
20
|
-
ON auth.sessions(user_sub) WHERE revoked_at IS NULL;
|
|
21
|
-
|
|
22
|
-
CREATE TABLE IF NOT EXISTS auth.login_state (
|
|
23
|
-
state varchar(128) PRIMARY KEY,
|
|
24
|
-
code_verifier varchar(255) NOT NULL,
|
|
25
|
-
redirect_path text NOT NULL DEFAULT '/',
|
|
26
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
27
|
-
expires_at timestamptz NOT NULL
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
CREATE INDEX IF NOT EXISTS ix_auth_login_state_expires
|
|
31
|
-
ON auth.login_state(expires_at);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS id_token text;
|
|
File without changes
|
|
File without changes
|