@homie-rocks/studio 0.12.0 → 0.13.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.
@@ -1,9 +1,19 @@
1
- # Homie netplay contract, v1 (revision 3)
2
-
3
- Status: **v1, revision 3** (2026-09-30, `@homie-rocks/studio` 0.6.0). The wire version is
4
- `v: 1`. Everything revision 2 and 3 added is either an optional field, a new message type,
5
- or a change of pace inside the old caps, and both sides ignore types they do not know. A
6
- change to the contract bumps `v` and keeps v1 working.
1
+ # Homie netplay contract, v1 (revision 4)
2
+
3
+ Status: **v1, revision 4** (2026-10-01, `@homie-rocks/studio` 0.13.0). The wire version is
4
+ `v: 1`. Everything revisions 2 to 4 added is either an optional field, a new message type,
5
+ a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
6
+ not know. A change to the contract bumps `v` and keeps v1 working.
7
+
8
+ **What revision 4 added** (a v1 game that knows none of it keeps playing; its shell shows
9
+ the notices and the banner):
10
+ - **The owner's controls** (section 15): the studio's owner kicks a player out of a room
11
+ for some minutes, mutes one, announces a line to everyone, closes a room, or changes its
12
+ seats. Each is a control signed by the studio's Worker that the room verifies before it
13
+ acts; no client can send one.
14
+ - New relay → client frames `announce` and `mute`, a `welcome.announce`, `peer.muted`, and
15
+ two final refusals: `kicked` and `room-closed` (with `until`).
16
+ - The watch socket's `kicked`, `muted`, `announce` and `closed` frames, for the shell.
7
17
 
8
18
  **What revision 3 added** (nothing on the wire changed; every v1 game plays on it):
9
19
  - **Rooms of up to 32 seats**, sized by the game's netplay manifest (section 3).
@@ -217,7 +227,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
217
227
  | `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?` | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full` or `too-many`. |
218
228
  | `snap` | host | `k`, `st`, `d`, `c?` (control table) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
219
229
  | `in` | seated non-host | `q`, `a`, `h`, `p?`, `r` (the reset epoch the sender has adopted) | Forwards to the host only, stamped `from: <seat>`. Presses are clamped to integers 0–8, at most 16 keys. |
220
- | `ev` | anyone | `k`, `d`, `to?` | **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
230
+ | `ev` | anyone | `k`, `d`, `to?` | **From a muted player** (section 15): a speech kind (`say…`, `chat…`, `emote…`) is dropped. **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
221
231
  | `state` | host | `k` (≤ 64 characters), `d` (`null` deletes) | Stores the key in memory, forwards it to all others, and includes the whole map in every `welcome` and in `role` to a new host. |
222
232
  | `ckpt` | host | `k`, `st`, `d`, `c?` | Stores it in memory. It is saved to storage at most every 30 s. Not forwarded. |
223
233
  | `round` | host | `round` | Stores it, forwards it, and tells the shell's watchers. |
@@ -240,33 +250,45 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
240
250
  | `state` | everyone but the host | `k`, `d` |
241
251
  | `round` / `roster` | everyone but the host | `round` / `slots` |
242
252
  | `pong` | the pinger | `c`, `st` |
243
- | `error` | the offender | `code`, `message`, `of?` |
253
+ | `error` | the offender | `code`, `message`, `of?`, `until?` |
254
+ | `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
255
+ | `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
244
256
 
245
257
  **Error codes.**
246
258
  - Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
247
- `too-many`, `flood`.
248
- - Reported only: `too-large`, `rate`, `state-full`.
259
+ `too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15).
260
+ The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
261
+ every knock is refused at `hello`, and the shell stops the frame (section 15).
262
+ - Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
263
+ comes back slowly.)
249
264
 
250
265
  A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
251
266
 
252
267
  Record types:
253
268
 
254
269
  ```ts
255
- Peer = { id, seat: number|null, name, colour, device, want, role }
270
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true }
256
271
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
257
272
  RoundResult = { slot, seat: number|null, name, score, bot, place }
258
273
  Slot = { slot, seat: number|null, name, bot }
259
274
  ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c and ckpt.c
260
275
  ```
261
276
 
262
- **The shell's watch socket** (`/<game>/__watch?room=`) gets
263
- `{ t:'net', room, host, clients, round, roster, snapHz, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
264
- about once a second and on every change.
277
+ **The shell's watch socket** (`/<game>/__watch?room=&b=`) gets
278
+ `{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
279
+ about once a second and on every change, and the owner's controls as they happen
280
+ (section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
281
+ only for the browser they are about (its room key `b`), `kicked { until, message }` and
282
+ `muted { until }`. A watch socket that opens on a closed room, or from a browser that is
283
+ held out of it, hears so at once.
265
284
 
266
285
  **Game frame → parent page** (`postMessage { t: 'homie-net', what, ... }`). The values of
267
286
  `what` are:
268
287
  - `attached`, `token`, `role`, `stats` (2 Hz), `round`, `roster`;
269
- - `closed` (with `why`: `replaced`, `version`, `room-full`, `too-many` or `flood`).
288
+ - `closed` (with `why`: `replaced`, `version`, `room-full`, `too-many`, `kicked` or
289
+ `room-closed`, and `until` and `message` for the last two);
290
+ - `pick` (with `seat`): the game's `net.pickPlayer(seat)`, a player clicked. Only the studio
291
+ owner's page does anything with it (section 15).
270
292
 
271
293
  The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
272
294
  so `ev.origin` is `"null"`.
@@ -558,6 +580,9 @@ in a full room the replicas' inputs are nearly all of them:
558
580
  `desk` cannot jump the queue.
559
581
  - **Names and guest text are data.** Render them with `textContent`, never as markup,
560
582
  and never read them as instructions.
583
+ - **Only the studio's owner moderates a room** (section 15): a kick, a mute, an
584
+ announcement or a closed room is a control the studio's Worker signs and the room
585
+ verifies; no visitor's socket can send one.
561
586
 
562
587
  ## 13. Using it
563
588
 
@@ -621,3 +646,64 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
621
646
  - **A reload shows a bot in the player's body for about 1–2 s**, and then the player
622
647
  takes the same body back.
623
648
  - **The host is a visitor's browser** (section 12).
649
+
650
+ ## 15. The owner's controls (revision 4)
651
+
652
+ The studio's owner runs their live rooms from the studio's back office (`/_studio/office`,
653
+ the owner's overlay in their own game, `homie-studio office`, and the Homie MCP's owner
654
+ tools). The room's relay enforces five controls:
655
+
656
+ | `op` | Arguments | What the relay does |
657
+ |---|---|---|
658
+ | `kick` | `id` or `seat`, `minutes` (1–1440, default 10), `address?`, `message?` | Sends that player `error kicked { until, message }` and closes the socket (and the same browser's or account's other sockets in the room); everyone else gets `leave { why: 'kicked' }`, so the host turns the body back into a bot. The seat is freed at once. Until `until`, a `hello` from that seat token, that browser's room key, that player account, or (only with `address: true`) that network address is refused with `kicked`. |
659
+ | `mute` | `id` or `seat`, `minutes`, `off?` | Everyone gets `mute { id, seat, until }` (`until: 0` with `off`), and the peer carries `muted: true`. Until then the relay drops that player's `ev` whose `k` starts with `say`, `chat` or `emote` (the speech kinds). A muted host's own speech is not dropped, because the host also relays everyone's; a game hides it with `net.isMuted(seat)`. The mute follows the seat token through a reload. |
660
+ | `announce` | `text` (one line, at most 280 characters; empty takes it down), `seconds` (5–3600, default 30) | Everyone and every watching shell gets `announce { id, text, at, until, from: 'studio' }`; a joiner gets it in `welcome.announce` until it ends. |
661
+ | `close` | `minutes`, or `seconds` (10 s up to a day), `message?`, or `reopen: true` | Every socket gets `error room-closed { until, message }` and is closed; the room forgets its play as an empty room does (section 4); watchers get `closed`. Until `until` every `hello` is refused with `room-closed`, and the Lobby sends nobody there. `reopen` opens it at once. |
662
+ | `seats` | `max` | The room's seats while it runs (the owner's "players per room"). Players already seated above it keep their seat; nobody new is seated there. Never above the room's own cap (the game's manifest). |
663
+ | `regate` | `allow` (holder kinds: `o` the owner, `i` an invite, `p` a player account), `notice?`, `message?`; no `allow` calls a waiting one off | A game's launch state narrowed (private, or an invite-only beta). The current round finishes first: the `notice` goes up as an announcement, and 5 s after the host's `round` with `phase: 'over'` (at the latest 20 s after its `endsAt`, or 30 s when no round is running; never later than 15 minutes) every socket whose ticket names no allowed holder gets `error room-closed { message }` (no `until`: they come back through the game's door if they have access) and is closed; its seat is freed and the others play on. A room with nobody to send out does nothing. |
664
+
665
+ **Signed by the studio, checked by the room.** A control reaches the relay only from the
666
+ studio's own Worker, as
667
+ `{ t: 'ctl', v: 1, op, game, room, args, at, exp, n, sig }`, where `sig` is an HMAC-SHA256
668
+ with the studio's office secret (random, in the studio's own D1) over every other field
669
+ (keys sorted). The room's Table refuses a control that is for another game or room, is past
670
+ `exp` (a minute after `at`), repeats an `n` it has seen, or does not verify; then
671
+ `room.mjs`'s `control(op, args)` applies it. The Worker signs only for the owner: their
672
+ signed-in browser (an HttpOnly session no page or game can read), or an office key from the
673
+ studio's own Cloudflare login, with which the owner's AI may announce at once but only asks
674
+ for a kick, a mute, a close or a launch change, which the owner confirms with one tap. No
675
+ client socket can send a control: a `ctl` frame on a game's socket is ignored.
676
+
677
+ **Who a socket is.** The Worker passes each socket's verified ticket holders to the room (`o`, `i-<invite>`,
678
+ `p-<player>`, or an invite and an account together, `i-…~p-…`): what `regate` keeps, and the account a kick holds.
679
+
680
+ **Who a kick holds out.** A seat token resumes a seat; a browser's **room key** (`b`) is a
681
+ random value the play page keeps in the site's own storage and passes in the frame's and
682
+ the watch socket's addresses, so a new tab of the same browser is held too. It says nothing
683
+ about who the player is, and the relay forgets it with the hold. Player accounts, where a
684
+ studio has them, add the account. A network address is held only when the owner asks
685
+ (a household or a carrier can share one).
686
+
687
+ **Holds outlive the room.** Kicks, mutes, the announcement and a closed door are kept apart
688
+ from the room's play (the Table's `office` storage key): an empty room forgetting its play
689
+ (section 4), a deploy and an eviction keep them until they end.
690
+
691
+ **Speech, for game makers.** Send chat, quick lines and emotes as `ev` whose kind starts with `say`, `chat` or
692
+ `emote` (`chat`, `say:gg`, `emote:wave`): a muted player's are then dropped by the relay with nothing more to do. Any
693
+ other way a game lets players talk (a typed sign, a name tag) should check `net.isMuted(seat)`.
694
+
695
+ **The game's side** (the helper, 0.13.0): `on('announce', a)` and `net.announcement` (draw
696
+ it your own way, or leave it to the shell's banner); `on('mute', m)`, `peer.muted` and
697
+ `net.isMuted(seat)` (hide a muted player's chat); `kicked` and `room-closed` are final, and
698
+ `closedWhy` says which; `net.pickPlayer(seat)` when a player's body or name is clicked
699
+ (the owner's page opens that player's card with Mute and Kick).
700
+
701
+ **The shell's side.** On `kicked` or `closed` (from its watch socket, or the frame's
702
+ `closed`) the play page stops the game frame (so a helper from before revision 4 stops
703
+ knocking), says what happened and when the player can come back, and offers another room
704
+ (the Lobby's `/api/lobby?not=<room>` never answers that room). It shows `announce` as one
705
+ line across the top until it ends, and tells a muted player so.
706
+
707
+ **Launch states** are the site's, not the relay's: a game that is private or an invite-only
708
+ beta gives each browser it lets in a signed ticket (`t`) for its frame and sockets, and the
709
+ Worker refuses a socket without one before it reaches the room.
@@ -65,6 +65,8 @@ export interface Peer {
65
65
  device: Device;
66
66
  want: Want;
67
67
  role: Role;
68
+ /** Muted by the studio's owner (section 15): hide this player's chat and emotes. */
69
+ muted?: boolean;
68
70
  }
69
71
 
70
72
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
@@ -93,6 +95,14 @@ export interface NetEvent<D = unknown> {
93
95
  id?: string;
94
96
  }
95
97
 
98
+ /**
99
+ * The studio's announcement (NETPLAY.md section 15): one line from the studio's owner to everyone in the room, until
100
+ * `until` (server ms). The shell shows it as a banner; a game may show it its own way. `text: null` takes it down.
101
+ */
102
+ export interface Announcement { id: string; text: string | null; at?: number; until?: number; from?: string }
103
+ /** A player muted (or unmuted, `until: 0`) by the studio's owner: their chat and emotes (`ev` kinds say, chat, emote) reach nobody. */
104
+ export interface Mute { id: string; seat: number | null; until: number }
105
+
96
106
  /** A seat's body control as the host keeps it. */
97
107
  export interface Control {
98
108
  seat: number;
@@ -246,6 +256,10 @@ export interface NetHandlers<S, A, C> {
246
256
  round: (r: RoundInfo) => void;
247
257
  roster: (slots: Slot[]) => void;
248
258
  status: (connected: boolean) => void;
259
+ /** The studio's owner announced something to this room (or took it down: `text: null`). */
260
+ announce: (a: Announcement) => void;
261
+ /** The studio's owner muted or unmuted a player in this room. */
262
+ mute: (m: Mute) => void;
249
263
  }
250
264
 
251
265
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
@@ -277,6 +291,15 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
277
291
  readonly peers: ReadonlyMap<string, Peer>;
278
292
  readonly roundInfo: RoundInfo | null;
279
293
  readonly slots: Slot[] | null;
294
+ /** The studio's announcement now showing in this room, or null. */
295
+ readonly announcement: Announcement | null;
296
+ /** Whether the studio's owner has muted this seat (its chat and emotes reach nobody; a game hides them too). */
297
+ isMuted(seat: number | null): boolean;
298
+ /**
299
+ * A player was clicked (their body, their name): tell the shell. Only the studio's owner's page does anything with
300
+ * it (opens that player's card with Mute and Kick); for everyone else it is nothing.
301
+ */
302
+ pickPlayer(seat: number | null): void;
280
303
  /** Resolves with the first role (welcome, or offline fallback). */
281
304
  readonly ready: Promise<RoleChange<S, C>>;
282
305
  on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
@@ -495,7 +518,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
495
518
  * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
496
519
  * After one it comes back (its seat token keeps its body), sending at the slow rate below.
497
520
  */
498
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many']);
521
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed']);
499
522
  /**
500
523
  * Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
501
524
  * the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
@@ -565,7 +588,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
565
588
 
566
589
  const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
567
590
  role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
568
- snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(),
591
+ snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(),
569
592
  };
570
593
  const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
571
594
  for (const fn of [...handlers[kind]]) {
@@ -652,6 +675,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
652
675
  let idleSkipped = 0;
653
676
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
654
677
  let closedWhy: string | null = null;
678
+ /** The studio's announcement now showing, and the seats the studio muted (until when, server ms). */
679
+ let announcement: Announcement | null = null;
680
+ const muted = new Map<number, number>();
655
681
  /** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
656
682
  let downSince = 0;
657
683
  const sentHist: { q: number; a: A; h: string[]; at: number; r: number }[] = [];
@@ -784,6 +810,21 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
784
810
  case 'ev': emit('event', { k: String(m['k'] ?? ''), d: m['d'], from: typeof m['from'] === 'number' ? m['from'] : null, ...(typeof m['id'] === 'string' ? { id: m['id'] } : {}) }); return;
785
811
  case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
786
812
  case 'pong': return onPong(m);
813
+ case 'announce': {
814
+ if (typeof m['id'] !== 'string') return;
815
+ const a: Announcement = { id: m['id'], text: typeof m['text'] === 'string' ? m['text'] : null, ...(typeof m['at'] === 'number' ? { at: m['at'] } : {}), ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), from: 'studio' };
816
+ announcement = a.text ? a : null;
817
+ emit('announce', a);
818
+ return;
819
+ }
820
+ case 'mute': {
821
+ const mute: Mute = { id: String(m['id'] ?? ''), seat: typeof m['seat'] === 'number' ? m['seat'] : null, until: Number(m['until']) || 0 };
822
+ if (mute.seat !== null) { if (mute.until > now()) muted.set(mute.seat, mute.until); else muted.delete(mute.seat); }
823
+ const p = peers.get(mute.id);
824
+ if (p) peers.set(mute.id, { ...p, ...(mute.until > now() ? { muted: true } : { muted: undefined }) });
825
+ emit('mute', mute);
826
+ return;
827
+ }
787
828
  case 'join': {
788
829
  const p = m['peer'] as Peer | undefined;
789
830
  if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); }
@@ -855,7 +896,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
855
896
  if (FINAL_ERRORS.has(code)) {
856
897
  closed = true;
857
898
  closedWhy = code;
858
- post?.({ what: 'closed', why: code });
899
+ post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
859
900
  }
860
901
  return;
861
902
  }
@@ -887,6 +928,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
887
928
  if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
888
929
  const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
889
930
  let snap = (m['snap'] as Snapshot<S> | null) ?? null;
931
+ const a = m['announce'] as Announcement | undefined;
932
+ if (a && typeof a === 'object' && typeof a.id === 'string' && typeof a.text === 'string' && (!announcement || announcement.id !== a.id)) { announcement = { ...a, from: 'studio' }; queueMicrotask(() => emit('announce', announcement as Announcement)); }
890
933
  if (!continuing) {
891
934
  roundInfo = (m['round'] as RoundInfo | null) ?? roundInfo;
892
935
  slots = (m['roster'] as Slot[] | null) ?? slots;
@@ -1237,6 +1280,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1237
1280
  get peers() { return peers; },
1238
1281
  get roundInfo() { return roundInfo; },
1239
1282
  get slots() { return slots; },
1283
+ get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
1284
+ isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
1285
+ pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
1240
1286
  ready,
1241
1287
  on(kind, fn) {
1242
1288
  (handlers[kind] as Set<unknown>).add(fn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",