@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 +46 -0
- package/package.json +31 -0
- package/src/index.ts +2 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/rpc-contract.test.ts +77 -0
- package/src/rpc-contract.ts +140 -0
- package/tsconfig.json +11 -0
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,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);
|