@hanzo/rooms 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/LICENSE-APACHE +205 -0
- package/LICENSE-MIT +21 -0
- package/README.md +23 -0
- package/dist/Account.d.ts +8 -0
- package/dist/Account.js +58 -0
- package/dist/Beluga.d.ts +12 -0
- package/dist/Beluga.js +152 -0
- package/dist/Billing.d.ts +1 -0
- package/dist/Billing.js +31 -0
- package/dist/Board.d.ts +9 -0
- package/dist/Board.js +447 -0
- package/dist/Boxes.d.ts +1 -0
- package/dist/Boxes.js +173 -0
- package/dist/CalScheduler.d.ts +15 -0
- package/dist/CalScheduler.js +196 -0
- package/dist/Chat.d.ts +35 -0
- package/dist/Chat.js +1049 -0
- package/dist/Column.d.ts +15 -0
- package/dist/Column.js +88 -0
- package/dist/Contacts.d.ts +1 -0
- package/dist/Contacts.js +301 -0
- package/dist/Directory.d.ts +24 -0
- package/dist/Directory.js +356 -0
- package/dist/Drive.d.ts +2 -0
- package/dist/Drive.js +466 -0
- package/dist/Find.d.ts +7 -0
- package/dist/Find.js +82 -0
- package/dist/Guide.d.ts +6 -0
- package/dist/Guide.js +272 -0
- package/dist/Home.d.ts +39 -0
- package/dist/Home.js +306 -0
- package/dist/Inbox.d.ts +9 -0
- package/dist/Inbox.js +193 -0
- package/dist/Market.d.ts +3 -0
- package/dist/Market.js +149 -0
- package/dist/Me.d.ts +27 -0
- package/dist/Me.js +251 -0
- package/dist/Meet.d.ts +9 -0
- package/dist/Meet.js +370 -0
- package/dist/ModeMark.d.ts +8 -0
- package/dist/ModeMark.js +18 -0
- package/dist/OrgSwitcher.d.ts +31 -0
- package/dist/OrgSwitcher.js +120 -0
- package/dist/Orgs.d.ts +10 -0
- package/dist/Orgs.js +582 -0
- package/dist/Profile.d.ts +4 -0
- package/dist/Profile.js +67 -0
- package/dist/Prose.d.ts +5 -0
- package/dist/Prose.js +111 -0
- package/dist/ProviderMark.d.ts +17 -0
- package/dist/ProviderMark.js +277 -0
- package/dist/Recents.d.ts +10 -0
- package/dist/Recents.js +91 -0
- package/dist/RightPane.d.ts +13 -0
- package/dist/RightPane.js +426 -0
- package/dist/Room.d.ts +8 -0
- package/dist/Room.js +50 -0
- package/dist/Scene.d.ts +12 -0
- package/dist/Scene.js +105 -0
- package/dist/Settings.d.ts +46 -0
- package/dist/Settings.js +321 -0
- package/dist/Setup.d.ts +1 -0
- package/dist/Setup.js +36 -0
- package/dist/Share.d.ts +3 -0
- package/dist/Share.js +130 -0
- package/dist/Shared.d.ts +1 -0
- package/dist/Shared.js +113 -0
- package/dist/Shell.d.ts +63 -0
- package/dist/Shell.js +1225 -0
- package/dist/Start.d.ts +1 -0
- package/dist/Start.js +96 -0
- package/dist/Support.d.ts +14 -0
- package/dist/Support.js +122 -0
- package/dist/Talk.d.ts +4 -0
- package/dist/Talk.js +107 -0
- package/dist/Upgrade.d.ts +11 -0
- package/dist/Upgrade.js +75 -0
- package/dist/Usage.d.ts +1 -0
- package/dist/Usage.js +21 -0
- package/dist/Vibe.d.ts +2 -0
- package/dist/Vibe.js +188 -0
- package/dist/Work.d.ts +12 -0
- package/dist/Work.js +256 -0
- package/dist/bots/Bots.d.ts +2 -0
- package/dist/bots/Bots.js +456 -0
- package/dist/bots/Lights.d.ts +8 -0
- package/dist/bots/Lights.js +13 -0
- package/dist/bots/api.d.ts +120 -0
- package/dist/bots/api.js +75 -0
- package/dist/bots/face.d.ts +5 -0
- package/dist/bots/face.js +13 -0
- package/dist/bots/ink.d.ts +7 -0
- package/dist/bots/ink.js +29 -0
- package/dist/bots/kit.d.ts +113 -0
- package/dist/bots/kit.js +43 -0
- package/dist/bots/runtime.d.ts +4 -0
- package/dist/bots/runtime.js +326 -0
- package/dist/card.d.ts +52 -0
- package/dist/card.js +52 -0
- package/dist/cast.d.ts +143 -0
- package/dist/cast.js +251 -0
- package/dist/connect.d.ts +29 -0
- package/dist/connect.js +64 -0
- package/dist/contacts-store.d.ts +111 -0
- package/dist/contacts-store.js +276 -0
- package/dist/conversations.d.ts +54 -0
- package/dist/conversations.js +98 -0
- package/dist/copy.d.ts +23 -0
- package/dist/copy.js +39 -0
- package/dist/crew.d.ts +36 -0
- package/dist/crew.js +304 -0
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +102 -0
- package/dist/enso.d.ts +70 -0
- package/dist/enso.js +112 -0
- package/dist/failure.d.ts +57 -0
- package/dist/failure.js +143 -0
- package/dist/ground.d.ts +32 -0
- package/dist/ground.js +32 -0
- package/dist/host.d.ts +76 -0
- package/dist/host.js +76 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/kept.d.ts +5 -0
- package/dist/kept.js +27 -0
- package/dist/lib/account.d.ts +49 -0
- package/dist/lib/account.js +229 -0
- package/dist/lib/ai.d.ts +46 -0
- package/dist/lib/ai.js +140 -0
- package/dist/lib/api.d.ts +43 -0
- package/dist/lib/api.js +62 -0
- package/dist/lib/bare.d.ts +9 -0
- package/dist/lib/bare.js +7 -0
- package/dist/lib/coding.d.ts +105 -0
- package/dist/lib/coding.js +87 -0
- package/dist/lib/destination.d.ts +102 -0
- package/dist/lib/destination.js +260 -0
- package/dist/lib/durable.d.ts +66 -0
- package/dist/lib/durable.js +107 -0
- package/dist/lib/first.d.ts +11 -0
- package/dist/lib/first.js +23 -0
- package/dist/lib/home.d.ts +61 -0
- package/dist/lib/home.js +54 -0
- package/dist/lib/host.d.ts +104 -0
- package/dist/lib/host.js +132 -0
- package/dist/lib/hostname.d.ts +1 -0
- package/dist/lib/hostname.js +14 -0
- package/dist/lib/hydrated.d.ts +1 -0
- package/dist/lib/hydrated.js +20 -0
- package/dist/lib/integrations.d.ts +21 -0
- package/dist/lib/integrations.js +1 -0
- package/dist/lib/intent.d.ts +6 -0
- package/dist/lib/intent.js +71 -0
- package/dist/lib/invite.d.ts +155 -0
- package/dist/lib/invite.js +245 -0
- package/dist/lib/limits.d.ts +55 -0
- package/dist/lib/limits.js +119 -0
- package/dist/lib/list.d.ts +9 -0
- package/dist/lib/list.js +11 -0
- package/dist/lib/mark.d.ts +13 -0
- package/dist/lib/mark.js +85 -0
- package/dist/lib/mix.d.ts +22 -0
- package/dist/lib/mix.js +22 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +106 -0
- package/dist/lib/org.d.ts +109 -0
- package/dist/lib/org.js +140 -0
- package/dist/lib/path.d.ts +9 -0
- package/dist/lib/path.js +29 -0
- package/dist/lib/plans.d.ts +193 -0
- package/dist/lib/plans.js +310 -0
- package/dist/lib/prose.d.ts +13 -0
- package/dist/lib/prose.js +62 -0
- package/dist/lib/reach.d.ts +17 -0
- package/dist/lib/reach.js +70 -0
- package/dist/lib/rooms.json +23 -0
- package/dist/lib/served.d.ts +20 -0
- package/dist/lib/served.js +92 -0
- package/dist/lib/session.d.ts +211 -0
- package/dist/lib/session.js +493 -0
- package/dist/lib/share.d.ts +131 -0
- package/dist/lib/share.js +216 -0
- package/dist/lib/signout.d.ts +14 -0
- package/dist/lib/signout.js +40 -0
- package/dist/lib/tags.d.ts +27 -0
- package/dist/lib/tags.js +46 -0
- package/dist/lib/tap.d.ts +17 -0
- package/dist/lib/tap.js +9 -0
- package/dist/lib/tier.d.ts +115 -0
- package/dist/lib/tier.js +207 -0
- package/dist/lib/tiers.d.ts +9 -0
- package/dist/lib/tiers.js +141 -0
- package/dist/lib/todo.d.ts +125 -0
- package/dist/lib/todo.js +379 -0
- package/dist/look.d.ts +1 -0
- package/dist/look.js +21 -0
- package/dist/meters.d.ts +8 -0
- package/dist/meters.js +31 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +44 -0
- package/dist/network.d.ts +64 -0
- package/dist/network.js +216 -0
- package/dist/open.d.ts +81 -0
- package/dist/open.js +149 -0
- package/dist/pane.d.ts +92 -0
- package/dist/pane.js +231 -0
- package/dist/progress.d.ts +46 -0
- package/dist/progress.js +51 -0
- package/dist/recent.d.ts +67 -0
- package/dist/recent.js +161 -0
- package/dist/rooms.d.ts +131 -0
- package/dist/rooms.js +195 -0
- package/dist/roster.d.ts +28 -0
- package/dist/roster.js +70 -0
- package/dist/span.d.ts +14 -0
- package/dist/span.js +50 -0
- package/dist/speech.d.ts +44 -0
- package/dist/speech.js +229 -0
- package/dist/stars.d.ts +16 -0
- package/dist/stars.js +75 -0
- package/dist/tabs.d.ts +26 -0
- package/dist/tabs.js +25 -0
- package/dist/team.d.ts +11 -0
- package/dist/team.gen.d.ts +10 -0
- package/dist/team.gen.js +11 -0
- package/dist/team.js +30 -0
- package/dist/tools.d.ts +35 -0
- package/dist/tools.js +143 -0
- package/dist/tours.d.ts +24 -0
- package/dist/tours.js +311 -0
- package/dist/waiting.d.ts +15 -0
- package/dist/waiting.js +17 -0
- package/dist/where.d.ts +54 -0
- package/dist/where.js +14 -0
- package/package.json +108 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { me, scope } from './session.js';
|
|
2
|
+
import { api, CLIENT, iam } from './api.js';
|
|
3
|
+
export class InviteRefusal extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(status, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
async function call(path, init) {
|
|
11
|
+
const res = await fetch(`${api()}${path}`, {
|
|
12
|
+
...init,
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
...scope(),
|
|
16
|
+
...init?.headers,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
if (res.status === 401)
|
|
20
|
+
throw new InviteRefusal(401, 'Your session expired — sign in again');
|
|
21
|
+
if (res.status === 403)
|
|
22
|
+
throw new InviteRefusal(403, 'Your role in this organization does not allow this');
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
let detail = '';
|
|
25
|
+
try {
|
|
26
|
+
const body = (await res.json());
|
|
27
|
+
detail = body.msg || body.error || body.message || '';
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// an HTML error page proves nothing worth toasting
|
|
31
|
+
}
|
|
32
|
+
throw new InviteRefusal(res.status, detail || `Invitation service answered ${res.status}`);
|
|
33
|
+
}
|
|
34
|
+
const body = (await res.json());
|
|
35
|
+
// IAM's raw handlers refuse with HTTP 200 and `{status: "error", msg}` — the
|
|
36
|
+
// memberships writes do — so the envelope is read as well as the status.
|
|
37
|
+
if (body?.status === 'error')
|
|
38
|
+
throw new InviteRefusal(400, body.msg || 'IAM refused the change');
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
/** The invitation's row name within its org: random, and never the code. */
|
|
42
|
+
function mintName() {
|
|
43
|
+
const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
44
|
+
const bytes = new Uint8Array(10);
|
|
45
|
+
crypto.getRandomValues(bytes);
|
|
46
|
+
return `inv-${Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('')}`;
|
|
47
|
+
}
|
|
48
|
+
/** The link a member joins by, on the same IAM the account panel signs into.
|
|
49
|
+
* Those two were separately configurable and neither name was set anywhere, so
|
|
50
|
+
* pointing the app at another IAM would have moved one and left the other. It is
|
|
51
|
+
* the link IAM emails, parameter for parameter, so a copied link and an emailed
|
|
52
|
+
* one are the same link: the application the member joins through, the code, and
|
|
53
|
+
* the org IAM looks the code up in. */
|
|
54
|
+
export function inviteLink(org, code) {
|
|
55
|
+
const q = new URLSearchParams({ client_id: CLIENT, invite: code, org });
|
|
56
|
+
return `${iam()}/join?${q}`;
|
|
57
|
+
}
|
|
58
|
+
/** How many people one shared invite link admits. */
|
|
59
|
+
export const LINK_SEATS = 25;
|
|
60
|
+
/**
|
|
61
|
+
* Issues one invitation and returns it: pinned to an email and redeemable once,
|
|
62
|
+
* or open to anyone with the link for `quota` joins. The caller owns telling the
|
|
63
|
+
* person — `sendInvitation` emails a pinned one, and the link is
|
|
64
|
+
* `inviteLink(invitation.owner, invitation.code)`.
|
|
65
|
+
*/
|
|
66
|
+
export async function createInvitation(org, email, quota = 1) {
|
|
67
|
+
// No code is sent: IAM mints it, and only a code IAM minted is exempt from the
|
|
68
|
+
// limit on guesses at an org's invitations. The row it answers carries it.
|
|
69
|
+
const created = (await call('/v1/iam/invitations', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
owner: org,
|
|
73
|
+
name: mintName(),
|
|
74
|
+
displayName: email ? `Invitation for ${email}` : 'Invite link',
|
|
75
|
+
quota,
|
|
76
|
+
usedCount: 0,
|
|
77
|
+
email: email || '',
|
|
78
|
+
state: 'Active',
|
|
79
|
+
}),
|
|
80
|
+
}));
|
|
81
|
+
if (!created?.code)
|
|
82
|
+
throw new InviteRefusal(502, 'IAM made the invitation without a code');
|
|
83
|
+
return created;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* One organization's invitations, newest first, redeemed ones included.
|
|
87
|
+
*
|
|
88
|
+
* The organization is named as `?owner=`, the one input IAM's list reads. A
|
|
89
|
+
* read that names none is authorized against no organization and refused.
|
|
90
|
+
*/
|
|
91
|
+
export async function listInvitations(org) {
|
|
92
|
+
const body = (await call(`/v1/iam/invitations?owner=${encodeURIComponent(org)}`));
|
|
93
|
+
return (body.invitations ?? []).filter((r) => r && r.code);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Emails the join link to the address an invitation is pinned to, and returns
|
|
97
|
+
* the address IAM sent it to. A refusal carries IAM's own reason.
|
|
98
|
+
*/
|
|
99
|
+
export async function sendInvitation(inv) {
|
|
100
|
+
try {
|
|
101
|
+
const body = (await call(`/v1/iam/invitations/${encodeURIComponent(inv.owner)}/${encodeURIComponent(inv.name)}/send`, { method: 'POST', body: '{}' }));
|
|
102
|
+
if (!body.sent)
|
|
103
|
+
throw new InviteRefusal(502, 'IAM did not send the email');
|
|
104
|
+
return body.to ?? '';
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (!(e instanceof InviteRefusal))
|
|
108
|
+
throw e;
|
|
109
|
+
// IAM paces sends per invitation, per org and per mailbox, and names which.
|
|
110
|
+
if (e.status === 429)
|
|
111
|
+
throw new InviteRefusal(429, e.message || 'Wait before sending it again');
|
|
112
|
+
// An IAM that predates the send route answers a bare 404.
|
|
113
|
+
if (e.status === 404 && e.message.startsWith('Invitation service answered')) {
|
|
114
|
+
throw new InviteRefusal(404, 'Invitation emails are not switched on yet');
|
|
115
|
+
}
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The organization's shared invite link: an active invitation pinned to nobody
|
|
121
|
+
* with a seat left. Pinned invitations are one person's and are not it.
|
|
122
|
+
*/
|
|
123
|
+
export function shareable(rows) {
|
|
124
|
+
return rows.find((r) => r.state === 'Active' && !r.email && r.quota > 1 && r.usedCount < r.quota);
|
|
125
|
+
}
|
|
126
|
+
/** Withdraws an invitation so its code stops redeeming. */
|
|
127
|
+
export async function withdrawInvitation(inv) {
|
|
128
|
+
await call(`/v1/iam/invitations/${encodeURIComponent(inv.owner)}/${encodeURIComponent(inv.name)}`, {
|
|
129
|
+
method: 'DELETE',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Sets how the organization APPEARS: an image, or one emoji, or neither.
|
|
134
|
+
*
|
|
135
|
+
* `POST /v1/iam/organizations/avatar` writes those two fields onto the stored
|
|
136
|
+
* row and touches nothing else — which the general update cannot do, because it
|
|
137
|
+
* replaces the whole record and a record read back arrives MASKED, so a
|
|
138
|
+
* read-modify-write through it would persist the mask over the organization's
|
|
139
|
+
* own credential settings. That is IAM's own reason for publishing this route,
|
|
140
|
+
* and it is why the logo does not go through `updateOrganization`.
|
|
141
|
+
*
|
|
142
|
+
* An image is an https link or the bytes inline as a data URL, up to 96 KiB —
|
|
143
|
+
* the same bound and the same shape `lib/mark.ts` already produces for a
|
|
144
|
+
* person's photograph, so a logo is not a second kind of picture.
|
|
145
|
+
*/
|
|
146
|
+
export async function setOrganizationMark(owner, name, mark) {
|
|
147
|
+
await call('/v1/iam/organizations/avatar', {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: JSON.stringify({ owner, name, ...mark }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Changes how an organization READS: its display name and its website.
|
|
154
|
+
*
|
|
155
|
+
* `POST /v1/iam/organizations/profile`, the sibling of the avatar route, and it
|
|
156
|
+
* exists for the same reason. Update REPLACES the record, so sending just a new
|
|
157
|
+
* name blanks the website and the sign-in rules — and a record read back arrives
|
|
158
|
+
* MASKED, so the obvious read-modify-write stores "***" as the master password.
|
|
159
|
+
* One costs the organization its settings, the other its credentials. This
|
|
160
|
+
* writes the fields it names and touches nothing else.
|
|
161
|
+
*
|
|
162
|
+
* A field left `undefined` is not sent and not changed; `''` is sent and clears.
|
|
163
|
+
*/
|
|
164
|
+
export async function setOrganizationProfile(owner, name, profile) {
|
|
165
|
+
await call('/v1/iam/organizations/profile', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: JSON.stringify({ owner, name, ...profile }),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The organization's people: everyone who may act in it, with their role.
|
|
172
|
+
*
|
|
173
|
+
* Two reads, because IAM grants access two ways: a MEMBERSHIP row (someone who
|
|
174
|
+
* lives in another org and was let into this one) and an account that LIVES in
|
|
175
|
+
* this org, whose access is the account itself and has no row. A person usually
|
|
176
|
+
* lives in their own personal organization and acts in a team one, so a team
|
|
177
|
+
* org's people are mostly rows; an older org's people mostly live in it.
|
|
178
|
+
*
|
|
179
|
+
* A membership row carries the account and the role and no name, and IAM lets a
|
|
180
|
+
* reader open accounts in the org's own directory and no other org's. So each
|
|
181
|
+
* member is named from what the reader may read: the reader from their own
|
|
182
|
+
* account (`self`, where the caller holds it), a member who lives in the org
|
|
183
|
+
* from its directory, and anyone else by the username the row carries. The
|
|
184
|
+
* directory also marks the org's service accounts, which are left out: the
|
|
185
|
+
* roster beside the agents is where somebody looks for those. A reader who may
|
|
186
|
+
* not read the directory sees the rows alone, by username.
|
|
187
|
+
*/
|
|
188
|
+
export async function members(org, self) {
|
|
189
|
+
const q = encodeURIComponent(org);
|
|
190
|
+
const [roster, directory] = await Promise.all([
|
|
191
|
+
call(`/v1/iam/memberships?org=${q}`),
|
|
192
|
+
call(`/v1/iam/users?owner=${q}`).catch(() => ({ users: [] })),
|
|
193
|
+
]);
|
|
194
|
+
const accounts = directory?.users ?? [];
|
|
195
|
+
const key = (u) => `${u.owner}/${u.name}`;
|
|
196
|
+
const machines = new Set(accounts.filter((u) => u?.type === 'service-account').map(key));
|
|
197
|
+
const people = accounts.filter((u) => u?.type !== 'service-account');
|
|
198
|
+
const known = new Map(people.map((u) => [key(u), u]));
|
|
199
|
+
const mine = me();
|
|
200
|
+
const rows = (Array.isArray(roster?.data) ? roster.data : []).filter((r) => typeof r?.user === 'string' && r.user.includes('/'));
|
|
201
|
+
const named = (id, role, home) => {
|
|
202
|
+
const username = id.slice(id.indexOf('/') + 1);
|
|
203
|
+
if (self && id === mine)
|
|
204
|
+
return { id, name: self.name || username, email: self.email, role, home };
|
|
205
|
+
const account = known.get(id);
|
|
206
|
+
return { id, name: account?.displayName || username, email: account?.email ?? '', role, home };
|
|
207
|
+
};
|
|
208
|
+
const listed = new Set();
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const r of rows) {
|
|
211
|
+
if (machines.has(r.user))
|
|
212
|
+
continue;
|
|
213
|
+
listed.add(r.user);
|
|
214
|
+
out.push(named(r.user, r.role, r.user.slice(0, r.user.indexOf('/')) === org));
|
|
215
|
+
}
|
|
216
|
+
// Everyone whose account lives here acts here, row or no row: IAM puts the home
|
|
217
|
+
// org first in every token it mints (store.MemberOrgRefs) with the role the
|
|
218
|
+
// account's own flag gives it (store.HomeRole).
|
|
219
|
+
for (const u of people) {
|
|
220
|
+
const id = key(u);
|
|
221
|
+
if (listed.has(id))
|
|
222
|
+
continue;
|
|
223
|
+
out.push(named(id, u.isAdmin ? 'admin' : 'member', true));
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Changes an existing member's role: `POST /v1/iam/memberships {user, org, role}`,
|
|
229
|
+
* the one role write HIP-0527 names, by an owner or admin of the org. Resolves to
|
|
230
|
+
* the role IAM holds for them afterwards, read back from the roster, so a caller
|
|
231
|
+
* reports what IAM stored rather than what it asked for.
|
|
232
|
+
*/
|
|
233
|
+
export async function setRole(org, user, next) {
|
|
234
|
+
await call('/v1/iam/memberships', { method: 'POST', body: JSON.stringify({ user, org, role: next }) });
|
|
235
|
+
const roster = (await call(`/v1/iam/memberships?org=${encodeURIComponent(org)}`));
|
|
236
|
+
return (Array.isArray(roster?.data) ? roster.data : []).find((r) => r?.user === user)?.role ?? null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Ends someone's access to the org: `POST /v1/iam/delete-membership {user, org}`.
|
|
240
|
+
* Their account survives. IAM refuses it for a person whose account lives in the
|
|
241
|
+
* org, and says so in its own words.
|
|
242
|
+
*/
|
|
243
|
+
export async function removeMember(org, user) {
|
|
244
|
+
await call('/v1/iam/delete-membership', { method: 'POST', body: JSON.stringify({ user, org }) });
|
|
245
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** One window: the percent of it used, and when it starts over. */
|
|
2
|
+
export interface Span {
|
|
3
|
+
/** Used, 0–100. */
|
|
4
|
+
percent: number;
|
|
5
|
+
/** RFC 3339, or null when no window is running — a session starts at the next request. */
|
|
6
|
+
resets_at: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface Limits {
|
|
9
|
+
/** The plan slug ("max-20x"), or "" for a caller with no plan. */
|
|
10
|
+
plan: string;
|
|
11
|
+
period_start?: string;
|
|
12
|
+
period_end?: string;
|
|
13
|
+
session?: Span;
|
|
14
|
+
day?: Span;
|
|
15
|
+
month?: Span;
|
|
16
|
+
/** The next plan's slug, "" at the top of the ladder. */
|
|
17
|
+
upgrade?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The answer, checked field by field: it is a network response. A body without
|
|
21
|
+
* a string `plan` is not an answer at all; a window without a numeric percent
|
|
22
|
+
* is left out rather than drawn as full or empty.
|
|
23
|
+
*/
|
|
24
|
+
export declare function limitsOf(body: unknown): Limits | null;
|
|
25
|
+
/** Reads the limits as whoever holds the bearer, in this browser's organization. */
|
|
26
|
+
export declare function readLimits(signal?: AbortSignal): Promise<Limits>;
|
|
27
|
+
/**
|
|
28
|
+
* The limits, held, and the way to read them again. `limits` is `null` until
|
|
29
|
+
* answered, and after a refusal: a reader whose limits are unknown is drawn no
|
|
30
|
+
* bars. `answered` says the first read has come back either way, so a surface
|
|
31
|
+
* that shows something only to a reader with no plan waits for it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useLimits(enabled?: boolean, org?: string | null): {
|
|
34
|
+
limits: Limits | null;
|
|
35
|
+
answered: boolean;
|
|
36
|
+
reload: () => void;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Whether a reader's dollar spend may be shown: their limits came back and name no
|
|
40
|
+
* plan, or could not be read. A plan holder sees usage as shares, never money.
|
|
41
|
+
*/
|
|
42
|
+
export declare const spendShown: ({ limits, answered }: {
|
|
43
|
+
limits: Limits | null;
|
|
44
|
+
answered: boolean;
|
|
45
|
+
}) => boolean;
|
|
46
|
+
/** What is left of a window, 0–100: the bar goes down as it is used. */
|
|
47
|
+
export declare const left: (s: Span) => number;
|
|
48
|
+
/** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
|
|
49
|
+
export declare const sku: (id: string) => boolean;
|
|
50
|
+
/** A reset as a clock reads it in this browser — "17:00" — or '' for none. */
|
|
51
|
+
export declare function clock(iso: string | null | undefined): string;
|
|
52
|
+
/** A period's end as a sentence states it — "Oct 30, 2026" — or '' for none. Periods end on UTC days. */
|
|
53
|
+
export declare function date(iso: string | null | undefined): string;
|
|
54
|
+
/** A period's end without its year — "Oct 30" — for the month bar. */
|
|
55
|
+
export declare function day(iso: string | null | undefined): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* WHAT A PLAN HOLDER HAS LEFT, AS A SHARE.
|
|
4
|
+
*
|
|
5
|
+
* A paid plan is a subscription with usage limits, never a debit from a
|
|
6
|
+
* balance. `GET /v1/ai/limits` answers the acting org's plan and three
|
|
7
|
+
* windows — the five-hour session, the day, and the billing month — each as
|
|
8
|
+
* the percent USED with the moment it resets, and the next plan up. Shares
|
|
9
|
+
* only: nothing here is a count of requests or an amount of money.
|
|
10
|
+
*
|
|
11
|
+
* The month is the plan's included usage for the period. Spending it refuses
|
|
12
|
+
* nothing — Enso and Zen keep answering from free models — so it is a line
|
|
13
|
+
* under the bars, never a refusal. The session and the day refuse when spent.
|
|
14
|
+
*
|
|
15
|
+
* `plan` "" is a caller with no plan (Free), and then nothing else is sent:
|
|
16
|
+
* that reader keeps the Free display. A read that fails is `null`, which is not
|
|
17
|
+
* knowing, and nothing is drawn from it.
|
|
18
|
+
*
|
|
19
|
+
* Same base URL and headers as every billing read (lib/hanzo/tier.ts): the
|
|
20
|
+
* bearer is the subject and `X-Org-Id` the organization this browser works in.
|
|
21
|
+
*/
|
|
22
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
23
|
+
import { api } from './api.js';
|
|
24
|
+
import { scope } from './session.js';
|
|
25
|
+
const text = (v) => (typeof v === 'string' ? v : null);
|
|
26
|
+
function span(v) {
|
|
27
|
+
const o = v;
|
|
28
|
+
const p = o?.percent;
|
|
29
|
+
if (typeof p !== 'number' || !Number.isFinite(p))
|
|
30
|
+
return undefined;
|
|
31
|
+
return { percent: Math.min(100, Math.max(0, Math.round(p))), resets_at: text(o?.resets_at) };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The answer, checked field by field: it is a network response. A body without
|
|
35
|
+
* a string `plan` is not an answer at all; a window without a numeric percent
|
|
36
|
+
* is left out rather than drawn as full or empty.
|
|
37
|
+
*/
|
|
38
|
+
export function limitsOf(body) {
|
|
39
|
+
const o = body;
|
|
40
|
+
const plan = text(o?.plan);
|
|
41
|
+
if (plan === null)
|
|
42
|
+
return null;
|
|
43
|
+
if (plan === '')
|
|
44
|
+
return { plan };
|
|
45
|
+
return {
|
|
46
|
+
plan,
|
|
47
|
+
period_start: text(o?.period_start) ?? undefined,
|
|
48
|
+
period_end: text(o?.period_end) ?? undefined,
|
|
49
|
+
session: span(o?.session),
|
|
50
|
+
day: span(o?.day),
|
|
51
|
+
month: span(o?.month),
|
|
52
|
+
upgrade: text(o?.upgrade) ?? '',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Reads the limits as whoever holds the bearer, in this browser's organization. */
|
|
56
|
+
export async function readLimits(signal) {
|
|
57
|
+
const res = await fetch(`${api()}/v1/ai/limits`, { headers: scope(), signal });
|
|
58
|
+
if (!res.ok)
|
|
59
|
+
throw Object.assign(new Error(`Limits answered ${res.status}`), { status: res.status });
|
|
60
|
+
const got = limitsOf(await res.json());
|
|
61
|
+
if (!got)
|
|
62
|
+
throw Object.assign(new Error('Limits answered without a plan'), { status: 502 });
|
|
63
|
+
return got;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The limits, held, and the way to read them again. `limits` is `null` until
|
|
67
|
+
* answered, and after a refusal: a reader whose limits are unknown is drawn no
|
|
68
|
+
* bars. `answered` says the first read has come back either way, so a surface
|
|
69
|
+
* that shows something only to a reader with no plan waits for it.
|
|
70
|
+
*/
|
|
71
|
+
export function useLimits(enabled = true, org = null) {
|
|
72
|
+
const [limits, setLimits] = useState(null);
|
|
73
|
+
const [answered, setAnswered] = useState(false);
|
|
74
|
+
const [nonce, setNonce] = useState(0);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!enabled)
|
|
77
|
+
return;
|
|
78
|
+
const stop = new AbortController();
|
|
79
|
+
readLimits(stop.signal)
|
|
80
|
+
.then((got) => {
|
|
81
|
+
if (!stop.signal.aborted)
|
|
82
|
+
setLimits(got);
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {
|
|
85
|
+
if (!stop.signal.aborted)
|
|
86
|
+
setLimits(null);
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
if (!stop.signal.aborted)
|
|
90
|
+
setAnswered(true);
|
|
91
|
+
});
|
|
92
|
+
return () => stop.abort();
|
|
93
|
+
}, [enabled, nonce, org]);
|
|
94
|
+
return { limits, answered, reload: useCallback(() => setNonce((n) => n + 1), []) };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether a reader's dollar spend may be shown: their limits came back and name no
|
|
98
|
+
* plan, or could not be read. A plan holder sees usage as shares, never money.
|
|
99
|
+
*/
|
|
100
|
+
export const spendShown = ({ limits, answered }) => answered && !limits?.plan;
|
|
101
|
+
/** What is left of a window, 0–100: the bar goes down as it is used. */
|
|
102
|
+
export const left = (s) => 100 - s.percent;
|
|
103
|
+
/** Whether a model is a Hanzo SKU — an Enso or Zen id — the only models a consumer surface offers. */
|
|
104
|
+
export const sku = (id) => /^(hanzo\/)?(enso|zen)/i.test(id);
|
|
105
|
+
/** A reset as a clock reads it in this browser — "17:00" — or '' for none. */
|
|
106
|
+
export function clock(iso) {
|
|
107
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
108
|
+
return Number.isNaN(t) ? '' : new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
109
|
+
}
|
|
110
|
+
/** A period's end as a sentence states it — "Oct 30, 2026" — or '' for none. Periods end on UTC days. */
|
|
111
|
+
export function date(iso) {
|
|
112
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
113
|
+
return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString('en-US', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' });
|
|
114
|
+
}
|
|
115
|
+
/** A period's end without its year — "Oct 30" — for the month bar. */
|
|
116
|
+
export function day(iso) {
|
|
117
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
118
|
+
return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric' });
|
|
119
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetched list, as an array or nothing.
|
|
3
|
+
*
|
|
4
|
+
* `x ?? []` covers null and undefined and nothing else: a body that answers
|
|
5
|
+
* `{rooms: {}}`, or an error object where a page was expected, passes through
|
|
6
|
+
* it and the first `.filter` on it throws in render. A read from the network
|
|
7
|
+
* is a boundary, and this is the one check at it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function list<T>(x: unknown): T[];
|
package/dist/lib/list.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fetched list, as an array or nothing.
|
|
3
|
+
*
|
|
4
|
+
* `x ?? []` covers null and undefined and nothing else: a body that answers
|
|
5
|
+
* `{rooms: {}}`, or an error object where a page was expected, passes through
|
|
6
|
+
* it and the first `.filter` on it throws in render. A read from the network
|
|
7
|
+
* is a boundary, and this is the one check at it.
|
|
8
|
+
*/
|
|
9
|
+
export function list(x) {
|
|
10
|
+
return Array.isArray(x) ? x : [];
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Longest reference IAM stores — `schema.AvatarLimit`, restated because it is
|
|
2
|
+
* the far side of a wire. A write over it is refused there, so it is checked
|
|
3
|
+
* here where a smaller encode is still possible. */
|
|
4
|
+
export declare const LIMIT: number;
|
|
5
|
+
/**
|
|
6
|
+
* A file the reader picked, as a data URL ready to store.
|
|
7
|
+
*
|
|
8
|
+
* Throws where the file is not an image the browser can decode, or where even
|
|
9
|
+
* the lowest quality is over the limit — both are things the caller must say
|
|
10
|
+
* out loud rather than swallow, because the alternative is a picture that
|
|
11
|
+
* silently did not change.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mark(file: File): Promise<string>;
|
package/dist/lib/mark.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A picked image, turned into the thing IAM stores.
|
|
2
|
+
//
|
|
3
|
+
// The server states the contract and states that this side exists:
|
|
4
|
+
// `pkg/schema/avatar.go` accepts an `https` link or an inline `data:` URL of a
|
|
5
|
+
// raster format, bounded at 96 KiB — "which is what a crop performed in a
|
|
6
|
+
// browser produces". Nothing performed it. The profile page drew an upload
|
|
7
|
+
// button with no handler, so the sentence described a step no code took.
|
|
8
|
+
//
|
|
9
|
+
// SQUARE, BECAUSE EVERY READER DRAWS IT ROUND. A portrait handed over whole is
|
|
10
|
+
// cropped by whichever avatar happens to render it, and the person who chose
|
|
11
|
+
// the picture is not the one choosing what survives. Cropping here means the
|
|
12
|
+
// centre they framed is the centre stored.
|
|
13
|
+
//
|
|
14
|
+
// The bound is met by LOWERING QUALITY, not by refusing. A 4 MB photograph is
|
|
15
|
+
// the normal thing to pick, and answering it with "too large" hands somebody an
|
|
16
|
+
// image editor as homework; 256px at falling quality reaches the limit in two
|
|
17
|
+
// or three steps and looks like what they chose.
|
|
18
|
+
/** Longest reference IAM stores — `schema.AvatarLimit`, restated because it is
|
|
19
|
+
* the far side of a wire. A write over it is refused there, so it is checked
|
|
20
|
+
* here where a smaller encode is still possible. */
|
|
21
|
+
export const LIMIT = 96 << 10;
|
|
22
|
+
/** Side of the stored square. The limit was sized for this. */
|
|
23
|
+
const SIDE = 256;
|
|
24
|
+
/** Tried in order until one fits. Below the last, the picture is not worth keeping. */
|
|
25
|
+
const QUALITY = [0.9, 0.75, 0.6, 0.45, 0.3];
|
|
26
|
+
/** JPEG: every browser encodes it, and a photograph is what this holds. PNG at
|
|
27
|
+
* 256² is routinely over the limit for the same picture. */
|
|
28
|
+
const KIND = 'image/jpeg';
|
|
29
|
+
/**
|
|
30
|
+
* A file the reader picked, as a data URL ready to store.
|
|
31
|
+
*
|
|
32
|
+
* Throws where the file is not an image the browser can decode, or where even
|
|
33
|
+
* the lowest quality is over the limit — both are things the caller must say
|
|
34
|
+
* out loud rather than swallow, because the alternative is a picture that
|
|
35
|
+
* silently did not change.
|
|
36
|
+
*/
|
|
37
|
+
export async function mark(file) {
|
|
38
|
+
if (!file.type.startsWith('image/')) {
|
|
39
|
+
throw new Error('Pick an image file.');
|
|
40
|
+
}
|
|
41
|
+
const source = await decode(file);
|
|
42
|
+
const canvas = document.createElement('canvas');
|
|
43
|
+
canvas.width = SIDE;
|
|
44
|
+
canvas.height = SIDE;
|
|
45
|
+
const ink = canvas.getContext('2d');
|
|
46
|
+
if (!ink)
|
|
47
|
+
throw new Error('This browser cannot crop images.');
|
|
48
|
+
// The centre square of whatever was handed over, scaled to fill.
|
|
49
|
+
const edge = Math.min(source.width, source.height);
|
|
50
|
+
ink.drawImage(source, (source.width - edge) / 2, (source.height - edge) / 2, edge, edge, 0, 0, SIDE, SIDE);
|
|
51
|
+
if ('close' in source)
|
|
52
|
+
source.close();
|
|
53
|
+
for (const quality of QUALITY) {
|
|
54
|
+
const url = canvas.toDataURL(KIND, quality);
|
|
55
|
+
if (url.length <= LIMIT)
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
throw new Error('That image will not fit. Try a simpler one.');
|
|
59
|
+
}
|
|
60
|
+
/** `createImageBitmap` where it exists — it decodes off the main thread and
|
|
61
|
+
* honours EXIF orientation, so a photo taken sideways is stored upright. The
|
|
62
|
+
* `<img>` path is the fallback, and Safari below 17 is why there is one. */
|
|
63
|
+
async function decode(file) {
|
|
64
|
+
if (typeof createImageBitmap === 'function') {
|
|
65
|
+
try {
|
|
66
|
+
return await createImageBitmap(file, { imageOrientation: 'from-image' });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Fall through: a decoder that refuses the bytes may still be an image
|
|
70
|
+
// the element can read.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const url = URL.createObjectURL(file);
|
|
74
|
+
try {
|
|
75
|
+
return await new Promise((ok, no) => {
|
|
76
|
+
const img = new Image();
|
|
77
|
+
img.onload = () => ok(img);
|
|
78
|
+
img.onerror = () => no(new Error('That file is not an image this browser reads.'));
|
|
79
|
+
img.src = url;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
URL.revokeObjectURL(url);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A colour mixed down to a share of itself, for a gui colour prop.
|
|
3
|
+
*
|
|
4
|
+
* gui hands a `color-mix()` to the page unchanged; its types only name custom
|
|
5
|
+
* properties, so the value is typed as one.
|
|
6
|
+
*/
|
|
7
|
+
export declare const mix: (ink: string, share: number, space?: 'oklab' | 'srgb') => `var(--${string})`;
|
|
8
|
+
/**
|
|
9
|
+
* A state hue set as text: the hue mixed toward the theme's own ink.
|
|
10
|
+
*
|
|
11
|
+
* design's state colours are one value in both themes: `--state-success` is
|
|
12
|
+
* 2.1:1 on the light paper, and `--destructive` is a FILL (red-600, cut for the
|
|
13
|
+
* white label on it) that reads 4.1:1 as text on the dark ground. Mixed toward
|
|
14
|
+
* `--foreground` they darken on light and lighten on dark, and hold 4.5:1 on
|
|
15
|
+
* either theme's panes (measured on /meet and /drive) while still reading as
|
|
16
|
+
* the hue.
|
|
17
|
+
*/
|
|
18
|
+
export declare const hue: (ink: string, share: number) => `var(--${string})`;
|
|
19
|
+
/** Success, said in text. */
|
|
20
|
+
export declare const GOOD: `var(--${string})`;
|
|
21
|
+
/** A destructive action's word. */
|
|
22
|
+
export declare const BAD: `var(--${string})`;
|
package/dist/lib/mix.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A colour mixed down to a share of itself, for a gui colour prop.
|
|
3
|
+
*
|
|
4
|
+
* gui hands a `color-mix()` to the page unchanged; its types only name custom
|
|
5
|
+
* properties, so the value is typed as one.
|
|
6
|
+
*/
|
|
7
|
+
export const mix = (ink, share, space = 'oklab') => `color-mix(in ${space}, ${ink} ${share}%, transparent)`;
|
|
8
|
+
/**
|
|
9
|
+
* A state hue set as text: the hue mixed toward the theme's own ink.
|
|
10
|
+
*
|
|
11
|
+
* design's state colours are one value in both themes: `--state-success` is
|
|
12
|
+
* 2.1:1 on the light paper, and `--destructive` is a FILL (red-600, cut for the
|
|
13
|
+
* white label on it) that reads 4.1:1 as text on the dark ground. Mixed toward
|
|
14
|
+
* `--foreground` they darken on light and lighten on dark, and hold 4.5:1 on
|
|
15
|
+
* either theme's panes (measured on /meet and /drive) while still reading as
|
|
16
|
+
* the hue.
|
|
17
|
+
*/
|
|
18
|
+
export const hue = (ink, share) => `color-mix(in srgb, ${ink} ${share}%, var(--foreground))`;
|
|
19
|
+
/** Success, said in text. */
|
|
20
|
+
export const GOOD = hue('var(--state-success)', 55);
|
|
21
|
+
/** A destructive action's word. */
|
|
22
|
+
export const BAD = hue('var(--destructive)', 70);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { canonicalOrg, getOrgAndSlug, modelPagePath } from './path.js';
|
|
2
|
+
/** The two fields naming needs. */
|
|
3
|
+
export interface ModelData {
|
|
4
|
+
id: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const ORG_DISPLAY_NAMES: Record<string, string>;
|
|
8
|
+
export declare function orgDisplayName(org?: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* What a model is CALLED, as against the id it answers to.
|
|
11
|
+
*
|
|
12
|
+
* Two things the catalog does that a reader should not have to:
|
|
13
|
+
*
|
|
14
|
+
* it names the house families by their slug — `name` is `"enso"`, the same
|
|
15
|
+
* string as `id` — so the card would read "enso" twice, once in prose type
|
|
16
|
+
* and once in mono. Title-case the slug and the card reads "Enso Flash" over
|
|
17
|
+
* `enso-flash`, which is a name over an address.
|
|
18
|
+
*
|
|
19
|
+
* it prefixes third-party names with the lab — `"DeepSeek: DeepSeek V4 Pro"`,
|
|
20
|
+
* `"SpaceXAI: Grok 4.5"`. The lab is already the group the row sits in and
|
|
21
|
+
* the namespace on the id beside it, so the prefix is the third telling.
|
|
22
|
+
*
|
|
23
|
+
* It lived on /models as a private helper while the table, the tile and the lab
|
|
24
|
+
* index all needed it, which is how the same model could read three ways on one
|
|
25
|
+
* page.
|
|
26
|
+
*/
|
|
27
|
+
export declare function modelName(model: Pick<ModelData, 'id' | 'name'>): string;
|