Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
650932a170 |
@@ -27,7 +27,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
# The only registry-specific setting: where the train is published.
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
|
||||
+7
-1
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
@@ -407,6 +407,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||
...(options.devices
|
||||
? {
|
||||
renewDeviceCredential: (c: Context) =>
|
||||
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (options.devices) {
|
||||
|
||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
||||
*/
|
||||
function writeDeviceCookie(
|
||||
c: Context,
|
||||
options: DeviceRoutesOptions,
|
||||
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||
name: string,
|
||||
value: string,
|
||||
maxAgeSeconds: number,
|
||||
@@ -106,6 +106,27 @@ 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 {
|
||||
const routes = new HonoApp();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||
* surface-owned files before binding and supplies their frozen contents here. */
|
||||
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,
|
||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
||||
if (!viewer) {
|
||||
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);
|
||||
} catch {
|
||||
return displayFailure(c);
|
||||
|
||||
@@ -782,6 +782,127 @@ 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 () => {
|
||||
const running = await platform("mixed");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
|
||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* How long a device credential is accepted before the device must pair again.
|
||||
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||
*
|
||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
||||
* 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.
|
||||
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||
*/
|
||||
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
|
||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
}
|
||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||
// checked above and is what makes a long horizon acceptable.
|
||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
||||
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- Status: Accepted
|
||||
- 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),
|
||||
[0032](0032-native-module-realtime-transport.md),
|
||||
[0033](0033-display-kiosk-host-contract.md),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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.
|
||||
@@ -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
|
||||
- [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
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.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)
|
||||
- [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
|
||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||
@@ -64,3 +64,4 @@ 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
|
||||
- [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
|
||||
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||
|
||||
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
|
||||
|
||||
## Publishing the train
|
||||
|
||||
The registry is the Gitea npm registry under the `Zayin` owner:
|
||||
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner:
|
||||
|
||||
```text
|
||||
https://git.zayin.net/api/packages/Zayin/npm/
|
||||
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
```
|
||||
|
||||
| Who | May |
|
||||
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
||||
{
|
||||
"publicationRecord": 1,
|
||||
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
||||
"registry": "https://git.zayin.net/api/packages/Zayin/npm/",
|
||||
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/",
|
||||
"finalTag": "latest",
|
||||
"completedAt": "<ISO time>",
|
||||
"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
|
||||
the publish step. Nobody needs a token to read or install.
|
||||
- **The publisher** is a dedicated account, member only of a `Zayin` team whose one
|
||||
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one
|
||||
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
|
||||
token.
|
||||
@@ -319,7 +319,7 @@ guarantee:
|
||||
- **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
|
||||
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 `Zayin` owner.
|
||||
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner.
|
||||
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
||||
check that.
|
||||
|
||||
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
|
||||
Before it, an administrator of the Gitea instance:
|
||||
|
||||
1. creates the dedicated publisher account;
|
||||
2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
|
||||
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages
|
||||
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
|
||||
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
||||
|
||||
@@ -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:
|
||||
|
||||
```ini
|
||||
@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
|
||||
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
```
|
||||
|
||||
Then the module builds and packages like any other:
|
||||
|
||||
@@ -110,7 +110,7 @@ for user in publisher outsider; do
|
||||
password_variable="${user}_password"
|
||||
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
||||
done
|
||||
owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||
for owner in "${owners[@]}"; do
|
||||
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"}}' |
|
||||
|
||||
Reference in New Issue
Block a user