@terpjs/contract 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/openapi.json +1807 -0
- package/package.json +35 -0
- package/scripts/build-tokens.mjs +80 -0
- package/src/auth.ts +43 -0
- package/src/client.ts +28 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +45 -0
- package/src/schema.d.ts +1428 -0
- package/src/tokens.css +113 -0
- package/tokens.dark.json +34 -0
- package/tokens.json +73 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@terpjs/contract",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./schema": "./src/schema.d.ts",
|
|
9
|
+
"./openapi.json": "./openapi.json",
|
|
10
|
+
"./tokens.css": "./src/tokens.css"
|
|
11
|
+
},
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"generate": "openapi-typescript ./openapi.json --output ./src/schema.d.ts",
|
|
15
|
+
"tokens": "node scripts/build-tokens.mjs",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"openapi-typescript": "^7.13.0",
|
|
20
|
+
"style-dictionary": "^5.5.0",
|
|
21
|
+
"typescript": "^5.9.3"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"openapi-fetch": "^0.17.0"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/AITT-NL/terp-framework.git",
|
|
30
|
+
"directory": "packages/frontend/contract"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token build: tokens.json (light, the framework-agnostic source of truth) +
|
|
3
|
+
* tokens.dark.json (the dark colour overrides) -> src/tokens.css (design §7.1, item 2).
|
|
4
|
+
*
|
|
5
|
+
* The output is one stylesheet with three blocks:
|
|
6
|
+
* 1. `:root` — the light theme (every token).
|
|
7
|
+
* 2. `[data-theme="dark"]` — the dark colour overrides (explicit choice).
|
|
8
|
+
* 3. `@media (prefers-color-scheme: dark)` scoped to `:root:not([data-theme="light"])`
|
|
9
|
+
* — the OS preference applies automatically unless the app pinned a theme.
|
|
10
|
+
*
|
|
11
|
+
* Apps opt in/out per user via the `data-theme` attribute on <html> (react-core's
|
|
12
|
+
* `ThemeProvider` manages it); with no attribute the OS preference wins. Regenerate with
|
|
13
|
+
* `npm run -w @terpjs/contract tokens`; the frontend CI gate fails on drift.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import StyleDictionary from "style-dictionary";
|
|
21
|
+
|
|
22
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const buildDir = mkdtempSync(join(tmpdir(), "terp-tokens-"));
|
|
24
|
+
|
|
25
|
+
async function buildCss(sourceFile, outputFile) {
|
|
26
|
+
const sd = new StyleDictionary({
|
|
27
|
+
source: [join(packageRoot, sourceFile)],
|
|
28
|
+
platforms: {
|
|
29
|
+
css: {
|
|
30
|
+
transformGroup: "css",
|
|
31
|
+
buildPath: buildDir + "/",
|
|
32
|
+
files: [
|
|
33
|
+
{
|
|
34
|
+
destination: outputFile,
|
|
35
|
+
format: "css/variables",
|
|
36
|
+
options: { outputReferences: true },
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
await sd.buildAllPlatforms();
|
|
43
|
+
return readFileSync(join(buildDir, outputFile), "utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The bare `--x: y;` declaration lines of a generated `:root { ... }` block. */
|
|
47
|
+
function declarations(css) {
|
|
48
|
+
return css
|
|
49
|
+
.split("\n")
|
|
50
|
+
.filter((line) => line.trimStart().startsWith("--"))
|
|
51
|
+
.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const light = declarations(await buildCss("tokens.json", "tokens.light.css"));
|
|
55
|
+
const dark = declarations(await buildCss("tokens.dark.json", "tokens.dark.css"));
|
|
56
|
+
rmSync(buildDir, { recursive: true, force: true });
|
|
57
|
+
|
|
58
|
+
const output = `/**
|
|
59
|
+
* Do not edit directly, this file was auto-generated.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
:root {
|
|
63
|
+
${light}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Dark theme: an explicit user/app choice via <html data-theme="dark">. */
|
|
67
|
+
[data-theme='dark'] {
|
|
68
|
+
${dark}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Dark theme: the OS preference, unless the app pinned light explicitly. */
|
|
72
|
+
@media (prefers-color-scheme: dark) {
|
|
73
|
+
:root:not([data-theme='light']) {
|
|
74
|
+
${dark.replace(/^ {2}/gm, " ")}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
writeFileSync(join(packageRoot, "src", "tokens.css"), output);
|
|
80
|
+
console.log("wrote src/tokens.css");
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { components } from "./schema";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The auth/session contract every frontend stack implements identically (design §7.1,
|
|
5
|
+
* item 4): token handling, the current user, and UI permission gating that honours the
|
|
6
|
+
* backend roles. Implementations wrap the generated `@terpjs/contract` client; they never
|
|
7
|
+
* invent their own auth semantics.
|
|
8
|
+
*
|
|
9
|
+
* Wire types (`Credentials`, `AccessToken`) are reused from the generated schema, so the
|
|
10
|
+
* contract cannot drift from the backend auth surface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Login body — the backend `LoginRequest` (email + password). */
|
|
14
|
+
export type Credentials = components["schemas"]["LoginRequest"];
|
|
15
|
+
|
|
16
|
+
/** Login response — the backend `AccessToken` (`access_token` + `token_type`). */
|
|
17
|
+
export type AccessToken = components["schemas"]["AccessToken"];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The signed-in user as the UI needs it — the backend `CurrentUser`, returned by
|
|
21
|
+
* `GET /api/v1/me` (ADR 0044) and reused from the generated schema so it cannot drift.
|
|
22
|
+
* The role is on the wire as the numeric `role_rank` (the comparable primitive the UI
|
|
23
|
+
* gates on) plus a display `role_name` (ADR 0004 / 0022).
|
|
24
|
+
*/
|
|
25
|
+
export type CurrentUser = components["schemas"]["CurrentUser"];
|
|
26
|
+
|
|
27
|
+
/** Coarse capability the UI gates on; the adapter maps it to the backend role tiers. */
|
|
28
|
+
export type Action = "read" | "write" | "admin";
|
|
29
|
+
|
|
30
|
+
export interface AuthSession {
|
|
31
|
+
/** Exchange credentials for a session; resolves to the signed-in user. */
|
|
32
|
+
login(credentials: Credentials): Promise<CurrentUser>;
|
|
33
|
+
/** End the session (revokes the token at the backend, ADR 0031). */
|
|
34
|
+
logout(): Promise<void>;
|
|
35
|
+
/** Re-validate the stored token; resolves to the user, or null if signed out. */
|
|
36
|
+
refresh(): Promise<CurrentUser | null>;
|
|
37
|
+
/** The cached current user, or null when signed out. */
|
|
38
|
+
currentUser(): CurrentUser | null;
|
|
39
|
+
/** True while the provider is resolving an existing session (e.g. boot refresh). */
|
|
40
|
+
loading(): boolean;
|
|
41
|
+
/** UI gate: may the current user perform `action`? (Honours the backend roles.) */
|
|
42
|
+
can(action: Action): boolean;
|
|
43
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import createClient, { type Client, type ClientOptions } from "openapi-fetch";
|
|
2
|
+
|
|
3
|
+
import type { paths } from "./schema";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a fully typed Terp API client from the backend OpenAPI contract.
|
|
7
|
+
*
|
|
8
|
+
* Every path, method, path/query parameter, request body and response is type-checked
|
|
9
|
+
* against `openapi.json` (regenerated into `./schema`), so a call that does not match
|
|
10
|
+
* the backend fails to compile — the frontend client cannot drift from the API.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const api = createTerpClient({ baseUrl: "https://api.example.com" });
|
|
14
|
+
* const { data, error } = await api.GET("/api/v1/notes/", { params: { query: { skip: 0 } } });
|
|
15
|
+
*/
|
|
16
|
+
export function createTerpClient(options: ClientOptions) {
|
|
17
|
+
return createClient<paths>(options);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TerpClient = ReturnType<typeof createTerpClient>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A typed Terp client for ANY app's generated `paths` (the openapi-typescript output of
|
|
24
|
+
* that app's own OpenAPI document). `@terpjs/react-core`'s `useTerpClient<paths>()` returns
|
|
25
|
+
* this, so a client app types calls to its OWN endpoints — not only the base-profile paths
|
|
26
|
+
* bundled in this package. The runtime client is the same; only the compile-time view differs.
|
|
27
|
+
*/
|
|
28
|
+
export type TerpClientFor<AppPaths extends {}> = Client<AppPaths>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Public surface of @terpjs/contract — the generated, drift-checked API contract.
|
|
2
|
+
export { createTerpClient } from "./client";
|
|
3
|
+
export type { TerpClient, TerpClientFor } from "./client";
|
|
4
|
+
export type { components, operations, paths } from "./schema";
|
|
5
|
+
|
|
6
|
+
// Stack-agnostic UI contract: the module/route/nav manifest and the auth/session shape.
|
|
7
|
+
export { defineModuleManifest } from "./manifest";
|
|
8
|
+
export type { ModuleManifest, ModuleRoute, NavItem, RoleName } from "./manifest";
|
|
9
|
+
export type { AccessToken, Action, AuthSession, Credentials, CurrentUser } from "./auth";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack-agnostic description of a module's UI surface (design §7.1, item 3).
|
|
3
|
+
*
|
|
4
|
+
* Decision: the manifest *types* are the shared contract (defined here), but each
|
|
5
|
+
* frontend module authors its own manifest *values* — view names and navigation are
|
|
6
|
+
* frontend concerns, so they are not emitted from the backend `ModuleSpec`. Each stack
|
|
7
|
+
* ships a thin adapter that realises a manifest into its own router + sidebar (TanStack
|
|
8
|
+
* Router for React, SvelteKit for Svelte). `role` references the app's backend role
|
|
9
|
+
* names; the stack's auth adapter resolves a name to the backend role rank.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A role name as understood by the app's backend (e.g. "viewer" | "editor" | "admin"). */
|
|
13
|
+
export type RoleName = string;
|
|
14
|
+
|
|
15
|
+
export interface ModuleRoute {
|
|
16
|
+
/** URL path the route mounts at, e.g. "/billing" or "/billing/:id". */
|
|
17
|
+
path: string;
|
|
18
|
+
/** Stack-agnostic view identifier the adapter resolves to a component. */
|
|
19
|
+
view: string;
|
|
20
|
+
/** Minimum role required to see the route; omitted = any authenticated user. */
|
|
21
|
+
role?: RoleName;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NavItem {
|
|
25
|
+
/** Sidebar label. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Destination path; should match a {@link ModuleRoute.path}. */
|
|
28
|
+
to: string;
|
|
29
|
+
/** Icon identifier the stack maps to its own icon set. */
|
|
30
|
+
icon?: string;
|
|
31
|
+
/** Minimum role required to show the nav item. */
|
|
32
|
+
role?: RoleName;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ModuleManifest {
|
|
36
|
+
/** Module name; matches the backend module / API prefix (e.g. "notes"). */
|
|
37
|
+
name: string;
|
|
38
|
+
routes: ModuleRoute[];
|
|
39
|
+
nav?: NavItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Identity helper that gives module authors full type-checking on a manifest literal. */
|
|
43
|
+
export function defineModuleManifest(manifest: ModuleManifest): ModuleManifest {
|
|
44
|
+
return manifest;
|
|
45
|
+
}
|