@checkstack/tips-common 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # @checkstack/tips-common
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3547670: Add `@checkstack/tips-*` — first-run tip and onboarding infrastructure for
8
+ the frontends.
9
+
10
+ Three new packages:
11
+
12
+ - `@checkstack/tips-common` — RPC contract (`tipsContract`), `TipsApi`
13
+ client definition, and zod schemas. Fully-qualified tip IDs have shape
14
+ `<pluginId>.<localTipId>` and are produced exclusively by
15
+ `qualifyTipId(plugin, localId)` — plugins never write the namespace
16
+ themselves, and a local id with a leading or trailing `.` is rejected,
17
+ so one plugin cannot forge or dismiss a tip in another plugin's
18
+ namespace.
19
+ - `@checkstack/tips-backend` — Postgres-backed dismissal store
20
+ (`user_tip_dismissal` with composite PK on `(user_id, tip_id)`),
21
+ `listDismissed` / `dismiss` / `reset` endpoints scoped to the
22
+ requesting user via the auto-auth middleware, and a
23
+ `auth.userDeleted` hook that cleans up dismissals when a user is
24
+ deleted.
25
+ - `@checkstack/tips-frontend` — `<Tip>` (anchored popover) and
26
+ `<TipBanner>` (inline callout) components plus the `useTipState`
27
+ hook. All three accept `{ plugin, id }` (where `plugin` is the
28
+ caller's `pluginMetadata`) and route through `qualifyTipId` so the
29
+ namespace prefix is enforced at the boundary. Persists per-user on
30
+ the server when logged in, and per-browser in `localStorage`
31
+ (`checkstack.tips.dismissed`) when anonymous, with cross-tab sync via
32
+ the `storage` event.
33
+
34
+ `@checkstack/ui`'s `<EmptyState>` gains optional `steps` and `actions`
35
+ props for richer empty-state coaching (numbered onboarding lists +
36
+ primary CTA), and accepts `ReactNode` for `description`. Existing
37
+ callers continue to work unchanged.
38
+
39
+ `@checkstack/test-utils-backend`'s `createMockDb` now also mocks
40
+ `insert().values().onConflictDoNothing()` so routers using upsert-or-skip
41
+ semantics can be unit-tested.
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [42abfff]
46
+ - @checkstack/common@0.9.0
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@checkstack/tips-common",
3
+ "version": "0.2.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/common": "0.8.0",
14
+ "@orpc/contract": "^1.13.14",
15
+ "zod": "^4.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@checkstack/tsconfig": "0.0.7",
19
+ "@checkstack/scripts": "0.3.0",
20
+ "typescript": "^5.7.2"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsgo -b",
24
+ "lint": "bun run lint:code",
25
+ "lint:code": "eslint . --max-warnings 0",
26
+ "test": "bun test"
27
+ },
28
+ "checkstack": {
29
+ "type": "common"
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./rpc-contract";
2
+ export * from "./plugin-metadata";
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the tips plugin.
5
+ * Powers the per-user dismissable tip infrastructure used by all frontends.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "tips",
9
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ LocalTipIdSchema,
4
+ TipIdSchema,
5
+ qualifyTipId,
6
+ } from "./rpc-contract";
7
+
8
+ describe("LocalTipIdSchema", () => {
9
+ it("accepts simple identifiers", () => {
10
+ expect(LocalTipIdSchema.parse("first-run")).toBe("first-run");
11
+ expect(LocalTipIdSchema.parse("systems_create")).toBe("systems_create");
12
+ });
13
+
14
+ it("accepts dotted sub-grouping inside the local part", () => {
15
+ expect(LocalTipIdSchema.parse("systems.create.first-time")).toBe(
16
+ "systems.create.first-time",
17
+ );
18
+ });
19
+
20
+ it("rejects a leading dot — would let a plugin escape its namespace", () => {
21
+ expect(() => LocalTipIdSchema.parse(".other-plugin.tip")).toThrow();
22
+ });
23
+
24
+ it("rejects a trailing dot", () => {
25
+ expect(() => LocalTipIdSchema.parse("systems.")).toThrow();
26
+ });
27
+
28
+ it("rejects whitespace and special characters", () => {
29
+ expect(() => LocalTipIdSchema.parse("systems create")).toThrow();
30
+ expect(() => LocalTipIdSchema.parse("systems!create")).toThrow();
31
+ expect(() => LocalTipIdSchema.parse("systems/create")).toThrow();
32
+ });
33
+
34
+ it("rejects empty strings", () => {
35
+ expect(() => LocalTipIdSchema.parse("")).toThrow();
36
+ });
37
+ });
38
+
39
+ describe("TipIdSchema (fully qualified)", () => {
40
+ it("requires a namespace separator", () => {
41
+ expect(() => TipIdSchema.parse("no-separator")).toThrow();
42
+ });
43
+
44
+ it("accepts well-formed `<plugin>.<local>` IDs", () => {
45
+ expect(TipIdSchema.parse("catalog.systems.create")).toBe(
46
+ "catalog.systems.create",
47
+ );
48
+ });
49
+ });
50
+
51
+ describe("qualifyTipId", () => {
52
+ const catalog = { pluginId: "catalog" };
53
+
54
+ it("prefixes the calling plugin's id", () => {
55
+ expect(qualifyTipId(catalog, "systems.create")).toBe(
56
+ "catalog.systems.create",
57
+ );
58
+ });
59
+
60
+ it("rejects a local id that tries to escape into another namespace", () => {
61
+ // A malicious plugin trying to silently dismiss someone else's tip:
62
+ // `qualifyTipId({ pluginId: "evil" }, ".healthcheck.first-run")` would,
63
+ // without validation, produce `"evil..healthcheck.first-run"` (still
64
+ // distinct, harmless) — but a leading dot is still rejected so the
65
+ // construction is impossible regardless of intent.
66
+ expect(() => qualifyTipId(catalog, ".healthcheck.first-run")).toThrow();
67
+ });
68
+
69
+ it("rejects dangerous separators that could spoof structure", () => {
70
+ expect(() => qualifyTipId(catalog, "../healthcheck/first-run")).toThrow();
71
+ });
72
+
73
+ it("returns a value that itself parses as a fully-qualified TipId", () => {
74
+ const id = qualifyTipId(catalog, "systems.create");
75
+ expect(() => TipIdSchema.parse(id)).not.toThrow();
76
+ });
77
+ });
@@ -0,0 +1,140 @@
1
+ import {
2
+ createClientDefinition,
3
+ proc,
4
+ type PluginMetadata,
5
+ } from "@checkstack/common";
6
+ import { pluginMetadata } from "./plugin-metadata";
7
+ import { z } from "zod";
8
+
9
+ /**
10
+ * Fully-qualified tip identifier — the wire format used in RPC payloads and
11
+ * stored in the database.
12
+ *
13
+ * Shape: `<pluginId>.<localTipId>`. The `.` is the namespace separator and
14
+ * MUST appear at least once. Plugins cannot construct a fully-qualified ID
15
+ * directly — they must go through `qualifyTipId` (or the frontend hooks /
16
+ * components, which call it for them) which prefixes the caller's own
17
+ * `pluginId` and rejects local IDs that contain a `.` themselves. This is
18
+ * what prevents one plugin from forging a tip in another plugin's
19
+ * namespace.
20
+ */
21
+ export const TipIdSchema = z
22
+ .string()
23
+ .min(3)
24
+ .max(200)
25
+ .regex(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9._-]+$/, {
26
+ message:
27
+ "Tip IDs must be `<pluginId>.<localTipId>` and contain only letters, numbers, dots, dashes and underscores",
28
+ });
29
+
30
+ export type TipId = z.infer<typeof TipIdSchema>;
31
+
32
+ /**
33
+ * Local (post-namespace) tip identifier.
34
+ *
35
+ * This is what a plugin author writes in their code — e.g. `"systems.create"`
36
+ * — and explicitly forbids the namespace separator at the start so a plugin
37
+ * cannot author a tip that masquerades as belonging to another plugin.
38
+ *
39
+ * Allowed characters: letters, numbers, dots, dashes, underscores. The dot
40
+ * is allowed *inside* the local part for further sub-grouping (e.g.
41
+ * `"systems.create.first-time"`), but a leading or trailing dot is rejected
42
+ * so the concatenation with `<pluginId>.` always produces a well-formed
43
+ * fully-qualified ID with exactly one separator at the boundary.
44
+ */
45
+ export const LocalTipIdSchema = z
46
+ .string()
47
+ .min(1)
48
+ .max(150)
49
+ .regex(/^[a-zA-Z0-9_-](?:[a-zA-Z0-9._-]*[a-zA-Z0-9_-])?$/, {
50
+ message:
51
+ "Local tip IDs may contain letters, numbers, dots, dashes and underscores, and must not start or end with a dot",
52
+ });
53
+
54
+ export type LocalTipId = z.infer<typeof LocalTipIdSchema>;
55
+
56
+ /**
57
+ * Qualify a local tip ID with the calling plugin's namespace.
58
+ *
59
+ * This is the ONLY supported way to produce a fully-qualified tip ID from
60
+ * plugin code. Both the local ID and the resulting fully-qualified ID are
61
+ * validated at runtime, so:
62
+ *
63
+ * - A plugin cannot pass a local ID containing a leading dot (e.g.
64
+ * `".other-plugin.tip"`) to escape into another namespace.
65
+ * - The frontend hooks and components (`useTipState`, `<Tip>`,
66
+ * `<TipBanner>`) accept only `(plugin, localId)` pairs and route through
67
+ * this helper, so they cannot bypass namespacing either.
68
+ */
69
+ export const qualifyTipId = (
70
+ plugin: Pick<PluginMetadata, "pluginId">,
71
+ localId: string,
72
+ ): TipId => {
73
+ const parsedLocal = LocalTipIdSchema.parse(localId);
74
+ const fullyQualified = `${plugin.pluginId}.${parsedLocal}`;
75
+ return TipIdSchema.parse(fullyQualified);
76
+ };
77
+
78
+ /** Server-returned dismissed tip record. */
79
+ export const DismissedTipSchema = z.object({
80
+ tipId: TipIdSchema,
81
+ dismissedAt: z.date(),
82
+ });
83
+ export type DismissedTip = z.infer<typeof DismissedTipSchema>;
84
+
85
+ /**
86
+ * Tips RPC Contract.
87
+ *
88
+ * All endpoints are scoped to the requesting user — there is no cross-user
89
+ * read or admin surface.
90
+ */
91
+ export const tipsContract = {
92
+ /** List tip IDs the current user has dismissed. */
93
+ listDismissed: proc({
94
+ operationType: "query",
95
+ userType: "user",
96
+ access: [],
97
+ }).output(
98
+ z.object({
99
+ dismissed: z.array(DismissedTipSchema),
100
+ }),
101
+ ),
102
+
103
+ /** Mark a tip as dismissed for the current user. Idempotent. */
104
+ dismiss: proc({
105
+ operationType: "mutation",
106
+ userType: "user",
107
+ access: [],
108
+ })
109
+ .input(
110
+ z.object({
111
+ tipId: TipIdSchema,
112
+ }),
113
+ )
114
+ .output(z.void()),
115
+
116
+ /**
117
+ * Reset (un-dismiss) tips for the current user.
118
+ *
119
+ * If `tipIds` is omitted, ALL of the user's dismissed tips are cleared
120
+ * (used by the "replay onboarding" affordance).
121
+ */
122
+ reset: proc({
123
+ operationType: "mutation",
124
+ userType: "user",
125
+ access: [],
126
+ })
127
+ .input(
128
+ z.object({
129
+ tipIds: z.array(TipIdSchema).optional(),
130
+ }),
131
+ )
132
+ .output(z.void()),
133
+ };
134
+
135
+ export type TipsContract = typeof tipsContract;
136
+
137
+ /**
138
+ * Client definition for type-safe `rpcApi.forPlugin(TipsApi)` usage.
139
+ */
140
+ export const TipsApi = createClientDefinition(tipsContract, pluginMetadata);
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../common"
9
+ }
10
+ ]
11
+ }