349 lines
8.9 KiB
Plaintext
349 lines
8.9 KiB
Plaintext
-- allium: 1
|
|
-- scheduled-publishing.allium
|
|
|
|
-- Scope: Scheduled blog post publishing and authenticated author dashboard
|
|
-- Includes: Post visibility rules, date-based scheduling, authentication,
|
|
-- registration gating, author dashboard
|
|
-- Excludes:
|
|
-- - Post editing / CMS (posts are markdown files in git)
|
|
-- - Role-based access (single role: authenticated user)
|
|
-- - Multi-tenancy
|
|
-- - Post creation workflow
|
|
-- Constraints: Posts are compiled from markdown at build time (NimblePublisher).
|
|
-- All filtering is runtime — no database for post content.
|
|
|
|
------------------------------------------------------------
|
|
-- External Entities
|
|
------------------------------------------------------------
|
|
|
|
-- Posts are compiled from markdown at build time, not managed by this spec
|
|
external entity Post {
|
|
id: String
|
|
title: String
|
|
author: String
|
|
body: String
|
|
description: String
|
|
date: Date
|
|
tags: Set<String>
|
|
blog: Blog
|
|
published: Boolean
|
|
}
|
|
|
|
external entity Blog {
|
|
blog_id: String
|
|
title: String
|
|
description: String
|
|
base_path: String
|
|
|
|
posts: Post with blog = this
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Enumerations
|
|
------------------------------------------------------------
|
|
|
|
enum PostVisibility { live | scheduled | draft }
|
|
|
|
------------------------------------------------------------
|
|
-- Entities and Variants
|
|
------------------------------------------------------------
|
|
|
|
entity User {
|
|
email: String
|
|
password_hash: String
|
|
active_sessions: Session with user = this
|
|
|
|
-- Derived
|
|
is_authenticated: active_sessions.count > 0
|
|
}
|
|
|
|
entity Session {
|
|
user: User
|
|
token: String
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Value Types
|
|
------------------------------------------------------------
|
|
|
|
value PostWithVisibility {
|
|
post: Post
|
|
visibility: PostVisibility
|
|
|
|
-- Derived
|
|
days_until_live: if visibility = scheduled: post.date - today else: null
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Config
|
|
------------------------------------------------------------
|
|
|
|
config {
|
|
allowed_registration_email: String?
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Defaults
|
|
------------------------------------------------------------
|
|
|
|
-- Dev-only seed user
|
|
default User demo_user = {
|
|
email: "demo@example.com",
|
|
password_hash: hash("password123")
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Rules
|
|
------------------------------------------------------------
|
|
|
|
-- Post visibility
|
|
|
|
rule DeterminePostVisibility {
|
|
when: post: Post.created
|
|
ensures:
|
|
if not post.published:
|
|
post.visibility = draft
|
|
else if post.date > today:
|
|
post.visibility = scheduled
|
|
else:
|
|
post.visibility = live
|
|
}
|
|
|
|
rule FilterPublicPosts {
|
|
when: PublicPostsRequested(blog)
|
|
ensures: PublicPostsResolved(
|
|
posts: blog.posts where published and date <= today
|
|
)
|
|
}
|
|
|
|
rule FilterPublicPostsByTag {
|
|
when: PublicPostsByTagRequested(blog, tag)
|
|
let matching = blog.posts where published and date <= today
|
|
ensures: PublicPostsResolved(
|
|
posts: matching where tag in tags
|
|
)
|
|
}
|
|
|
|
rule ResolvePostForPublic {
|
|
when: PostRequested(blog, slug)
|
|
let post = blog.posts where id = slug
|
|
requires: exists post
|
|
ensures: PostResolved(post: post)
|
|
|
|
-- No date/published filter — direct URL access always works
|
|
guidance:
|
|
-- Direct URL access is intentionally unfiltered so authors
|
|
-- can share preview links with reviewers before publish date
|
|
}
|
|
|
|
rule GenerateFeed {
|
|
when: FeedRequested(blog)
|
|
ensures: FeedResolved(
|
|
posts: blog.posts where published and date <= today
|
|
)
|
|
}
|
|
|
|
-- Authentication
|
|
|
|
rule UserRegisters {
|
|
when: RegisterRequested(email, password)
|
|
requires: config.allowed_registration_email != null
|
|
requires: email = config.allowed_registration_email
|
|
ensures: User.created(
|
|
email: email,
|
|
password_hash: hash(password)
|
|
)
|
|
}
|
|
|
|
rule RegistrationRejectedWhenDisabled {
|
|
when: RegisterRequested(email, password)
|
|
requires: config.allowed_registration_email = null
|
|
ensures: RegistrationRejected(reason: "registration is invite only.")
|
|
}
|
|
|
|
rule RegistrationRejectedWhenEmailNotAllowed {
|
|
when: RegisterRequested(email, password)
|
|
requires: config.allowed_registration_email != null
|
|
requires: email != config.allowed_registration_email
|
|
ensures: RegistrationRejected(reason: "registration is invite only.")
|
|
}
|
|
|
|
rule UserLogsIn {
|
|
when: LoginRequested(email, password)
|
|
let user = User{email}
|
|
requires: exists user
|
|
requires: verify(password, user.password_hash)
|
|
ensures: Session.created(user: user)
|
|
}
|
|
|
|
rule UserLogsOut {
|
|
when: LogoutRequested(session)
|
|
ensures: not exists session
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Actor Declarations
|
|
------------------------------------------------------------
|
|
|
|
actor Author {
|
|
identified_by: User where is_authenticated
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Surfaces
|
|
------------------------------------------------------------
|
|
|
|
surface PublicBlogIndex {
|
|
facing visitor: User
|
|
|
|
context blog: Blog
|
|
|
|
let visible_posts = blog.posts where published and date <= today
|
|
|
|
exposes:
|
|
blog.title
|
|
blog.description
|
|
for post in visible_posts:
|
|
post.title
|
|
post.date
|
|
post.author
|
|
post.description
|
|
post.tags
|
|
|
|
provides:
|
|
PublicPostsRequested(blog)
|
|
|
|
related:
|
|
PublicPostDetail(post) when post in visible_posts
|
|
PublicBlogByTag(blog)
|
|
}
|
|
|
|
surface PublicPostDetail {
|
|
facing visitor: User
|
|
|
|
context post: Post
|
|
|
|
exposes:
|
|
post.title
|
|
post.date
|
|
post.author
|
|
post.body
|
|
post.description
|
|
post.tags
|
|
post.visibility
|
|
|
|
guidance:
|
|
-- Accessible by direct URL regardless of date or published status.
|
|
-- This allows authors to share preview links with reviewers.
|
|
-- When viewer is authenticated and post is not live:
|
|
-- draft → banner: "Draft — not published"
|
|
-- scheduled → banner: "This post is scheduled for {date}"
|
|
}
|
|
|
|
surface PublicBlogByTag {
|
|
facing visitor: User
|
|
|
|
context blog: Blog
|
|
|
|
let visible_posts = blog.posts where published and date <= today
|
|
|
|
exposes:
|
|
for tag in blog.all_tags:
|
|
tag
|
|
for post in visible_posts where tag in post.tags:
|
|
post.title
|
|
post.date
|
|
|
|
provides:
|
|
PublicPostsByTagRequested(blog, tag)
|
|
}
|
|
|
|
surface PublicFeed {
|
|
facing visitor: User
|
|
|
|
context blog: Blog
|
|
|
|
exposes:
|
|
blog.title
|
|
blog.description
|
|
for post in blog.posts where published and date <= today:
|
|
post.title
|
|
post.date
|
|
post.author
|
|
post.description
|
|
post.body
|
|
|
|
provides:
|
|
FeedRequested(blog)
|
|
|
|
guidance:
|
|
-- Serves RSS and Atom feeds.
|
|
-- Only includes posts that are published and not future-dated.
|
|
-- Feeds reflect the filtered list at request time; no cache-busting needed.
|
|
}
|
|
|
|
surface EditorDashboard {
|
|
facing author: Author
|
|
|
|
context blog: Blog
|
|
|
|
let drafts = blog.posts where not published
|
|
let scheduled = blog.posts where published and date > today
|
|
|
|
exposes:
|
|
blog.title
|
|
for draft in drafts:
|
|
draft.title
|
|
draft.date
|
|
draft.author
|
|
draft.blog
|
|
draft.visibility
|
|
for scheduled_post in scheduled:
|
|
scheduled_post.title
|
|
scheduled_post.date
|
|
scheduled_post.author
|
|
scheduled_post.blog
|
|
scheduled_post.visibility
|
|
scheduled_post.days_until_live
|
|
|
|
guidance:
|
|
-- Dashboard at /editor/dashboard.
|
|
-- Drafts and scheduled posts shown in separate tabs.
|
|
-- Unified timeline across all blogs (not grouped by blog).
|
|
-- Drafts sorted by date descending.
|
|
-- Scheduled posts sorted by date ascending (soonest first).
|
|
-- Scheduled posts show "X days until live" countdown.
|
|
|
|
related:
|
|
PublicPostDetail(post) when post in drafts or post in scheduled
|
|
}
|
|
|
|
surface Login {
|
|
facing visitor: User
|
|
|
|
provides:
|
|
LoginRequested(email, password)
|
|
|
|
guidance:
|
|
-- Login page is not linked from public navigation.
|
|
-- Authors access it directly by URL.
|
|
}
|
|
|
|
surface Registration {
|
|
facing visitor: User
|
|
|
|
provides:
|
|
RegisterRequested(email, password)
|
|
|
|
guidance:
|
|
-- Registration page is not linked from public navigation.
|
|
-- Rejects with "registration is invite only." when email does not
|
|
-- match ALLOWED_REGISTRATION_EMAIL or when the env var is unset.
|
|
}
|
|
|
|
------------------------------------------------------------
|
|
-- Open Questions
|
|
------------------------------------------------------------
|
|
|
|
-- No open questions remaining
|