@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.
Files changed (235) hide show
  1. package/LICENSE-APACHE +205 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +23 -0
  4. package/dist/Account.d.ts +8 -0
  5. package/dist/Account.js +58 -0
  6. package/dist/Beluga.d.ts +12 -0
  7. package/dist/Beluga.js +152 -0
  8. package/dist/Billing.d.ts +1 -0
  9. package/dist/Billing.js +31 -0
  10. package/dist/Board.d.ts +9 -0
  11. package/dist/Board.js +447 -0
  12. package/dist/Boxes.d.ts +1 -0
  13. package/dist/Boxes.js +173 -0
  14. package/dist/CalScheduler.d.ts +15 -0
  15. package/dist/CalScheduler.js +196 -0
  16. package/dist/Chat.d.ts +35 -0
  17. package/dist/Chat.js +1049 -0
  18. package/dist/Column.d.ts +15 -0
  19. package/dist/Column.js +88 -0
  20. package/dist/Contacts.d.ts +1 -0
  21. package/dist/Contacts.js +301 -0
  22. package/dist/Directory.d.ts +24 -0
  23. package/dist/Directory.js +356 -0
  24. package/dist/Drive.d.ts +2 -0
  25. package/dist/Drive.js +466 -0
  26. package/dist/Find.d.ts +7 -0
  27. package/dist/Find.js +82 -0
  28. package/dist/Guide.d.ts +6 -0
  29. package/dist/Guide.js +272 -0
  30. package/dist/Home.d.ts +39 -0
  31. package/dist/Home.js +306 -0
  32. package/dist/Inbox.d.ts +9 -0
  33. package/dist/Inbox.js +193 -0
  34. package/dist/Market.d.ts +3 -0
  35. package/dist/Market.js +149 -0
  36. package/dist/Me.d.ts +27 -0
  37. package/dist/Me.js +251 -0
  38. package/dist/Meet.d.ts +9 -0
  39. package/dist/Meet.js +370 -0
  40. package/dist/ModeMark.d.ts +8 -0
  41. package/dist/ModeMark.js +18 -0
  42. package/dist/OrgSwitcher.d.ts +31 -0
  43. package/dist/OrgSwitcher.js +120 -0
  44. package/dist/Orgs.d.ts +10 -0
  45. package/dist/Orgs.js +582 -0
  46. package/dist/Profile.d.ts +4 -0
  47. package/dist/Profile.js +67 -0
  48. package/dist/Prose.d.ts +5 -0
  49. package/dist/Prose.js +111 -0
  50. package/dist/ProviderMark.d.ts +17 -0
  51. package/dist/ProviderMark.js +277 -0
  52. package/dist/Recents.d.ts +10 -0
  53. package/dist/Recents.js +91 -0
  54. package/dist/RightPane.d.ts +13 -0
  55. package/dist/RightPane.js +426 -0
  56. package/dist/Room.d.ts +8 -0
  57. package/dist/Room.js +50 -0
  58. package/dist/Scene.d.ts +12 -0
  59. package/dist/Scene.js +105 -0
  60. package/dist/Settings.d.ts +46 -0
  61. package/dist/Settings.js +321 -0
  62. package/dist/Setup.d.ts +1 -0
  63. package/dist/Setup.js +36 -0
  64. package/dist/Share.d.ts +3 -0
  65. package/dist/Share.js +130 -0
  66. package/dist/Shared.d.ts +1 -0
  67. package/dist/Shared.js +113 -0
  68. package/dist/Shell.d.ts +63 -0
  69. package/dist/Shell.js +1225 -0
  70. package/dist/Start.d.ts +1 -0
  71. package/dist/Start.js +96 -0
  72. package/dist/Support.d.ts +14 -0
  73. package/dist/Support.js +122 -0
  74. package/dist/Talk.d.ts +4 -0
  75. package/dist/Talk.js +107 -0
  76. package/dist/Upgrade.d.ts +11 -0
  77. package/dist/Upgrade.js +75 -0
  78. package/dist/Usage.d.ts +1 -0
  79. package/dist/Usage.js +21 -0
  80. package/dist/Vibe.d.ts +2 -0
  81. package/dist/Vibe.js +188 -0
  82. package/dist/Work.d.ts +12 -0
  83. package/dist/Work.js +256 -0
  84. package/dist/bots/Bots.d.ts +2 -0
  85. package/dist/bots/Bots.js +456 -0
  86. package/dist/bots/Lights.d.ts +8 -0
  87. package/dist/bots/Lights.js +13 -0
  88. package/dist/bots/api.d.ts +120 -0
  89. package/dist/bots/api.js +75 -0
  90. package/dist/bots/face.d.ts +5 -0
  91. package/dist/bots/face.js +13 -0
  92. package/dist/bots/ink.d.ts +7 -0
  93. package/dist/bots/ink.js +29 -0
  94. package/dist/bots/kit.d.ts +113 -0
  95. package/dist/bots/kit.js +43 -0
  96. package/dist/bots/runtime.d.ts +4 -0
  97. package/dist/bots/runtime.js +326 -0
  98. package/dist/card.d.ts +52 -0
  99. package/dist/card.js +52 -0
  100. package/dist/cast.d.ts +143 -0
  101. package/dist/cast.js +251 -0
  102. package/dist/connect.d.ts +29 -0
  103. package/dist/connect.js +64 -0
  104. package/dist/contacts-store.d.ts +111 -0
  105. package/dist/contacts-store.js +276 -0
  106. package/dist/conversations.d.ts +54 -0
  107. package/dist/conversations.js +98 -0
  108. package/dist/copy.d.ts +23 -0
  109. package/dist/copy.js +39 -0
  110. package/dist/crew.d.ts +36 -0
  111. package/dist/crew.js +304 -0
  112. package/dist/dev.d.ts +3 -0
  113. package/dist/dev.js +102 -0
  114. package/dist/enso.d.ts +70 -0
  115. package/dist/enso.js +112 -0
  116. package/dist/failure.d.ts +57 -0
  117. package/dist/failure.js +143 -0
  118. package/dist/ground.d.ts +32 -0
  119. package/dist/ground.js +32 -0
  120. package/dist/host.d.ts +76 -0
  121. package/dist/host.js +76 -0
  122. package/dist/index.d.ts +14 -0
  123. package/dist/index.js +12 -0
  124. package/dist/kept.d.ts +5 -0
  125. package/dist/kept.js +27 -0
  126. package/dist/lib/account.d.ts +49 -0
  127. package/dist/lib/account.js +229 -0
  128. package/dist/lib/ai.d.ts +46 -0
  129. package/dist/lib/ai.js +140 -0
  130. package/dist/lib/api.d.ts +43 -0
  131. package/dist/lib/api.js +62 -0
  132. package/dist/lib/bare.d.ts +9 -0
  133. package/dist/lib/bare.js +7 -0
  134. package/dist/lib/coding.d.ts +105 -0
  135. package/dist/lib/coding.js +87 -0
  136. package/dist/lib/destination.d.ts +102 -0
  137. package/dist/lib/destination.js +260 -0
  138. package/dist/lib/durable.d.ts +66 -0
  139. package/dist/lib/durable.js +107 -0
  140. package/dist/lib/first.d.ts +11 -0
  141. package/dist/lib/first.js +23 -0
  142. package/dist/lib/home.d.ts +61 -0
  143. package/dist/lib/home.js +54 -0
  144. package/dist/lib/host.d.ts +104 -0
  145. package/dist/lib/host.js +132 -0
  146. package/dist/lib/hostname.d.ts +1 -0
  147. package/dist/lib/hostname.js +14 -0
  148. package/dist/lib/hydrated.d.ts +1 -0
  149. package/dist/lib/hydrated.js +20 -0
  150. package/dist/lib/integrations.d.ts +21 -0
  151. package/dist/lib/integrations.js +1 -0
  152. package/dist/lib/intent.d.ts +6 -0
  153. package/dist/lib/intent.js +71 -0
  154. package/dist/lib/invite.d.ts +155 -0
  155. package/dist/lib/invite.js +245 -0
  156. package/dist/lib/limits.d.ts +55 -0
  157. package/dist/lib/limits.js +119 -0
  158. package/dist/lib/list.d.ts +9 -0
  159. package/dist/lib/list.js +11 -0
  160. package/dist/lib/mark.d.ts +13 -0
  161. package/dist/lib/mark.js +85 -0
  162. package/dist/lib/mix.d.ts +22 -0
  163. package/dist/lib/mix.js +22 -0
  164. package/dist/lib/models.d.ts +27 -0
  165. package/dist/lib/models.js +106 -0
  166. package/dist/lib/org.d.ts +109 -0
  167. package/dist/lib/org.js +140 -0
  168. package/dist/lib/path.d.ts +9 -0
  169. package/dist/lib/path.js +29 -0
  170. package/dist/lib/plans.d.ts +193 -0
  171. package/dist/lib/plans.js +310 -0
  172. package/dist/lib/prose.d.ts +13 -0
  173. package/dist/lib/prose.js +62 -0
  174. package/dist/lib/reach.d.ts +17 -0
  175. package/dist/lib/reach.js +70 -0
  176. package/dist/lib/rooms.json +23 -0
  177. package/dist/lib/served.d.ts +20 -0
  178. package/dist/lib/served.js +92 -0
  179. package/dist/lib/session.d.ts +211 -0
  180. package/dist/lib/session.js +493 -0
  181. package/dist/lib/share.d.ts +131 -0
  182. package/dist/lib/share.js +216 -0
  183. package/dist/lib/signout.d.ts +14 -0
  184. package/dist/lib/signout.js +40 -0
  185. package/dist/lib/tags.d.ts +27 -0
  186. package/dist/lib/tags.js +46 -0
  187. package/dist/lib/tap.d.ts +17 -0
  188. package/dist/lib/tap.js +9 -0
  189. package/dist/lib/tier.d.ts +115 -0
  190. package/dist/lib/tier.js +207 -0
  191. package/dist/lib/tiers.d.ts +9 -0
  192. package/dist/lib/tiers.js +141 -0
  193. package/dist/lib/todo.d.ts +125 -0
  194. package/dist/lib/todo.js +379 -0
  195. package/dist/look.d.ts +1 -0
  196. package/dist/look.js +21 -0
  197. package/dist/meters.d.ts +8 -0
  198. package/dist/meters.js +31 -0
  199. package/dist/model.d.ts +4 -0
  200. package/dist/model.js +44 -0
  201. package/dist/network.d.ts +64 -0
  202. package/dist/network.js +216 -0
  203. package/dist/open.d.ts +81 -0
  204. package/dist/open.js +149 -0
  205. package/dist/pane.d.ts +92 -0
  206. package/dist/pane.js +231 -0
  207. package/dist/progress.d.ts +46 -0
  208. package/dist/progress.js +51 -0
  209. package/dist/recent.d.ts +67 -0
  210. package/dist/recent.js +161 -0
  211. package/dist/rooms.d.ts +131 -0
  212. package/dist/rooms.js +195 -0
  213. package/dist/roster.d.ts +28 -0
  214. package/dist/roster.js +70 -0
  215. package/dist/span.d.ts +14 -0
  216. package/dist/span.js +50 -0
  217. package/dist/speech.d.ts +44 -0
  218. package/dist/speech.js +229 -0
  219. package/dist/stars.d.ts +16 -0
  220. package/dist/stars.js +75 -0
  221. package/dist/tabs.d.ts +26 -0
  222. package/dist/tabs.js +25 -0
  223. package/dist/team.d.ts +11 -0
  224. package/dist/team.gen.d.ts +10 -0
  225. package/dist/team.gen.js +11 -0
  226. package/dist/team.js +30 -0
  227. package/dist/tools.d.ts +35 -0
  228. package/dist/tools.js +143 -0
  229. package/dist/tours.d.ts +24 -0
  230. package/dist/tours.js +311 -0
  231. package/dist/waiting.d.ts +15 -0
  232. package/dist/waiting.js +17 -0
  233. package/dist/where.d.ts +54 -0
  234. package/dist/where.js +14 -0
  235. 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[];
@@ -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>;
@@ -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})`;
@@ -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;