fix(devices): keep an in-use display paired by renewing its credential (ADR 0056) #94
+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 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 } from "./device-routes.js";
|
import { createDeviceRoutes, renewDeviceSessionCookie } 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,6 +407,12 @@ 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) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
|||||||
*/
|
*/
|
||||||
function writeDeviceCookie(
|
function writeDeviceCookie(
|
||||||
c: Context,
|
c: Context,
|
||||||
options: DeviceRoutesOptions,
|
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
maxAgeSeconds: number,
|
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 {
|
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());
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ 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,
|
||||||
@@ -199,6 +202,11 @@ 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,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 () => {
|
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);
|
||||||
|
|||||||
@@ -44,17 +44,26 @@ 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 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
|
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||||
* 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;
|
||||||
@@ -475,7 +484,8 @@ 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.
|
||||||
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 undefined;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- 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),
|
||||||
|
|||||||
@@ -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
|
- [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
|
- [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
|
- [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,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
|
- [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
|
||||||
|
|||||||
Reference in New Issue
Block a user