@godxjp/sdk 0.1.0 → 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/README.md CHANGED
@@ -8,27 +8,75 @@ Published for use by GoDX applications. The source repository is private; this p
8
8
 
9
9
  Entries are split by **who owns the contract**, not by what happens to be duplicated. Duplication alone is not a reason to share code; a contract that two sides must agree on is.
10
10
 
11
- | Entry | Contents | Cadence |
12
- | --- | --- | --- |
13
- | `.` — not yet published | Platform contracts: permission DTO, tenant-scoped channel names | Binds every product at once |
14
- | `./laravel` | House conventions for Laravel + React: HTTP client, `ApiError` | Moves faster |
15
- | `./realtime` | Realtime seam: configuration, connection status, channel listeners | Moves faster |
11
+ | Entry | Contents | Cadence |
12
+ | ------------ | ------------------------------------------------------------------ | --------------------------- |
13
+ | `.` | Platform contracts: permission DTO, tenant-scoped channel names | Binds every product at once |
14
+ | `./laravel` | House conventions for Laravel + React: HTTP client, `ApiError` | Moves faster |
15
+ | `./realtime` | Realtime seam: configuration, connection status, channel listeners | Moves faster |
16
16
 
17
- `.` is deliberately **absent from `exports`** until the contracts it would carry are agreed. An empty entry published for the sake of symmetry is the thing to avoid.
17
+ ## Scope — what belongs here, and what does not
18
+
19
+ Entries are decided by **who owns the contract**, not by what happens to be duplicated in two products. Duplication is a symptom; a contract that two sides must agree on is the criterion.
20
+
21
+ The question for any candidate: **is it a shared integration contract owned by the platform, with more than one real consumer, no product-specific workflow, and explicit safety, recovery and compatibility semantics?**
22
+
23
+ ### In scope
24
+
25
+ | Area | What the package carries | Why it qualifies |
26
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | HTTP transport for Laravel hosts | `createClient`, `ApiError`, query encoding, CSRF header, upload progress | Every Laravel + React product needs the identical transport. Two of them had thirty-five raw `fetch()` call sites each hand-rolling headers and error handling |
28
+ | Realtime seam | Configuration, connection status, channel and notification listeners | The transport underneath is expected to change; the seam is what keeps that change to one adapter |
29
+ | Channel names | Branded `ChannelName`, parser, org- and platform-scoped forms | Two-sided contract with the PHP SDK. Both sides must produce and read the identical string |
30
+ | Identity snapshot for the UI | The `/me` shape as a **branded snapshot type** carrying `authoritative: false`, reachable only through one parse gate | The same hook was copied between two products and has already drifted into incompatible role shapes. The PHP side already marks the equivalent call "display snapshot only"; the type is where that has to become unavoidable |
31
+
32
+ ### Out of scope
33
+
34
+ | Area | Why not |
35
+ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | **Anything that stores platform identity** — organization, branch, brand or user rows written into a consumer's own database | The identity a request carries is request-scoped. A stored copy is a second source of truth that goes stale silently, and consumer paths depending on a stored organization id are a known defect class, not a pattern to standardise |
37
+ | **Business-domain state**, including a projection table that can be rebuilt from events | A projection still carries product semantics, schema and lifecycle. Rebuildable is not the same as contract-owned |
38
+ | **Product workflow** — what a notification means, which query to invalidate, what a screen does when an event arrives | The package delivers the event; the product decides what it means. A mapping from event to query key belongs to the application |
39
+ | **Generic React utilities** that happen to be identical in two products | Byte-identical duplication is not a contract. Utilities with no platform meaning stay in the products or in the design system |
40
+ | **Design-system concerns** — components, styling, tokens | `@godxjp/ui` owns those. The core of this package must run in a service with no DOM |
41
+
42
+ ### Rules that follow from the split
43
+
44
+ - **The core has no DOM, no React, no CSS and no framework.** `./realtime` and any future `./react` entry declare their peers as optional, so a headless service can install the package and use the HTTP client alone.
45
+ - **A DTO published here is a snapshot for the interface, never authorization evidence.** The server re-checks authority on every request regardless of what the client believes.
46
+ - **Two-sided contracts are proven by a shared fixture set**, run through both implementations and compared line by line. Two suites passing separately does not show that two sides agree.
47
+
48
+ ## `.` — platform contracts
49
+
50
+ ```ts
51
+ import {
52
+ mayShow,
53
+ parseUiPermissionSnapshot,
54
+ tryParseUiPermissionSnapshot,
55
+ type UiPermissionSnapshot,
56
+ } from "@godxjp/sdk";
57
+ ```
58
+
59
+ The `/me` response is a **display snapshot**, not authorization evidence. `parseUiPermissionSnapshot` is the only way to obtain a `UiPermissionSnapshot`; the branded type cannot be forged with `as`. The parser always sets `authoritative: false`, even if the wire payload claims otherwise — a hostile or broken server cannot upgrade what the UI believes into something the type treats as authoritative.
60
+
61
+ Wire shape: `user` (`id` as number, `name`, `email`), `permissions` (string array, may be empty), `roles` (string array — not `{ slug }` objects). Unknown top-level fields are ignored so the contract can grow without breaking older clients.
62
+
63
+ Use `mayShow(snapshot, permission)` only to decide whether a surface may appear in the UI; the server re-checks authority on every request. There are no `can` / `authorize` helpers on this entry — naming that implies client-side authorization is deliberately absent.
64
+
65
+ Legacy Inertia page props with `roles: [{ slug }]` are normalized at the product boundary via `src/identity/inertia-adapter.ts` (not re-exported from `.`).
18
66
 
19
67
  ## `./laravel`
20
68
 
21
69
  ```ts
22
- import { createClient, ApiError } from '@godxjp/sdk/laravel';
70
+ import { createClient, ApiError } from "@godxjp/sdk/laravel";
23
71
 
24
72
  export const apiClient = createClient({
25
- baseUrl: '/api/app/v1',
26
- signInUrl: (returnTo) => `/login?return=${encodeURIComponent(returnTo)}`,
73
+ baseUrl: "/api/app/v1",
74
+ signInUrl: (returnTo) => `/login?return=${encodeURIComponent(returnTo)}`,
27
75
  });
28
76
 
29
77
  // A public API whose visitor has nowhere to sign in: omit `signInUrl`,
30
78
  // and a 401 is simply reported.
31
- export const publicApiClient = createClient({ baseUrl: '/api' });
79
+ export const publicApiClient = createClient({ baseUrl: "/api" });
32
80
  ```
33
81
 
34
82
  Authentication is the session cookie, so a write sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, exactly as Laravel expects. Also handled: `key[]=a&key[]=b` for array query values and `1`/`0` for booleans, `204` resolving to `undefined`, `419` as an expired session, an unreachable server as `status: 0`, and upload progress over `XMLHttpRequest` because `fetch` cannot report it.
@@ -39,12 +87,12 @@ A cancellation stays an `AbortError` and is never converted to an `ApiError` —
39
87
 
40
88
  ```ts
41
89
  import {
42
- configureRealtime,
43
- isRealtimeConfigured,
44
- useChannelEvent,
45
- useRealtimeStatus,
46
- useUserNotification,
47
- } from '@godxjp/sdk/realtime';
90
+ configureRealtime,
91
+ isRealtimeConfigured,
92
+ useChannelEvent,
93
+ useRealtimeStatus,
94
+ useUserNotification,
95
+ } from "@godxjp/sdk/realtime";
48
96
  ```
49
97
 
50
98
  **Why the seam exists:** the transport underneath is expected to change. Putting the seam in first makes a later move one adapter in one package, rather than every listening component in every product.
@@ -54,7 +102,7 @@ So nothing from the transport appears in this API — no Echo type, no Pusher co
54
102
  ### Three words, not six
55
103
 
56
104
  ```ts
57
- type RealtimeStatus = 'connecting' | 'connected' | 'unavailable';
105
+ type RealtimeStatus = "connecting" | "connected" | "unavailable";
58
106
  ```
59
107
 
60
108
  `connecting` is the first state of **every** page load and says nothing yet — a screen must not start polling here, or every visit doubles its requests for the first second. `unavailable` means the client actually tried and is not live; that is the only state a fallback may fire on. The transport's `failed`, `unavailable` and `disconnected` all land there, because a screen has the same job in all three.
@@ -69,6 +117,15 @@ An unrecognised state maps to `unavailable`, never `connected`. Guessing toward
69
117
 
70
118
  Laravel broadcasts every notification under one framework event name, so subscribing by a custom name succeeds and then never fires — a failure that presents as "connected but nothing happens". Naming the two cases apart is what stops the next person reaching for the wrong one.
71
119
 
120
+ ### Channel names (PHP contract)
121
+
122
+ Realtime channels use a fixed shape so the JS client and the PHP broadcaster never disagree on a string:
123
+
124
+ - Organization scope: `org.{orgId}.{resource}.{resourceId}` — `orgId` and `resourceId` are `[A-Za-z0-9_-]+`; `resource` is lowercase `[a-z][a-z0-9-]*` (e.g. `issue`, `test-run`).
125
+ - Platform scope: `platform.{resource}.{resourceId}` — same `resource` / `resourceId` rules; no fake org prefix for cross-tenant traffic.
126
+
127
+ Build with `organizationChannel` / `platformChannel`, parse with `parseChannelName` or `tryParseChannelName`. The `ChannelName` brand is for compile-time safety only — it does not stop a client from subscribing to arbitrary strings. The server must always enforce user → organization → resource → authorization.
128
+
72
129
  ## Peer dependencies
73
130
 
74
131
  `./realtime` needs `@laravel/echo-react` and `react`. Both are optional peers: an application using only `./laravel` installs neither.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Display-only permission snapshot from `/me` — not evidence the server will honor.
3
+ *
4
+ * Types here catch programmer mistakes only; a client can still send arbitrary
5
+ * requests. The server must re-check authority on every call regardless of what
6
+ * the UI believes.
7
+ */
8
+ /** Only values produced by the parse gate carry this brand; `as` is not validation. */
9
+ type UiPermissionSnapshot = {
10
+ readonly authoritative: false;
11
+ readonly user: {
12
+ readonly id: number;
13
+ readonly name: string;
14
+ readonly email: string;
15
+ };
16
+ readonly permissions: readonly string[];
17
+ readonly roles: readonly string[];
18
+ } & {
19
+ readonly __brand: "UiPermissionSnapshot";
20
+ };
21
+ declare class UiPermissionSnapshotError extends Error {
22
+ constructor(message: string);
23
+ }
24
+ declare function parseUiPermissionSnapshot(raw: unknown): UiPermissionSnapshot;
25
+ declare function tryParseUiPermissionSnapshot(raw: unknown): UiPermissionSnapshot | null;
26
+ /**
27
+ * Whether the UI may show a surface gated by `permission`. This reads the
28
+ * display snapshot only; the server re-checks authority on every request.
29
+ */
30
+ declare function mayShow(snapshot: UiPermissionSnapshot, permission: string): boolean;
31
+
32
+ export { type UiPermissionSnapshot, UiPermissionSnapshotError, mayShow, parseUiPermissionSnapshot, tryParseUiPermissionSnapshot };
package/dist/index.js ADDED
@@ -0,0 +1,86 @@
1
+ // src/identity/me.ts
2
+ var UiPermissionSnapshotError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "UiPermissionSnapshotError";
6
+ }
7
+ };
8
+ function brandUiPermissionSnapshot(snapshot) {
9
+ return snapshot;
10
+ }
11
+ function assertPlainObject(raw) {
12
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
13
+ throw new UiPermissionSnapshotError("snapshot must be a plain object");
14
+ }
15
+ return raw;
16
+ }
17
+ function assertUser(raw) {
18
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
19
+ throw new UiPermissionSnapshotError("user must be an object");
20
+ }
21
+ const record = raw;
22
+ const { id, name, email } = record;
23
+ if (typeof id !== "number" || !Number.isFinite(id)) {
24
+ throw new UiPermissionSnapshotError("user.id must be a finite number");
25
+ }
26
+ if (typeof name !== "string") {
27
+ throw new UiPermissionSnapshotError("user.name must be a string");
28
+ }
29
+ if (typeof email !== "string") {
30
+ throw new UiPermissionSnapshotError("user.email must be a string");
31
+ }
32
+ return { id, name, email };
33
+ }
34
+ function assertStringArray(field, raw) {
35
+ if (!Array.isArray(raw)) {
36
+ throw new UiPermissionSnapshotError(`${field} must be an array`);
37
+ }
38
+ const result = [];
39
+ for (let index = 0; index < raw.length; index += 1) {
40
+ const item = raw[index];
41
+ if (typeof item !== "string") {
42
+ throw new UiPermissionSnapshotError(
43
+ `${field}[${index}] must be a string`
44
+ );
45
+ }
46
+ result.push(item);
47
+ }
48
+ return result;
49
+ }
50
+ function parseUiPermissionSnapshot(raw) {
51
+ const record = assertPlainObject(raw);
52
+ if (!("user" in record)) {
53
+ throw new UiPermissionSnapshotError("user is required");
54
+ }
55
+ const user = assertUser(record.user);
56
+ if (!("permissions" in record)) {
57
+ throw new UiPermissionSnapshotError("permissions is required");
58
+ }
59
+ const permissions = assertStringArray("permissions", record.permissions);
60
+ if (!("roles" in record)) {
61
+ throw new UiPermissionSnapshotError("roles is required");
62
+ }
63
+ const roles = assertStringArray("roles", record.roles);
64
+ const authoritative = false;
65
+ return brandUiPermissionSnapshot({
66
+ authoritative,
67
+ user,
68
+ permissions,
69
+ roles
70
+ });
71
+ }
72
+ function tryParseUiPermissionSnapshot(raw) {
73
+ try {
74
+ return parseUiPermissionSnapshot(raw);
75
+ } catch (error) {
76
+ if (error instanceof UiPermissionSnapshotError) {
77
+ return null;
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ function mayShow(snapshot, permission) {
83
+ return snapshot.permissions.includes(permission);
84
+ }
85
+
86
+ export { UiPermissionSnapshotError, mayShow, parseUiPermissionSnapshot, tryParseUiPermissionSnapshot };
@@ -92,9 +92,7 @@ function send(url, init, onUploadProgress) {
92
92
  }
93
93
  xhr.upload.onprogress = (event) => {
94
94
  if (event.lengthComputable && event.total > 0) {
95
- onUploadProgress(
96
- Math.round(event.loaded / event.total * 100)
97
- );
95
+ onUploadProgress(Math.round(event.loaded / event.total * 100));
98
96
  }
99
97
  };
100
98
  xhr.onload = () => {
@@ -21,7 +21,7 @@
21
21
  * on. Pusher's `failed`, `unavailable` and `disconnected` all land there,
22
22
  * because a screen has the same job in all three.
23
23
  */
24
- type RealtimeStatus = 'connecting' | 'connected' | 'unavailable';
24
+ type RealtimeStatus = "connecting" | "connected" | "unavailable";
25
25
  /** What the host must hand over to reach its own server. */
26
26
  type RealtimeConfig = {
27
27
  /** The app key the server issued. */
@@ -29,7 +29,7 @@ type RealtimeConfig = {
29
29
  host: string;
30
30
  port: number;
31
31
  /** `https` turns on TLS and the `wss` transport. */
32
- scheme: 'http' | 'https';
32
+ scheme: "http" | "https";
33
33
  };
34
34
  /**
35
35
  * Map a transport's own vocabulary onto ours.
@@ -80,4 +80,37 @@ declare function useChannelEvent<T = unknown>(channel: string, event: string, ha
80
80
  */
81
81
  declare function useUserNotification<T = unknown>(channel: string, handler: (payload: T) => void): void;
82
82
 
83
- export { type RealtimeConfig, type RealtimeStatus, configureRealtime, isRealtimeConfigured, toRealtimeStatus, useChannelEvent, useRealtimeStatus, useUserNotification };
83
+ /**
84
+ * Canonical realtime channel names shared with the PHP broadcaster.
85
+ *
86
+ * Types here catch programmer mistakes only — a client can still subscribe to
87
+ * any string it likes. The server must always map user → organization →
88
+ * resource → authorization before trusting a channel name.
89
+ */
90
+ /** Only values produced by the factories or parser carry this brand; `as` is not validation. */
91
+ type ChannelName = string & {
92
+ readonly __brand: "ChannelName";
93
+ };
94
+ type ParsedChannelName = {
95
+ scope: "org";
96
+ orgId: string;
97
+ resource: string;
98
+ resourceId: string;
99
+ name: ChannelName;
100
+ } | {
101
+ scope: "platform";
102
+ resource: string;
103
+ resourceId: string;
104
+ name: ChannelName;
105
+ };
106
+ declare class ChannelNameError extends Error {
107
+ constructor(message: string);
108
+ }
109
+ /** Organization-scoped channel: org.{orgId}.{resource}.{resourceId} */
110
+ declare function organizationChannel(orgId: string, resource: string, resourceId: string): ChannelName;
111
+ /** Platform-wide channel: platform.{resource}.{resourceId} */
112
+ declare function platformChannel(resource: string, resourceId: string): ChannelName;
113
+ declare function parseChannelName(name: string): ParsedChannelName;
114
+ declare function tryParseChannelName(name: string): ParsedChannelName | null;
115
+
116
+ export { type ChannelName, ChannelNameError, type ParsedChannelName, type RealtimeConfig, type RealtimeStatus, configureRealtime, isRealtimeConfigured, organizationChannel, parseChannelName, platformChannel, toRealtimeStatus, tryParseChannelName, useChannelEvent, useRealtimeStatus, useUserNotification };
@@ -38,4 +38,92 @@ function useUserNotification(channel, handler) {
38
38
  useEchoNotification(channel, handler);
39
39
  }
40
40
 
41
- export { configureRealtime, isRealtimeConfigured, toRealtimeStatus, useChannelEvent, useRealtimeStatus, useUserNotification };
41
+ // src/realtime/channel-name.ts
42
+ var ChannelNameError = class extends Error {
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = "ChannelNameError";
46
+ }
47
+ };
48
+ var ORG_ID = /^[A-Za-z0-9_-]+$/;
49
+ var RESOURCE = /^[a-z][a-z0-9-]*$/;
50
+ var RESOURCE_ID = /^[A-Za-z0-9_-]+$/;
51
+ function brandChannelName(value) {
52
+ return value;
53
+ }
54
+ function assertOrgId(orgId) {
55
+ if (orgId === void 0 || !ORG_ID.test(orgId)) {
56
+ throw new ChannelNameError(`invalid organization id: ${orgId ?? ""}`);
57
+ }
58
+ return orgId;
59
+ }
60
+ function assertResource(resource) {
61
+ if (resource === void 0 || !RESOURCE.test(resource)) {
62
+ throw new ChannelNameError(`invalid resource: ${resource ?? ""}`);
63
+ }
64
+ return resource;
65
+ }
66
+ function assertResourceId(resourceId) {
67
+ if (resourceId === void 0 || !RESOURCE_ID.test(resourceId)) {
68
+ throw new ChannelNameError(`invalid resource id: ${resourceId ?? ""}`);
69
+ }
70
+ return resourceId;
71
+ }
72
+ function organizationChannel(orgId, resource, resourceId) {
73
+ const safeOrgId = assertOrgId(orgId);
74
+ const safeResource = assertResource(resource);
75
+ const safeResourceId = assertResourceId(resourceId);
76
+ return brandChannelName(`org.${safeOrgId}.${safeResource}.${safeResourceId}`);
77
+ }
78
+ function platformChannel(resource, resourceId) {
79
+ const safeResource = assertResource(resource);
80
+ const safeResourceId = assertResourceId(resourceId);
81
+ return brandChannelName(`platform.${safeResource}.${safeResourceId}`);
82
+ }
83
+ function parseChannelName(name) {
84
+ if (name === "") {
85
+ throw new ChannelNameError("channel name is empty");
86
+ }
87
+ const parts = name.split(".");
88
+ if (parts[0] === "org") {
89
+ if (parts.length !== 4) {
90
+ throw new ChannelNameError(`invalid org channel shape: ${name}`);
91
+ }
92
+ const orgId = assertOrgId(parts[1]);
93
+ const resource = assertResource(parts[2]);
94
+ const resourceId = assertResourceId(parts[3]);
95
+ return {
96
+ scope: "org",
97
+ orgId,
98
+ resource,
99
+ resourceId,
100
+ name: brandChannelName(name)
101
+ };
102
+ }
103
+ if (parts[0] === "platform") {
104
+ if (parts.length !== 3) {
105
+ throw new ChannelNameError(`invalid platform channel shape: ${name}`);
106
+ }
107
+ const resource = assertResource(parts[1]);
108
+ const resourceId = assertResourceId(parts[2]);
109
+ return {
110
+ scope: "platform",
111
+ resource,
112
+ resourceId,
113
+ name: brandChannelName(name)
114
+ };
115
+ }
116
+ throw new ChannelNameError(`unknown channel scope: ${name}`);
117
+ }
118
+ function tryParseChannelName(name) {
119
+ try {
120
+ return parseChannelName(name);
121
+ } catch (error) {
122
+ if (error instanceof ChannelNameError) {
123
+ return null;
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ export { ChannelNameError, configureRealtime, isRealtimeConfigured, organizationChannel, parseChannelName, platformChannel, toRealtimeStatus, tryParseChannelName, useChannelEvent, useRealtimeStatus, useUserNotification };
@@ -0,0 +1,9 @@
1
+ {
2
+ "user": {
3
+ "id": 42,
4
+ "name": "Ada Lovelace",
5
+ "email": "ada@godx.jp"
6
+ },
7
+ "permissions": ["issues.view", "issues.create"],
8
+ "roles": ["member", "editor"]
9
+ }
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@godxjp/sdk",
3
- "version": "0.1.0",
4
- "description": "GoDX JavaScript SDK — shared frontend infrastructure for GoDX products.",
3
+ "version": "0.2.0",
4
+ "description": "GoDX JavaScript SDK \u2014 shared frontend infrastructure for GoDX products.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "files": [
9
9
  "dist",
10
+ "fixtures",
10
11
  "LICENSE",
11
12
  "README.md"
12
13
  ],
13
14
  "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
14
19
  "./laravel": {
15
20
  "types": "./dist/laravel/index.d.ts",
16
21
  "import": "./dist/laravel/index.js"
@@ -19,12 +24,13 @@
19
24
  "./realtime": {
20
25
  "types": "./dist/realtime/index.d.ts",
21
26
  "import": "./dist/realtime/index.js"
22
- }
27
+ },
28
+ "./fixtures/me.v1.json": "./fixtures/me.v1.json"
23
29
  },
24
30
  "scripts": {
25
31
  "build": "tsup",
26
32
  "types:check": "tsc --noEmit",
27
- "test": "vitest run"
33
+ "test": "vitest run --typecheck"
28
34
  },
29
35
  "devDependencies": {
30
36
  "@laravel/echo-react": "^2.5.0",