@ghub/identity-chrome 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,115 @@
1
+ Copyright (c) 2026 Gelhaus Solutions (Enno Gelhaus). All rights reserved.
2
+
3
+ Elastic License 2.0 (ELv2)
4
+
5
+ URL: https://www.elastic.co/licensing/elastic-license
6
+
7
+ ## Acceptance
8
+
9
+ By using the software, you agree to all of the terms and conditions below.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
14
+ non-sublicensable, non-transferable license to use, copy, distribute, make
15
+ available, and prepare derivative works of the software, in each case subject to
16
+ the limitations and conditions below.
17
+
18
+ ## Limitations
19
+
20
+ You may not provide the software to third parties as a hosted or managed
21
+ service, where the service provides users with access to any substantial set of
22
+ the features or functionality of the software.
23
+
24
+ You may not move, change, disable, or circumvent the license key functionality
25
+ in the software, and you may not remove or obscure any functionality in the
26
+ software that is protected by the license key.
27
+
28
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
29
+ of the licensor in the software. Any use of the licensor's trademarks is subject
30
+ to applicable law.
31
+
32
+ ## Patents
33
+
34
+ The licensor grants you a license, under any patent claims the licensor can
35
+ license, or becomes able to license, to make, have made, use, sell, offer for
36
+ sale, import and have imported the software, in each case subject to the
37
+ limitations and conditions in this license. This license does not cover any
38
+ patent claims that you cause to be infringed by modifications or additions to
39
+ the software. If you or your company make any written claim that the software
40
+ infringes or contributes to infringement of any patent, your patent license for
41
+ the software granted under these terms ends immediately. If your company makes
42
+ such a claim, your patent license ends immediately for work on behalf of your
43
+ company.
44
+
45
+ ## Notices
46
+
47
+ You must ensure that anyone who gets a copy of any part of the software from you
48
+ also gets a copy of these terms.
49
+
50
+ If you modify the software, you must include in any modified copies of the
51
+ software prominent notices stating that you have modified the software.
52
+
53
+ ## No Other Rights
54
+
55
+ These terms do not imply any licenses other than those expressly granted in
56
+ these terms.
57
+
58
+ ## Termination
59
+
60
+ If you use the software in violation of these terms, such use is not licensed,
61
+ and your licenses will automatically terminate. If the licensor provides you
62
+ with a notice of your violation, and you cease all violation of this license no
63
+ later than 30 days after you receive that notice, your licenses will be
64
+ reinstated retroactively. However, if you violate these terms after such
65
+ reinstatement, any additional violation of these terms will cause your licenses
66
+ to terminate automatically and permanently.
67
+
68
+ ## No Liability
69
+
70
+ *As far as the law allows, the software comes as is, without any warranty or
71
+ condition, and the licensor will not be liable to you for any damages arising
72
+ out of these terms or the use or nature of the software, under any kind of legal
73
+ claim.*
74
+
75
+ ## Definitions
76
+
77
+ The **licensor** is the entity offering these terms, and the **software** is the
78
+ software the licensor makes available under these terms, including any portion
79
+ of it.
80
+
81
+ **you** refers to the individual or entity agreeing to these terms.
82
+
83
+ **your company** is any legal entity, sole proprietorship, or other kind of
84
+ organization that you work for, plus all organizations that have control over,
85
+ are under the control of, or are under common control with that organization.
86
+ **control** means ownership of substantially all the assets of an entity, or the
87
+ power to direct its management and policies by vote, contract, or otherwise.
88
+ Control can be direct or indirect.
89
+
90
+ **your licenses** are all the licenses granted to you for the software under
91
+ these terms.
92
+
93
+ **use** means anything you do with the software requiring one of your licenses.
94
+
95
+ **trademark** means trademarks, service marks, and similar rights.
96
+ ---
97
+
98
+ ## Additional restriction
99
+
100
+ In addition to the limitations in the Elastic License 2.0 above, and as a
101
+ condition of the licence granted by it:
102
+
103
+ You may not use the software for commercial purposes without a separate
104
+ business licence granted in writing by Gelhaus Solutions.
105
+
106
+ Commercial purposes include use in, by or for a business or other commercial
107
+ undertaking, whether or not the software is provided to anyone outside it, and
108
+ whether or not a fee is charged. Non-commercial use is not restricted by this
109
+ paragraph.
110
+
111
+ This restriction is ours and is not part of the Elastic License 2.0. The
112
+ Elastic License 2.0 by itself permits commercial internal use; this paragraph
113
+ removes that permission. Where the two conflict, this paragraph applies.
114
+
115
+ For a business licence, contact egelhaus@ennogelhaus.de.
package/README.md CHANGED
@@ -1,3 +1,158 @@
1
- # Temporary Holding Version
1
+ # @ghub/identity-chrome
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared header controls of the Gelhaus Solutions products: the apps picker and the
4
+ account popup. Two custom elements with shadow DOM (`<gp-apps>`, `<gp-account>`) and React
5
+ wrappers for them; the browser half under both (what the controls know, what they keep, what
6
+ they do when Identity cannot be reached); and the server half (a product's own server asking
7
+ GPlatform Identity).
8
+
9
+ Nothing here depends on a product signing in through Identity. A product that has not
10
+ adopted it uses the same package the same way.
11
+
12
+ ## Off unless enabled
13
+
14
+ ```ts
15
+ import { createChromeCore } from "@ghub/identity-chrome";
16
+
17
+ const chrome = createChromeCore({
18
+ enabled: true, // anything but exactly true makes no request and reads no storage
19
+ endpoint: "/api/identity-chrome", // the product's own route, same origin
20
+ storage: window.localStorage, // optional: the last list, held for an outage
21
+ });
22
+
23
+ chrome.subscribe((state) => draw(state));
24
+ await chrome.load();
25
+ ```
26
+
27
+ A self-hosted or customer-run installation never sets `enabled`, so it never calls anybody.
28
+ The last answer is drawn at once while a fresh one is fetched, is kept and marked `stale`
29
+ when the refresh fails, and is thirty days old at most. Call `chrome.clear()` on sign-out, so
30
+ a shared device never shows the next person the previous one's products.
31
+
32
+ Every address in an answer is checked before it is drawn: a tile or an icon whose address is
33
+ not `http` or `https` is dropped, never repaired, in a fresh answer and in the cached copy
34
+ alike.
35
+
36
+ ## The product's own route
37
+
38
+ ```ts
39
+ import { createChromeServer } from "@ghub/identity-chrome/server";
40
+
41
+ const chrome = createChromeServer({
42
+ baseUrl: process.env.GPID_URL,
43
+ product: "advisory",
44
+ token: process.env.GPID_CHROME_TOKEN, // never sent to a browser
45
+ });
46
+
47
+ // Any framework that speaks the web Request and Response:
48
+ export const GET = chrome.handler(async (request) => {
49
+ const user = await sessionOf(request);
50
+ return user === null ? null : { email: user.verifiedEmail };
51
+ });
52
+ ```
53
+
54
+ The route says only who is signed in, by an address the product has verified, and Identity
55
+ answers with the products that person has and the public ones they do not, and the two
56
+ footer links of the account popup (the terms and the privacy policy, the same in every
57
+ product, so a moved address needs no new release here). An unverified
58
+ address is a way to ask about somebody else. Answers are reused for a minute, and the token
59
+ is never in anything this package throws or answers.
60
+
61
+ A one-off push of a product's users (`chrome.seed(emails)`) is refused unless staff have
62
+ opened a window for it on Identity's product screen, and `chrome.remove(emails)` says people
63
+ no longer have access.
64
+
65
+ ## The two controls
66
+
67
+ ```ts
68
+ import { createChromeCore, defineChromeElements } from "@ghub/identity-chrome";
69
+
70
+ defineChromeElements(); // <gp-apps> and <gp-account>, each once; a no-op on a server
71
+
72
+ const apps = document.querySelector("gp-apps")!;
73
+ apps.core = chrome; // the core above: one fetch and one cache behind both
74
+
75
+ const account = document.querySelector("gp-account")!;
76
+ account.core = chrome;
77
+ account.product = "GAdvisory"; // your own name, from your catalogue entry
78
+ account.person = { name: user.name, email: user.email, id: user.id, avatarUrl: user.picture };
79
+ account.organisations = [{ id: "org_1", name: "Hafen Labs", role: "admin", active: true }];
80
+ account.onSwitch = async (id) => switchOrganisation(id); // reject when it did not switch
81
+ account.onSignOut = async () => {
82
+ await signOut(); // reject when it failed: the popup says they are still signed in
83
+ location.assign("/"); // you navigate; as this resolves the element forgets the kept list
84
+ };
85
+ ```
86
+
87
+ Everything is a property, because a core, a person and a callback are objects. Each can be
88
+ set before the element is defined. Neither control renders anything while the core is off,
89
+ and the account popup renders nothing without a person.
90
+
91
+ **The account popup shows mostly what the product passes**: the name and address from your
92
+ session, your organisations with their roles (Owner, Admin or Member) and your two actions.
93
+ Identity adds the picture, the colour of the initial, the links to its own account and set up
94
+ screens and the footer, so the popup still signs somebody out while Identity is down. The
95
+ picture is Identity's if the person has one, else yours, else a coloured initial whose tint
96
+ Identity picks from its own id; for somebody with no Identity, pass your own stable user id as
97
+ `id` and the same hash (`avatarTint`) picks it. Set `viaIdentity` once you sign in through
98
+ Identity, and Sign out says it ends both. Sign out leaves this product only.
99
+
100
+ Every word can be replaced through `labels`; the rest stay English.
101
+
102
+ ### In React
103
+
104
+ ```tsx
105
+ import { GpAccount, GpApps } from "@ghub/identity-chrome/react";
106
+
107
+ <GpApps core={chrome} />
108
+ <GpAccount core={chrome} product="GAdvisory" person={person} organisations={orgs} onSwitch={switchTo} onSignOut={signOut} />
109
+ ```
110
+
111
+ The wrappers define the elements and set the properties after mounting, so they render on a
112
+ server as the bare tags. React 19 or newer, as an optional peer.
113
+
114
+ ### The host contract
115
+
116
+ Shadow DOM: nothing reaches in and nothing leaks out but the custom properties a page
117
+ declares on its root, which inherit into the shadow. The controls read the published
118
+ `@ghub/tokens` names, so a page on `@ghub/tokens` is styled with no setup, and Aurora's frosted
119
+ menu where the page is on Aurora:
120
+
121
+ | Reads | For |
122
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
123
+ | `--ga-menu`, else `--gm-overlay`; `--ga-frost-menu`; `--ga-shadow-menu`, else `--gm-shadow-overlay` | the picker's surface, its frost and its shadow |
124
+ | `--ga-menu`, else `--gm-sunken` | the account popup's tinted ground |
125
+ | `--ga-track`, else `--gm-overlay` | the account popup's panels |
126
+ | `--gm-plate`, `--gm-ink`, `--gm-ink-2`, `--gm-ink-3`, `--gm-hairline`, `--gm-control`, `--gm-hover`, `--gm-active`, `--gm-selected`, `--gm-accent`, `--gm-accent-text`, `--gm-ring`, `--gm-crit-ink` | everything else |
127
+ | `--font-sans` | the face |
128
+
129
+ A page that declares none of them gets modern light, or modern dark under
130
+ `prefers-color-scheme: dark`. There is no second accent: the controls take the page's. Both
131
+ follow reduced motion, reduced transparency and forced colours, and below 600 wide each is a
132
+ modal bottom sheet. `part="trigger"` and `part="popup"` are there for placement only.
133
+
134
+ ## A product's own name and mark
135
+
136
+ Identity's catalogue is where every product's name and mark live. Read your own from it for
137
+ your top bar, your title and your favicon, and a rename or a new mark reaches you without a
138
+ release:
139
+
140
+ ```ts
141
+ import { catalogueMarkUrl, createCatalogue } from "@ghub/identity-chrome";
142
+
143
+ const catalogue = createCatalogue({ baseUrl: process.env.GPID_URL });
144
+ const me = await catalogue.entry("gadvisory"); // { displayName, homeUrl, iconUrl, monoIconUrl, ... }
145
+
146
+ // Where you cannot ask first, an address that always leads to the current mark:
147
+ // <link rel="icon" type="image/svg+xml" href={catalogueMarkUrl(process.env.GPID_URL, "gadvisory")} />
148
+ ```
149
+
150
+ No token is needed. An entry is reused for a minute, and after a failed ask the last good one
151
+ is returned, so an outage leaves the name and mark you last had. A self-hosted install asks
152
+ nobody and ships the copy its release was built with.
153
+
154
+ The shapes are kept in step by hand with `ChromeView` and `CatalogueEntry` in GPlatform
155
+ Identity, which this public package cannot depend on.
156
+
157
+ Part of [GHub-Packages](https://github.com/Gelhaus-Solutions/GHub-Packages), the packages
158
+ the Gelhaus Solutions products share.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A product's own name and marks, read from Identity's catalogue.
3
+ *
4
+ * The catalogue is where a product's name and mark live. A product reads its own
5
+ * here when it draws its top bar, its title and its favicon, so a rename or a new
6
+ * mark reaches every product without a release. Nothing here needs a token: a
7
+ * product's name and mark are on its own pages already.
8
+ *
9
+ * Two ways in. `catalogueMarkUrl` is an address that always leads to the current
10
+ * mark, for places that cannot ask first (a favicon link, an `<img>` in static
11
+ * markup). `createCatalogue` asks for the whole entry and keeps the last good
12
+ * answer, so an Identity outage leaves the name and mark a product last had
13
+ * rather than none.
14
+ *
15
+ * Off for a self-hosted install, as the controls are: such an install ships the
16
+ * copy its release was built with and asks nobody.
17
+ */
18
+ import type { CatalogueEntry } from "./types.js";
19
+ /** The address that always leads to a product's current mark. Redirects, briefly cached. */
20
+ export declare function catalogueMarkUrl(baseUrl: string, slug: string, variant?: "colour" | "mono"): string;
21
+ export interface CatalogueOptions {
22
+ /** Identity's public address, with or without a trailing slash. */
23
+ baseUrl: string;
24
+ fetch?: typeof fetch;
25
+ /** Milliseconds. A call that hangs must not hang a page render. */
26
+ timeoutMs?: number;
27
+ /** How long an entry is reused before asking again, in seconds. Identity caches it for a minute too. */
28
+ cacheSeconds?: number;
29
+ now?: () => number;
30
+ }
31
+ export interface Catalogue {
32
+ /**
33
+ * A product's entry. After a failed ask, the last good answer, however old;
34
+ * null only when there never was one or the product does not exist.
35
+ */
36
+ entry(slug: string): Promise<CatalogueEntry | null>;
37
+ }
38
+ export declare function createCatalogue(options: CatalogueOptions): Catalogue;
39
+ //# sourceMappingURL=catalogue.d.ts.map
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * A product's own name and marks, read from Identity's catalogue.
4
+ *
5
+ * The catalogue is where a product's name and mark live. A product reads its own
6
+ * here when it draws its top bar, its title and its favicon, so a rename or a new
7
+ * mark reaches every product without a release. Nothing here needs a token: a
8
+ * product's name and mark are on its own pages already.
9
+ *
10
+ * Two ways in. `catalogueMarkUrl` is an address that always leads to the current
11
+ * mark, for places that cannot ask first (a favicon link, an `<img>` in static
12
+ * markup). `createCatalogue` asks for the whole entry and keeps the last good
13
+ * answer, so an Identity outage leaves the name and mark a product last had
14
+ * rather than none.
15
+ *
16
+ * Off for a self-hosted install, as the controls are: such an install ships the
17
+ * copy its release was built with and asks nobody.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.catalogueMarkUrl = catalogueMarkUrl;
21
+ exports.createCatalogue = createCatalogue;
22
+ const validate_js_1 = require("./validate.js");
23
+ /** The address that always leads to a product's current mark. Redirects, briefly cached. */
24
+ function catalogueMarkUrl(baseUrl, slug, variant = "colour") {
25
+ const base = baseUrl.replace(/\/+$/u, "");
26
+ return `${base}/api/v1/catalogue/${encodeURIComponent(slug)}/icon${variant === "mono" ? "/mono" : ""}`;
27
+ }
28
+ const DEFAULT_TIMEOUT_MS = 3000;
29
+ const DEFAULT_CACHE_SECONDS = 60;
30
+ function createCatalogue(options) {
31
+ const base = options.baseUrl.replace(/\/+$/u, "");
32
+ const now = options.now ?? Date.now;
33
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
+ const ttlMs = (options.cacheSeconds ?? DEFAULT_CACHE_SECONDS) * 1000;
35
+ const held = new Map();
36
+ async function ask(slug) {
37
+ const send = options.fetch ?? globalThis.fetch.bind(globalThis);
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => {
40
+ controller.abort();
41
+ }, timeoutMs);
42
+ try {
43
+ const response = await send(`${base}/api/v1/catalogue/${encodeURIComponent(slug)}`, {
44
+ method: "GET",
45
+ headers: { accept: "application/json" },
46
+ credentials: "omit",
47
+ signal: controller.signal,
48
+ });
49
+ if (response.status === 404)
50
+ return null;
51
+ if (!response.ok)
52
+ throw new Error(`Identity answered ${String(response.status)}`);
53
+ const entry = (0, validate_js_1.parseCatalogueEntry)(await response.json());
54
+ if (entry === null)
55
+ throw new Error("Identity's answer was not understood");
56
+ return entry;
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ return {
63
+ async entry(slug) {
64
+ const last = held.get(slug);
65
+ if (last !== undefined && now() - last.at < ttlMs)
66
+ return last.entry;
67
+ try {
68
+ const entry = await ask(slug);
69
+ held.set(slug, { entry, at: now() });
70
+ return entry;
71
+ }
72
+ catch {
73
+ // The name and mark it last had, rather than none.
74
+ return last?.entry ?? null;
75
+ }
76
+ },
77
+ };
78
+ }
79
+ //# sourceMappingURL=catalogue.js.map
package/dist/core.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The browser half: what the header controls know, and what they keep.
3
+ *
4
+ * It owns no markup. The apps picker and the account popup draw from `state()`
5
+ * and ask it to `load()`, which is what keeps one fetch, one cache and one
6
+ * failure story behind both controls.
7
+ *
8
+ * **Off unless enabled, and off means silent.** Nothing is fetched, nothing is
9
+ * read from storage and nothing is written, so a self-hosted installation that
10
+ * never sets `enabled` makes no call to anybody.
11
+ *
12
+ * **A cached list paints at once.** The last answer is drawn while a fresh one is
13
+ * fetched, and kept if the fetch fails, marked `stale`. It is thirty days old at
14
+ * most and is checked as hard as a fresh answer, because storage is something
15
+ * anybody on the device can write.
16
+ *
17
+ * **The product's own route is the personal half.** The product's server holds the
18
+ * secret token and says who is signed in; this only ever calls that route, same
19
+ * origin, with the page's own cookie. The public key, if one is given, fetches
20
+ * the lower group straight from Identity so it can paint before the product has
21
+ * answered, and nothing more.
22
+ */
23
+ import type { ChromeView } from "./types.js";
24
+ export type ChromeStatus = "off" | "idle" | "loading" | "ready" | "stale" | "error";
25
+ export interface ChromeState {
26
+ status: ChromeStatus;
27
+ view: ChromeView | null;
28
+ /** When `view` was last confirmed by Identity, in milliseconds since the epoch, or null. */
29
+ updatedAt: number | null;
30
+ /** Why the last refresh failed, short and for a quiet line rather than for a person. */
31
+ error: string | null;
32
+ }
33
+ /** The three calls a storage needs, which `localStorage` and `sessionStorage` both have. */
34
+ export interface StorageLike {
35
+ getItem(key: string): string | null;
36
+ setItem(key: string, value: string): void;
37
+ removeItem(key: string): void;
38
+ }
39
+ export declare const CACHE_KEY = "gpid.chrome.v1";
40
+ /** A list older than this is not drawn, however the fetch fares. */
41
+ export declare const CACHE_MAX_AGE_MS: number;
42
+ export declare const DEFAULT_TIMEOUT_MS = 5000;
43
+ export interface ChromeCoreOptions {
44
+ /** Nothing happens unless this is exactly `true`. */
45
+ enabled?: boolean;
46
+ /** The product's own route for the personal half, same origin. */
47
+ endpoint?: string;
48
+ /** Identity's address and this product's public key, for the lower group alone. */
49
+ baseUrl?: string;
50
+ publicKey?: string;
51
+ /** Where the last answer is kept. None means it is not kept. */
52
+ storage?: StorageLike | null;
53
+ cacheKey?: string;
54
+ fetch?: typeof fetch;
55
+ now?: () => number;
56
+ timeoutMs?: number;
57
+ }
58
+ export interface ChromeCore {
59
+ state(): ChromeState;
60
+ subscribe(listener: (state: ChromeState) => void): () => void;
61
+ /** Fetch, update the cache and the state, and resolve with the state. Concurrent calls share one fetch. */
62
+ load(): Promise<ChromeState>;
63
+ /** Forget the cached list. A product calls it on sign-out, so a shared device never shows the next person the last one's products. */
64
+ clear(): void;
65
+ /** Stop: abort what is in flight and ignore what arrives. */
66
+ dispose(): void;
67
+ }
68
+ export declare function createChromeCore(options?: ChromeCoreOptions): ChromeCore;
69
+ //# sourceMappingURL=core.d.ts.map