Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 2d861c6bf0 chore: publish and document packages at the registry's current Zayin location
CI / verify (pull_request) Successful in 1m17s
CI / infrastructure-smoke (pull_request) Successful in 31s
CI / package-release (pull_request) Successful in 49s
CI / image-build (pull_request) Successful in 59s
CI / migration-smoke (pull_request) Successful in 2m16s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m39s
The package registry moved with the repositories to
git.zayin.net/api/packages/Zayin/npm/; the old River_of_Glory_Church address
now answers 502, so the next manual Publish packages run would have failed.
Point the workflow, the release and module-development docs, and the
disposable-Gitea publication proof's owner at Zayin. The CI Docker label keys
keep their names: they are identifiers the sweep matches on, not hosts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:52 -05:00
12 changed files with 20 additions and 264 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ concurrency:
env: env:
# The only registry-specific setting: where the train is published. # The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs: jobs:
package-release: package-release:
+1 -7
View File
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js"; import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
@@ -407,12 +407,6 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry, registry: options.authorization.registry,
runtime, runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
}); });
if (options.devices) { if (options.devices) {
+1 -22
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/ */
function writeDeviceCookie( function writeDeviceCookie(
c: Context, c: Context,
options: Pick<DeviceRoutesOptions, "secureCookies">, options: DeviceRoutesOptions,
name: string, name: string,
value: string, value: string,
maxAgeSeconds: number, maxAgeSeconds: number,
@@ -106,27 +106,6 @@ function writeDeviceCookie(
}); });
} }
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp(); const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date()); const now = options.now ?? ((): Date => new Date());
-8
View File
@@ -83,9 +83,6 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
/** Omitted only when the registry contains no display entrypoints. Production reads the exact /** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */ * surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets; readonly assets?: DisplayHostAssets;
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
* deployment has device support; called only for a request that resolved to a device. */
readonly renewDeviceCredential?: (c: Context) => void;
} }
/** A composition error, detected before any display route is registered. Carries no module id, /** A composition error, detected before any display route is registered. Carries no module id,
@@ -202,11 +199,6 @@ function mountDisplay(
if (!viewer) { if (!viewer) {
return displayFailure(c); return displayFailure(c);
} }
// Every screen asks for this on start, on reconnect, and periodically while running, so it
// is where an in-use credential's browser expiry is pushed forward.
if (principal.kind === "device") {
options.renewDeviceCredential?.(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200); return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch { } catch {
return displayFailure(c); return displayFailure(c);
@@ -782,127 +782,6 @@ describe.sequential("display device pairing, end to end", () => {
} }
}); });
/**
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
*/
describe("credential lifetime", () => {
const ttlSeconds = 180 * 24 * 60 * 60;
const descriptor = "/api/v1/module-displays/announcements";
function renewal(response: Response): string | undefined {
return setCookieFor(response, "ekklesia_device_session");
}
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
const running = await platform("renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
);
const response = await device.request(descriptor);
expect(response.status).toBe(200);
// The same credential, re-issued with a full lifetime and the same protections.
const header = renewal(response);
expect(header).toBeDefined();
expect(header).toContain(`ekklesia_device_session=${credential};`);
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
expect(header).toMatch(/HttpOnly/u);
expect(header).toMatch(/SameSite=Strict/u);
expect(header).toMatch(/Path=\//u);
expect(header).not.toMatch(/Domain=/iu);
// Using it moved the server's window forward too.
const [row] = await query<{ recent: boolean }>(
running.url,
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
);
expect(row?.recent).toBe(true);
} finally {
await running.close();
}
});
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
const running = await platform("legacy");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
// A production screen paired 179 days ago that checked in this morning: under the old
// rule it had one day left. It must carry straight on.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
);
expect((await device.request(descriptor)).status).toBe(200);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
);
expect((await device.request(descriptor)).status).toBe(200);
} finally {
await running.close();
}
});
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
const running = await platform("abandoned");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
);
const response = await device.request(descriptor);
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("never renews a revoked screen", async () => {
const running = await platform("revoked-renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
const response = await device.request(descriptor);
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("does not hand a device cookie to a person viewing the display", async () => {
const running = await platform("human-renewal");
try {
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
expect(response.status).toBe(200);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => { it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed"); const running = await platform("mixed");
const device = new DeviceBrowser(running.origin); const device = new DeviceBrowser(running.origin);
+8 -18
View File
@@ -44,26 +44,17 @@ export class DeviceStoreError extends Error {
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/** /**
* How long a device credential may go **unused** before the device must pair again (ADR 0056). * How long a device credential is accepted before the device must pair again.
* *
* The window slides: every request from the device moves `lastSeenAt` forward, and the host * A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an * because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it * on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops * decides on every single request whether the device is still revoked, so an administrator's revoke
* being an identity at all: 180 days after it was last seen, its credential is dead. * takes effect immediately regardless of how long the credential would otherwise have lived. The
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
*/ */
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** When a device credential expires: the idle window, measured from the last time the device was
* seen, or from pairing if it never has been. */
export function deviceCredentialExpiresAt(input: {
readonly createdAt: Date;
readonly lastSeenAt?: Date | null | undefined;
}): Date {
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
}
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling /** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */ * realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
@@ -484,8 +475,7 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
} }
// The credential's own horizon, measured from when the device was paired. Revocation is // The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable. // checked above and is what makes a long horizon acceptable.
// Expiry is measured from last use, not from pairing, so a screen in use never ages out. if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
return undefined; return undefined;
} }
return Object.freeze({ return Object.freeze({
@@ -2,7 +2,6 @@
- Status: Accepted - Status: Accepted
- Date: 2026-08-24 - Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md), [0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md), [0033](0033-display-kiosk-host-contract.md),
@@ -1,76 +0,0 @@
# 0056: A device credential lasts while the device is in use
- Status: Accepted
- Date: 2026-10-12
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
request, re-pairing as a new identity — is unchanged.
## Context
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
stop being an identity.
A fixed horizon does the second and fails the first. Every screen in the building expires on the
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
in front of the congregation until someone with Dashboard access walks to it. For screens paired
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
on day 181 than on day 179; only the calendar changed.
## Decision
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
write per 15 minutes), so the server-side window slides with use and needs no new column or
migration.
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
display never receives a device cookie.
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
revoke still takes effect immediately.
## Why not rotate
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
the display: a renewal response lost to a network drop, a power cut, or a browser that never
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
wall goes dark. That is not justified while revocation is immediate and a device principal can only
read the displays it was paired for.
## Consequences
- A screen that is used at least once every 180 days stays paired until an administrator revokes
it. An abandoned screen still stops being an identity 180 days after it was last seen.
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
time it requests its descriptor, provided its browser still holds the cookie.
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
device may do — the display permissions of the module it was paired for, never a person's access
— and by revocation. Operators can see each device's last-seen time and revoke anything
unexpected.
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
it is the device.
## Alternatives
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
together fail together.
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
its credential would never stop working without someone remembering to revoke it.
- **Rotate on renewal.** Rejected for now; see above.
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
middleware that never writes responses. One endpoint every display already requests periodically
is enough, and keeps the behaviour in one visible place.
+1 -2
View File
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056) - [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
@@ -64,4 +64,3 @@ ADRs capture durable choices that affect more than one package or phase.
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted - [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted - [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted - [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
+6 -6
View File
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
## Publishing the train ## Publishing the train
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner: The registry is the Gitea npm registry under the `Zayin` owner:
```text ```text
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ https://git.zayin.net/api/packages/Zayin/npm/
``` ```
| Who | May | | Who | May |
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
{ {
"publicationRecord": 1, "publicationRecord": 1,
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" }, "packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/", "registry": "https://git.zayin.net/api/packages/Zayin/npm/",
"finalTag": "latest", "finalTag": "latest",
"completedAt": "<ISO time>", "completedAt": "<ISO time>",
"packages": [ "packages": [
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by - **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
the publish step. Nobody needs a token to read or install. the publish step. Nobody needs a token to read or install.
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one - **The publisher** is a dedicated account, member only of a `Zayin` team whose one
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
`write:package` also allows deleting packages; the tool never does, and nothing else uses the `write:package` also allows deleting packages; the tool never does, and nothing else uses the
token. token.
@@ -319,7 +319,7 @@ guarantee:
- **A published version is never deleted,** by anyone, for any reason short of a security incident. - **A published version is never deleted,** by anyone, for any reason short of a security incident.
- **A version number is never reused with other bytes.** A version found on the registry with other - **A version number is never reused with other bytes.** A version found on the registry with other
bytes is burned: investigate how it got there, and publish the train under a new version. bytes is burned: investigate how it got there, and publish the train under a new version.
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner. - **No package cleanup rule** may be configured for the `Zayin` owner.
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests - **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
check that. check that.
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
Before it, an administrator of the Gitea instance: Before it, an administrator of the Gitea instance:
1. creates the dedicated publisher account; 1. creates the dedicated publisher account;
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages 2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
unit at Write, and adds the account to it; unit at Write, and adds the account to it;
3. creates a token for the account with only `write:package`, and stores it as the repository 3. creates a token for the account with only `write:package`, and stores it as the repository
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`; Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
+1 -1
View File
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
the registry in your repository's committed `.npmrc`; that is all the configuration there is: the registry in your repository's committed `.npmrc`; that is all the configuration there is:
```ini ```ini
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ @ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
``` ```
Then the module builds and packages like any other: Then the module builds and packages like any other:
+1 -1
View File
@@ -110,7 +110,7 @@ for user in publisher outsider; do
password_variable="${user}_password" password_variable="${user}_password"
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
done done
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout) owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
for owner in "${owners[@]}"; do for owner in "${owners[@]}"; do
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' | team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |